@design-twin/dtwin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
@@ -0,0 +1,1265 @@
1
+ #!/usr/bin/env -S bun --no-install --no-env-file
2
+ // The shebang runs this TypeScript source directly (it is what the package's `bin` points at — there is
3
+ // no build). `--no-install`: Bun never auto-installs a package a project lacks from the network (a
4
+ // missing dependency is an error, not a download); `--no-env-file`: the project's .env is not loaded
5
+ // into this process (a FIGMA_BRIDGE_PORT there would silently move the bridge). `env -S` splits the
6
+ // line into its arguments on Linux (GNU coreutils >= 8.30) and macOS alike; BusyBox's env (Alpine) has no
7
+ // -S, so there the documented form is `bun --no-install --no-env-file <this file>` (bridge/README.md).
8
+ //
9
+ // ROUTING (see `cli()` near the bottom of this file): `dtwin mcp`, `dtwin init`, `dtwin doctor`,
10
+ // `dtwin seed` and the verbs are dispatched there before anything else runs — in particular before the settings are read.
11
+ // `dtwin mcp` routes to the MCP server (figma-mcp.ts). The .mcp.json entry `dtwin init --mcp` writes names
12
+ // figma-mcp.ts itself (an absolute bun and path); this route must not disturb any existing invocation: the branch is taken
13
+ // ONLY when this file is the process entry AND the first argument is exactly "mcp", so `dtwin …` and
14
+ // importing this module from the test suites are untouched, and `bun bridge/src/figma-mcp.ts` still
15
+ // works when invoked directly. Everything that reads argv lives inside the async `cli()`, where a
16
+ // plain `return` stops the rest of the CLI (arg parsing, bridge setup) from ever loading in MCP mode;
17
+ // the module top level holds only side-effect-free imports and the pure, test-driven helpers.
18
+
19
+ // figma-pull — READ plane CLI.
20
+ // Connects to the running Figma plugin over the localhost bridge, pulls the full
21
+ // design system + all page frames (or the current selection), and writes them to
22
+ // disk. The agent then Reads those files selectively (context-economical).
23
+ //
24
+ // Usage: the USAGE constant below (after the imports) is the reference for every command and flag —
25
+ // it is exactly what `dtwin --help` prints, so the help can never drift from the one place they are
26
+ // documented.
27
+
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import * as LAYOUT from "./project-layout.ts";
31
+ // TIMEOUTS is the per-command budget table both front-ends read (timeouts.ts); a read's own tier comes from
32
+ // read-plan.ts, so the CLI and the MCP tools cannot drift apart. parseArgs and formatClients are pure and test-driven:
33
+ // importing this module resolves no token and reads no port — cli() reads the settings (settings.ts) once the
34
+ // arguments are parsed.
35
+ import { TIMEOUTS, MAX_TIMER_MS } from "./timeouts.ts";
36
+ import { errMsg } from "./errmsg.ts";
37
+ import { emitTimings, stageClock, timingsTarget } from "./timings.ts";
38
+ import * as tokenStore from "./token-store.ts";
39
+ import { pluginStalenessNote, daemonRowStalenessNote } from "./staleness.ts";
40
+ import type { ClientRow } from "./server-core.ts";
41
+ // No load-time effects (settings.ts resolves what it once resolved at load), so a static import.
42
+ import { createBridge } from "./server-core.ts";
43
+ // The command/reply contract shared with the plugin (commands.ts): `send()` below is typed per command
44
+ // from it, so this file cannot read a reply field the plugin never sends (a hand-mirror once
45
+ // declared a top-level `frames` that did not exist, and two hints silently never printed).
46
+ import type { Cmd, Commands, ListChildrenReply, ListLibrariesReply, ListPagesReply, NodeSummary } from "./commands.ts";
47
+ // node-id.ts is "the ONE place that knows what a node id looks like and how it hides in a Figma URL"
48
+ // (its own header). --children's raw token is not handed straight to the plugin, whose only
49
+ // normalisation is replace(/-/g, ":") — a pasted design URL would work through the MCP twin
50
+ // (figma_list_children calls parseNodeId) and fail here. Same input, same answer, one parser.
51
+ import { toNodeId } from "./node-id.ts";
52
+ // write-out.ts is the ONE writer, shared with the MCP export tools' writeToDisk path — so a file
53
+ // pulled through the CLI and the same file pulled through MCP land in the same layout, by
54
+ // construction rather than by two implementations agreeing. The `log` argument is what differs:
55
+ // this front-end narrates every file to stderr, the MCP one stays silent.
56
+ import * as OUT from "./write-out.ts";
57
+ import { meteredRequest, runRead } from "./read-run.ts";
58
+ import type { ReadRun } from "./read-run.ts";
59
+ // The ONE registry of read options, shared with the plugin's run reads and the MCP tool schema.
60
+ import { READ_OPTS } from "./read-opts.ts";
61
+ import type { ReadOptName } from "./read-opts.ts";
62
+ // What a pull SENDS — command, arguments, budget, label — is the read plan's, shared with the MCP export tools.
63
+ import { planRead } from "./read-plan.ts";
64
+ import type { PlanOf, ReadCmd, ReadPlan, ReadRefusal, ReadScope } from "./read-plan.ts";
65
+ // daemon.ts holds the bridge open across invocations. Every ordinary command below goes through the bridge
66
+ // holder (bridge-holder.ts), which asks it for a connection FIRST and falls back to opening its own bridge — so the
67
+ // one-shot behaviour is unchanged when no daemon is running, and no command needs to know which mode it is in.
68
+ import * as daemon from "./daemon.ts";
69
+ import { bridgeHolder, daemonSide } from "./bridge-holder.ts";
70
+ import { daemonAddressOf, humanMs, readSettings, tokenOverridden } from "./settings.ts";
71
+ import type { BridgeSettings } from "./settings.ts";
72
+ import { createRoute } from "./route.ts";
73
+ import { ALLOWED_PORTS } from "./ports.ts";
74
+ import type { Route, Sent } from "./route.ts";
75
+ import { translate, VerbError, verbHelp, distance, dtwinPortWarning } from "./verbs.ts";
76
+ // THE command registry (cli-commands.ts): every command's flag, family, read and verb spellings, the
77
+ // combination guards per family, the modifiers and the help. Parsing, the guards, unknown-flag detection,
78
+ // dispatch and the help below are derived from it; each command's runner is in RUN_* below, typed by its id.
79
+ import { COMMAND_ROWS, COMMAND_SUMMARY, DEFAULT_COMMAND, FAMILIES, GUARDS, MCP_HELP, MODIFIERS, VALUE_MODIFIERS, VALUE_READ_ORDER } from "./cli-commands.ts";
80
+ import type { CommandId, CommandIdIn, CommandRow, Family, RoutedVerb, SessionFamily, ValueModifier } from "./cli-commands.ts";
81
+ import { ifDefined, isUnknownArray } from "./json-util.ts";
82
+ // The package's own package.json, imported (not read by a path at run time, so a compiled binary has it
83
+ // too): `dtwin --version` prints its version.
84
+ import pkg from "../package.json" with { type: "json" };
85
+ import { CliExit, exitCli, exitFlushed, runCli } from "./stdio.ts";
86
+
87
+ // --help / -h: print USAGE and exit 0. Handled in cli() BEFORE the settings are read, so help has no
88
+ // side effects at all — no token minted, no port bound. A constant, not text read back from this file
89
+ // at run time: a compiled `dtwin` binary has no source file beside it to read.
90
+ const USAGE = `Usage:
91
+ Quick start (Figma file open, the "Design Twin" plugin running):
92
+ dtwin list # what is in the file: pages + their top-level LAYERS, with ids
93
+ dtwin screenshot 12:34 # a reference PNG of one node — look before you pull
94
+ dtwin pull --node 12:34 # export ONE screen into design/export (tokens, tree, assets)
95
+ dtwin doctor # something not working? checks token, port, daemon, plugin, project
96
+ More than one Figma file connected? Add --client <fileKey|part of the file name> to every command
97
+ above that reaches the plugin — see \`dtwin list clients\` for the address book.
98
+
99
+ Where things land: everything a pull writes goes under design/export/ and nowhere else, so that
100
+ directory can be deleted and re-pulled without touching design/target.json, design/codeconnect.local.json,
101
+ design/plan/ or design/audit/ — the files you own. \`design/README.md\` (written by init) says so too.
102
+
103
+ Commands (each is shorthand for a flag documented below — the flags keep working unchanged):
104
+ ${COMMAND_SUMMARY}
105
+
106
+ Every command above answers \`--help\` with its own page, and none of them does anything else
107
+ while doing so.
108
+ A command is only recognised as the FIRST argument: \`dtwin design\` still pulls into ./design.
109
+ For an outDir spelled like a command, write \`dtwin pull list\` or \`dtwin ./list\`.
110
+
111
+ Full reference:
112
+ dtwin init [--mcp] # set up the project you are BUILDING: design/, target.json, the bridge
113
+ # token, (optionally) .mcp.json — then prints the steps left. See init --help
114
+ dtwin [outDir] # full: design-system.json + current-page frames + assets
115
+ dtwin [outDir] --all-pages # like full, but frame trees from EVERY page
116
+ dtwin [outDir] --selection # just the current selection
117
+ dtwin [outDir] --page <id|name> # ONE named page (repeatable; ids come from --list)
118
+ dtwin [outDir] --node <id> # ONE node, fully exported (properties + assets) — the
119
+ # "paste a Figma link" path. Takes a bare id, dash form,
120
+ # percent-encoded id, or a whole figma.com URL. Unlike
121
+ # --children (peek only) and --screenshot (PNG only) this
122
+ # walks the subtree and exports its assets, same as a
123
+ # --page pull would for that subtree. The CLI twin of the
124
+ # MCP figma_export_url tool.
125
+ dtwin [outDir] --design-system # ONLY tokens/styles/components/hygiene — no page
126
+ # walk, no assets (the cheap "just the design
127
+ # system" pull). Each catalogued component/variant
128
+ # carries its OWN fills/strokes/effects/cornerRadius/
129
+ # opacity/blendMode (\`visuals\`) — plain synchronous
130
+ # property reads, not the async cost this mode
131
+ # exists to avoid. NOTE: this is the component
132
+ # NODE's own paint (its default/base variant look),
133
+ # not per-instance overrides elsewhere in the file —
134
+ # and components consumed from a published library
135
+ # (not defined in this file) are NOT covered: pull
136
+ # --as-library on the source library file for those.
137
+ # Add --variant-visuals for the SET's own variants'
138
+ # real paint too (see below) — the one read option
139
+ # this mode accepts.
140
+ dtwin [outDir] --as-library <name> # the COMPLETE catalog of a LIBRARY file —
141
+ # every variable with full per-mode values, every
142
+ # style, every component. Run it with the LIBRARY
143
+ # file open, not the design file that consumes it.
144
+ # Writes <outDir>/libraries/<slug>-<fileKey8>/ (by
145
+ # default design/export/libraries/…) and
146
+ # never touches design-system/.
147
+ dtwin [outDir] --timeout N # seconds to wait for the export (default: 300,
148
+ # 900 with --all-pages, 120 for --selection); also
149
+ # raises the list commands' own 300s budget
150
+
151
+ Optional Tier-1 reads (opt-in — each costs extra Plugin-API work):
152
+ --css # Figma's own computed CSS per node (getCSSAsync) — the design-to-code oracle
153
+ --measurements # measurement redlines (spacing specs the designer placed) for the exported page(s).
154
+ # NOT Dev-Mode-only: per developers.figma.com only add/edit/deleteMeasurement are.
155
+ --plugin-data # own-scope plugin data stamped on nodes
156
+ --motion # motion/animation reads (timelines, keyframe tracks, animations, styles)
157
+ --shared-data # cross-plugin shared data (Tokens Studio applied tokens via getSharedPluginData)
158
+ --variant-visuals # walk each COMPONENT_SET's variants and attach their REAL layout/fills/radius/
159
+ # tokens (not the set wrapper's own selection-chrome visuals). One node walk
160
+ # per variant, so slower on large systems. The ONE read option --design-system
161
+ # and --as-library both accept (see below) — it enriches the component catalog
162
+ # they build, unlike the others which need a page/node walk neither does.
163
+
164
+ Look before you pull (cheap RELATIVE to an export — no recursion, no assets, no node properties;
165
+ prints JSON to stdout):
166
+ dtwin --list # pages + their top-level frames (ids to deep-pull next).
167
+ # Loads each page (Figma warns loading pages is slow on large
168
+ # files) — cheap next to an export, not free. Use --list-pages
169
+ # if you only need the page names.
170
+ dtwin --list-pages # page names only (near-free: does not load any page)
171
+ dtwin --children <id> # ONE node's direct children only (peek inside a frame from
172
+ # --list before committing to a full recursive --page pull)
173
+ dtwin --screenshot <id> [--scale N] # on-demand PNG of ONE node (a component/instance
174
+ # buried in a dense screen, say) — the visual-validation
175
+ # counterpart to --list/--children. WRITES assets/<id>_ref.png
176
+ # (with --scale: assets/<id>_shot@<N>x.png)
177
+ # (unlike --list/--children, which only print), but is still
178
+ # cheap: it skips serialize() and the recursive asset walk, so
179
+ # exportAsync on the node itself is the only cost. --scale
180
+ # overrides the default (auto, capped at 2048px on the longest
181
+ # side). Mirrors Figma's own get_screenshot tool (single-node,
182
+ # called on demand) rather than pre-rendering every node.
183
+ dtwin --list-libraries # which design libraries this file draws on (local + enabled
184
+ # team libraries), their variable collections, and how many
185
+ # of their components this file USES. The discovery step
186
+ # before a pull, exactly as --list is before --page.
187
+ # Honest limits: Figma exposes NO API to enumerate a
188
+ # library's full contents, so component counts are
189
+ # USAGE-derived; and libraries can only be enabled in the
190
+ # Figma UI (never via API), so an export is silently scoped
191
+ # to whatever was enabled at export time. Empty results are
192
+ # a NORMAL outcome (free plan / no library enabled), not a
193
+ # failure — they are reported as such.
194
+ # Requires the plugin manifest's "teamlibrary" permission:
195
+ # RE-IMPORT/reload the plugin in Figma after updating, or a
196
+ # stale plugin silently returns nothing.
197
+ dtwin --whoami # who is connected: plugin instance id, file name, whether
198
+ # figma.fileKey is available, socket uptime, and how many times
199
+ # a new connection displaced an earlier one — read from the
200
+ # daemon when one holds the port, so the numbers are real
201
+ # either way. The probe for
202
+ # "can two Figma files use the bridge at once?" — run it from
203
+ # each open file and compare instanceId. Costs nothing (no page
204
+ # load, no node walk, no assets) and writes no files.
205
+ --json # machine output for the two that print a TABLE (--list-clients, --list-libraries).
206
+ # The others above already print JSON; there it is accepted as a no-op.
207
+ These take NO read options (--css/--measurements/--plugin-data/--motion/--shared-data/
208
+ --no-assets): they emit structural fields only, so those flags are refused rather than ignored.
209
+ --timeout DOES apply to them — to the command AND to the wait for the plugin to connect
210
+ (default: 10 min at a terminal, 90 s when stderr is not a TTY, e.g. run by an agent).
211
+
212
+ Speed:
213
+ --no-assets # skip the per-node SVG/PNG export pass (the dominant cost on a big file).
214
+ # Structure + tokens are unaffected; the manifest reports how many were skipped.
215
+
216
+ Keep the connection open (daemon):
217
+ dtwin --serve # hold the bridge open until stopped. Every command above then
218
+ # routes through it automatically and skips the reconnect.
219
+ dtwin --stop # stop it
220
+ dtwin --daemon-status # is one running, and is the plugin connected?
221
+ Only ONE process can hold port 8787 — while a daemon (or the MCP server) is up, a second bridge
222
+ exits with EADDRINUSE. That is exactly what routing through the daemon avoids.
223
+
224
+ The bridge token (auth for the plugin -> bridge handshake). Generated once on the first bridge
225
+ start, saved to a per-user config file (0600), and reused forever after — so you paste it into the
226
+ plugin ONCE. These commands need no plugin and no bridge:
227
+ dtwin --token-status # where it lives, which source wins, its fingerprint — never the token
228
+ dtwin --show-token # print the token itself (stdout only, so \`| pbcopy\` works)
229
+ dtwin --rotate-token # replace it — you must then re-paste it into the plugin
230
+ dtwin --forget-token # delete it; the next bridge start mints a new one
231
+ dtwin --token-file <p> # read the token from <p> for this run instead of the stored one
232
+ Precedence: --token-file > FIGMA_BRIDGE_TOKEN > the saved file > mint a new one.
233
+ There is no \`--token <value>\`: argv is world-readable via \`ps\`, so passing a secret there leaks
234
+ it to every other user on the machine. Use --token-file, or the env var.
235
+
236
+ Port:
237
+ The bridge listens on 8787. FIGMA_BRIDGE_PORT=8788|8789 runs it on another of the three ports the
238
+ plugin tries, e.g. a second bridge beside the MCP server. There is no --port flag, and DTWIN_PORT is not read.
239
+
240
+ The Figma file must be open with the "Design Twin" plugin running.`;
241
+
242
+ function usageText(): string {
243
+ return "dtwin — pull a design out of a running Figma file (Design Twin plugin) onto disk.\n\n" + USAGE;
244
+ }
245
+
246
+ const plog = (m: string) => console.error("[dtwin] " + m);
247
+ const writeJson = (dir: string, name: string, obj: unknown, quiet?: boolean) => OUT.writeJson(dir, name, obj, quiet, plog);
248
+ // hygiene.json persists every warning (see design-system-layout.ts), but writeJson's own log line is
249
+ // just "wrote design-system/hygiene.json" — a caller watching stderr would never see a DUPLICATE
250
+ // COMPONENT NAME or a variant-explosion warning without a separate JSON read. Echo them to stderr
251
+ // here so hygiene surfaces the same run it was produced in, not only on a later manual diff.
252
+ function printHygiene(r: { designSystem: { hygiene?: string[] } }): void {
253
+ const hygiene = Array.isArray(r.designSystem.hygiene) ? r.designSystem.hygiene : [];
254
+ if (!hygiene.length) return;
255
+ plog(`hygiene (${hygiene.length}):`);
256
+ for (const h of hygiene) console.error(" - " + h);
257
+ }
258
+
259
+ // Argument parsing is plain functions the test suite drives directly: readArgs reads the flags and
260
+ // throws UsageError for a bad or missing value, validateArgs returns the outDir warning and the first
261
+ // flag-combination refusal without printing, and parseArgs is the two in one call, throwing on a
262
+ // refusal. None calls process.exit — the CLI entry below turns errors into the message + exit 1.
263
+ // Untestable arg parsing is how `--timeout` shipped silently ignoring its own flag when the value was
264
+ // missing.
265
+ class UsageError extends Error {}
266
+
267
+ // ONE parser for every `--flag value` / `--flag=value` pair, repeatable by construction. One
268
+ // shared rule marks every consumed index in consumedIdx, so the positional [outDir] is never a flag's
269
+ // value (hand-rolled copies drifted into subtly different shapes, and `--timeout` once mishandled its own
270
+ // missing value). A further value flag is one line.
271
+ // Matching the flag EXACTLY (plus its `=` form) is part of the rule: startsWith("--timeout") would
272
+ // also swallow a future `--timeout-ms`, reading another flag's value as this one's.
273
+ // Returns [] when the flag is absent; throws UsageError when it's present with no value.
274
+ function takeValues(args: string[], flag: string, missingMsg: string, consumedIdx: Set<number>): string[] {
275
+ const eq = flag + "=";
276
+ const out: string[] = [];
277
+ for (let i = 0; i < args.length; i++) {
278
+ const a = args[i];
279
+ if (a === undefined) continue; // cannot happen: i < args.length
280
+ if (a === flag) {
281
+ const next = args[i + 1];
282
+ if (next === undefined || next.startsWith("--")) throw new UsageError(missingMsg);
283
+ out.push(next);
284
+ consumedIdx.add(i + 1);
285
+ i++;
286
+ } else if (a.startsWith(eq)) {
287
+ const v = a.slice(eq.length);
288
+ if (!v) throw new UsageError(missingMsg);
289
+ out.push(v);
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+
295
+ // Every flag there is (cli-commands.ts: the commands', then the modifiers'): what the unknown-flag check
296
+ // knows, in the order its did-you-mean breaks a tie in. VALUE_FLAGS are the ones that also take `--flag=value`.
297
+ const KNOWN_FLAGS: string[] = [
298
+ ...COMMAND_ROWS.flatMap((r) => (r.flag === null || r.value ? [] : [r.flag])), ...MODIFIERS.filter((m) => !m.value).map((m) => m.flag),
299
+ ...COMMAND_ROWS.flatMap((r) => (r.flag === null || !r.value ? [] : [r.flag])), ...MODIFIERS.filter((m) => m.value).map((m) => m.flag),
300
+ ];
301
+ const VALUE_FLAGS: string[] = [...COMMAND_ROWS.flatMap((r) => (r.flag !== null && r.value ? [r.flag] : [])), ...MODIFIERS.filter((m) => m.value).map((m) => m.flag)];
302
+ const isValueModifier = (f: string): f is ValueModifier => (VALUE_MODIFIERS as readonly string[]).includes(f);
303
+
304
+ function readArgs(args: string[]) {
305
+ // Value-taking flags (space form: `--flag value`) consume the NEXT token — that token must never be
306
+ // mistaken for the positional [outDir] below. `consumedIdx` tracks every such value's index so
307
+ // outDir detection can skip them, not just skip tokens that literally start with "--".
308
+ const consumedIdx = new Set<number>();
309
+
310
+ // --scale <n>: override collectReference's default (auto, capped at 2048px on the longest side). Only
311
+ // meaningful paired with --screenshot — refused standalone, the same silent-loss class as a read
312
+ // option combined with --list.
313
+ const BAD_SCALE = "--scale expects a positive number";
314
+ // A missing VALUE is distinct from a missing FLAG, and takeValues makes that structural: `--timeout`
315
+ // with nothing after it (or another flag after it) throws here rather than leaving `undefined` for the
316
+ // validator's own `!== undefined` guard to EXEMPT — which is how the flag once got silently ignored
317
+ // and you got the default, the one outcome an explicit timeout exists to prevent, invisible until the
318
+ // export died at a limit you thought you had raised.
319
+ // One message for both failures (absent value, unusable value) so they cannot drift apart.
320
+ // At most what one timer can hold: setTimeout runs a longer delay (and Infinity) after 1 ms
321
+ // (nodejs.org/api/timers.html), so a request would time out at once; and a daemon frame cannot carry
322
+ // Infinity at all (JSON writes it as null).
323
+ const MAX_TIMEOUT_SEC = Math.floor(MAX_TIMER_MS / 1000);
324
+ const BAD_TIMEOUT = `--timeout expects a positive number of seconds (at most ${MAX_TIMEOUT_SEC}, about 24 days)`;
325
+ const MISSING: Record<ValueModifier, string> = {
326
+ "--scale": BAD_SCALE,
327
+ "--client": "--client needs a connection id, fileKey, or part of a file name (see --list-clients)",
328
+ "--token-file": "--token-file needs a path to a file containing the token",
329
+ "--timeout": BAD_TIMEOUT,
330
+ };
331
+
332
+ // Every value-taking flag, read in VALUE_READ_ORDER (cli-commands.ts: which of several missing values is
333
+ // refused first). A value-taking command is typed when it has a value (takeValues refuses it with none);
334
+ // a node id goes through node-id.ts's lenient toNodeId — the same inputs the MCP twins accept (bare id,
335
+ // dash form, percent-encoded, nested-instance path, or a whole figma.com URL), and an id shape it does not
336
+ // recognise passes through rather than becoming a hard failure. Only --page is repeatable (docker -e /
337
+ // curl -H convention); ambiguous/unknown page selectors fail loudly plugin-side with the available pages
338
+ // listed. A --as-library name is required rather than defaulted from figma.root.name: it is what the
339
+ // output directory is named after, and a silent default is a directory the user did not choose.
340
+ const values = new Map<CommandId, string[]>();
341
+ const modifier = new Map<ValueModifier, string>();
342
+ for (const f of VALUE_READ_ORDER) {
343
+ const row = COMMAND_ROWS.find((r) => r.flag === f);
344
+ if (row?.value) {
345
+ const taken = takeValues(args, f, row.value.missing, consumedIdx);
346
+ const v = (row.value.many ? taken : taken.slice(0, 1)).map((x) => (row.value?.nodeId ? toNodeId(x) : x));
347
+ if (v.length) values.set(row.id, v);
348
+ } else if (isValueModifier(f)) {
349
+ const v = takeValues(args, f, MISSING[f], consumedIdx)[0];
350
+ if (v === undefined) continue;
351
+ modifier.set(f, v);
352
+ if (f !== "--scale") continue;
353
+ if (!(Number(v) > 0)) throw new UsageError(`${BAD_SCALE}, got '${v}'`);
354
+ // Refused here while reading, not in validateArgs: this error comes before the outDir warning.
355
+ if (!values.has("screenshot")) throw new UsageError("--scale only applies to --screenshot — pass both, or drop --scale.");
356
+ }
357
+ }
358
+ const scaleArg = modifier.get("--scale");
359
+ const scale = scaleArg !== undefined ? Number(scaleArg) : undefined;
360
+ // --client: WHICH connected Figma file this command talks to. Unlike every command flag it is not a
361
+ // scope or a read option — it is the ADDRESS, and it composes with all of them. Omit it and the
362
+ // bridge uses the only connected file; with several connected it refuses and lists them rather than
363
+ // guessing, so an export can never silently come from the wrong file. Accepts a connId (from
364
+ // --list-clients), a fileKey, or part of the file's name. The connId (c1, c2, …) is a reconnect-order
365
+ // LABEL for the current bridge lifetime, not a stable id across restarts — prefer the
366
+ // file name or fileKey in a script; connId is fine for a human picking between two files open right now.
367
+ const client = modifier.get("--client") || null;
368
+ // --token-file <path>: read the bridge token from somewhere other than the stored default. A
369
+ // MODIFIER (it changes which token every other command uses), not a command.
370
+ //
371
+ // There is deliberately NO `--token <value>` twin. Process arguments are world-readable — `ps aux`,
372
+ // /proc/<pid>/cmdline — so a value flag would hand the secret to every other user on the machine for
373
+ // as long as the command runs. A path is not a secret; the file it points at is.
374
+ const tokenFile = modifier.get("--token-file") || null;
375
+
376
+ // The commands typed, in the command table's order (cli-commands.ts) — the order a refusal names them in.
377
+ // Matched EXACTLY (`includes`, or takeValues' flag and `=` form), so "--list-libraries" is not swallowed by
378
+ // "--list" — the prefix trap takeValues() guards against.
379
+ const given: CommandRow[] = COMMAND_ROWS.filter((r) => r.flag !== null && (r.value ? values.has(r.id) : args.includes(r.flag)));
380
+ // The one command this line names, once the guards have passed (validateArgs); with none, a pull of the current page.
381
+ const command = given[0] ?? DEFAULT_COMMAND;
382
+ const valueOf = (id: CommandId): string | null => values.get(id)?.[0] ?? null;
383
+
384
+ // --no-assets REMOVES work (every other flag adds it): skips the per-node exportAsync render pass,
385
+ // which dominates the export on a real design-system file. Structure, layout and tokens are
386
+ // unaffected — a skipped node stays the same LEAF the full export produces, marked
387
+ // `assetSkipped: true` instead of carrying an `asset` path; only assets/ goes missing.
388
+ // Flag -> option name comes from bridge/src/read-opts.ts, the ONE registry shared with the plugin's
389
+ // run reads and the MCP tool schema. Building the table from it (rather than restating the six names
390
+ // here) is what lets the guards name the offending flags without a second hand-written list —
391
+ // a twin that would silently stop matching the moment a read option is added.
392
+ const readOpts: Partial<Record<ReadOptName, boolean>> = {};
393
+ const readOptFlagsGiven: string[] = [];
394
+ for (const o of READ_OPTS) {
395
+ readOpts[o.name] = args.includes(o.flag);
396
+ if (readOpts[o.name]) readOptFlagsGiven.push(o.flag); // same predicate by construction, one walk
397
+ }
398
+
399
+ const timeoutArg = modifier.get("--timeout");
400
+ const timeoutSec = Number(timeoutArg);
401
+ if (timeoutArg !== undefined && !(timeoutSec > 0 && timeoutSec <= MAX_TIMEOUT_SEC)) throw new UsageError(`${BAD_TIMEOUT}, got '${timeoutArg}'`);
402
+ // One override, applied to every budget below — spelling the `--timeout` conversion per budget is how
403
+ // the list ops came to be missed by the flag in the first place. A read's own budget (by scope) is the
404
+ // read plan's (read-plan.ts); `timeoutOverrideMs` is what it is handed, and absent without the flag.
405
+ const overrideMs = timeoutSec > 0 ? timeoutSec * 1000 : undefined;
406
+ const timeoutOverrideMs = overrideMs;
407
+ // The list ops get their own budget, and --timeout raises it too — they are cheap RELATIVE to an
408
+ // export, not fast (see TIMEOUTS.list). Without this the command you are told to run FIRST would be
409
+ // the first to fail on the big files where looking before you pull is the whole point, with no flag to
410
+ // rescue it, since --timeout only ever reached the export branch.
411
+ const listTimeoutMs = overrideMs ?? TIMEOUTS.list;
412
+ // How long to wait for the Figma plugin to CONNECT, before any command is sent. --timeout bounds this
413
+ // too — the help has always said it applies to the discovery commands, and a wait it cannot shorten
414
+ // made `dtwin list --timeout 5` sit for ten minutes. Without the flag: ten minutes at a terminal (a
415
+ // person is walking over to Figma), but 90 s when nobody is watching stderr — an agent's shell gives
416
+ // up at two minutes, and a command that outlives it reads as a hang with no message at all.
417
+ const connectWaitMs = overrideMs ?? (process.stderr.isTTY ? 600000 : 90000);
418
+
419
+ // Unknown flags are REFUSED, not absorbed. Every flag above is matched by exact `includes`, so a typo
420
+ // (`--lsit`, `--al-pages`) would match nothing and fall through to the default — a full pull that
421
+ // then waits on the plugin: the wrong command, run silently. Same silent-loss class as the guards
422
+ // below, one step earlier. Runs after every takeValues() call so a flag's VALUE is never judged.
423
+ const OUT_FLAG_ALIASES = ["--out", "-o", "--output", "--out-dir", "--outdir", "--dir"];
424
+ const unknown = args.filter((a, i) => a.startsWith("-") && a !== "-h" && !consumedIdx.has(i)
425
+ && !KNOWN_FLAGS.includes(a) && !VALUE_FLAGS.some((f) => a.startsWith(f + "=")));
426
+ if (unknown.length) {
427
+ // `--out`/`-o`/`--output` are what people reach for to name where the file lands, and they are
428
+ // too far (> 2 edits) from every real flag for the did-you-mean below to help. outDir is positional.
429
+ const outLike = unknown.find((u) => OUT_FLAG_ALIASES.includes(u.split("=")[0] ?? u));
430
+ if (outLike) {
431
+ throw new UsageError(`unknown flag: ${outLike}. outDir is positional: \`dtwin screenshot <id> <outDir>\` (the PNG lands in <outDir>/assets/<id>_ref.png) or \`dtwin pull <outDir> --node <id>\`; there is no ${outLike.split("=")[0]} flag.`);
432
+ }
433
+ // "Did you mean": the nearest known flag by edit distance, offered only when it is a plausible typo.
434
+ const hints = unknown.map((u) => {
435
+ const eqAt = u.indexOf("=");
436
+ const name = eqAt === -1 ? u : u.slice(0, eqAt); // u.split("=")[0]
437
+ // The FIRST nearest flag in KNOWN_FLAGS order — what the stable `sort(...)[0]` picked. reduce()
438
+ // without a seed needs a non-empty array; KNOWN_FLAGS is a non-empty literal list.
439
+ const best = KNOWN_FLAGS.map((f) => [distance(name, f), f] as const).reduce((x, y) => (y[0] < x[0] ? y : x));
440
+ return best[0] <= 2 ? `${u} (did you mean ${best[1]}?)` : u;
441
+ });
442
+ throw new UsageError(`unknown flag${unknown.length > 1 ? "s" : ""}: ${hints.join(", ")}. Run dtwin --help for the full list.`);
443
+ }
444
+
445
+ // The positional [outDir] — the first token that's neither a `--flag` nor a value a flag above already
446
+ // claimed. Doing this AFTER parsing every value-taking flag is what keeps e.g. `--children 131:1879`
447
+ // (no outDir given) from being misread as `outDir = "131:1879"`.
448
+ // Default: design/export — dtwin writes only there, so `rm -rf design/export && re-pull` cannot take
449
+ // the component map, the plan or the audit with it. A project created under the older flat layout
450
+ // keeps working: its export is still found (bridge/src/project-layout.ts findExportDir).
451
+ const userOutDir = args.find((a, i) => !a.startsWith("--") && !consumedIdx.has(i));
452
+ const outDir = userOutDir ||
453
+ (LAYOUT.findExportDir(process.cwd()).layout === "legacy-flat" ? LAYOUT.DESIGN_DIR : LAYOUT.EXPORT_DIR);
454
+
455
+ const json = args.includes("--json");
456
+ // --list is pages + their top-level layers; --list-pages is depth 1, page names only (no page load).
457
+ const listDepth: 1 | 2 = command.id === "listPages" ? 1 : 2;
458
+
459
+ return {
460
+ command, given, readOpts, readOptFlagsGiven,
461
+ // Each value-taking command's value (null, or [] for --page, when it was not typed).
462
+ nodeId: valueOf("node"), childrenId: valueOf("children"), screenshotId: valueOf("screenshot"), asLibrary: valueOf("asLibrary"), pageSel: values.get("page") ?? [],
463
+ allPages: given.some((r) => r.id === "allPages"),
464
+ listDepth,
465
+ scale, client, tokenFile, json, timeoutOverrideMs, listTimeoutMs, connectWaitMs, outDir, userOutDir,
466
+ };
467
+ }
468
+
469
+ export type ParsedArgs = ReturnType<typeof readArgs>;
470
+
471
+ // `dtwin pull design --node <id>` is the outDir trap — `design` is a valid, deliberate
472
+ // positional outDir (never special-cased; see the note above), but when the project ALREADY has a
473
+ // design/export/ tree, writing into `design` too creates a SECOND, parallel export tree that every
474
+ // other command (doctor, cross-check, audit, build-screen) keeps ignoring. Warn, don't refuse — a
475
+ // project genuinely named "design" for something else is legitimate, and refusing would be the parser
476
+ // getting magic about one string.
477
+ // Returns the warning text (the caller prints it as `[dtwin] warn: …`), or null.
478
+ function outDirWarning(userOutDir: string | undefined, exists: (p: string) => boolean): string | null {
479
+ if (!userOutDir) return null;
480
+ const requested = path.resolve(process.cwd(), userOutDir);
481
+ if (!exists(path.join(requested, "export"))) return null;
482
+ return `${userOutDir} already contains ${path.join(userOutDir, "export")} — writing here too creates a` +
483
+ ` PARALLEL export tree that doctor/cross-check/audit/build-screen do not read. You almost certainly want` +
484
+ ` \`dtwin pull ${userOutDir === "design" ? "" : userOutDir + "/export "}...\` (drop the outDir to use the default` +
485
+ ` design/export, or pass the export dir itself) instead of writing into ${userOutDir}.`;
486
+ }
487
+
488
+ // The read a command names, as the read plan's scope (cli-commands.ts `reads`, with the values typed for it).
489
+ function scopeOf(row: CommandRow, p: ParsedArgs): ReadScope {
490
+ switch (row.reads) {
491
+ case "screenshot": return { kind: "screenshot", nodeId: p.screenshotId ?? "", scale: p.scale };
492
+ case "library": return { kind: "library", name: p.asLibrary ?? "" };
493
+ case "node": return { kind: "node", nodeId: p.nodeId ?? "" };
494
+ case "selection": return { kind: "selection" };
495
+ case "designSystem": return { kind: "designSystem" };
496
+ // A command that reads nothing has no scope; it is asked only for one that reads (cliReadPlan, refusedReadOpts).
497
+ case "pages": case null: return { kind: "pages", allPages: p.allPages, pages: p.pageSel };
498
+ }
499
+ }
500
+
501
+ // What this command line SENDS for its read (read-plan.ts — the same plan the MCP export tools send), with
502
+ // --timeout as its budget; null for a command that reads no design (an index, daemon or token command).
503
+ function cliReadPlan(p: ParsedArgs): ReadPlan | ReadRefusal | null {
504
+ if (p.command.reads === null) return null;
505
+ return planRead(scopeOf(p.command, p), p.readOpts, { timeoutMs: p.timeoutOverrideMs });
506
+ }
507
+
508
+ // The read options given that a command does not take, as their flags: every one for a command that reads
509
+ // no design; otherwise the ones its read plan refuses (read-opts.ts `scopes`).
510
+ function refusedReadOpts(row: CommandRow, p: ParsedArgs): string[] {
511
+ if (row.reads === null) return p.readOptFlagsGiven;
512
+ const plan = planRead(scopeOf(row, p), p.readOpts);
513
+ return "refused" in plan ? READ_OPTS.filter((o) => plan.refused.includes(o.name)).map((o) => o.flag) : [];
514
+ }
515
+
516
+ // The combination guards: the first refusal's message, or null when the flags make one command. Every
517
+ // rule is a GUARDS row (cli-commands.ts), asked in its order: two commands of one family, a command beside
518
+ // another family's (or beside read options), the read options a command's read does not take, and the two
519
+ // modifiers only some commands take. Each refusal exists because the alternative is SILENT loss — one of
520
+ // the two things typed would run, and the other would vanish without a word.
521
+ function argConflict(p: ParsedArgs): string | null {
522
+ const flag = (r: CommandRow): string => r.flag ?? "";
523
+ const typed = (f: Family | "readOpts"): string[] => (f === "readOpts" ? p.readOptFlagsGiven : p.given.filter((r) => r.family === f).map(flag));
524
+ for (const g of GUARDS) {
525
+ if (g.kind === "json") {
526
+ if (p.json && !p.given.some((r) => r.family === "index")) return g.say();
527
+ continue;
528
+ }
529
+ if (g.kind === "tokenFile") {
530
+ const cmd = p.given.find((r) => r.family === "token");
531
+ if (p.tokenFile && cmd && g.ignoredBy.includes(cmd.id)) return g.say(flag(cmd));
532
+ continue;
533
+ }
534
+ const mine = p.given.filter((r) => r.family === g.family);
535
+ const cmd = mine[0];
536
+ if (!cmd) continue;
537
+ if (g.kind === "within") {
538
+ if (mine.length > 1) return g.say(mine.map(flag));
539
+ } else if (g.kind === "cross") {
540
+ const others = g.others.flatMap(typed);
541
+ if (others.length) return g.say(flag(cmd), others);
542
+ } else {
543
+ const refused = refusedReadOpts(cmd, p);
544
+ if (refused.length) return g.say(cmd, refused);
545
+ }
546
+ }
547
+ return null;
548
+ }
549
+
550
+ // Everything the CLI says about a parsed command line before running it: warnings to print (each as
551
+ // `[dtwin] warn: …`, in order) and the refusal, if any (printed as `[dtwin] error: …`, exit 1, after the
552
+ // warnings). Nothing is printed here; `exists` is the filesystem probe for the outDir trap.
553
+ function validateArgs(p: ParsedArgs, exists: (p: string) => boolean = fs.existsSync): { warnings: string[]; error: string | null } {
554
+ const trap = outDirWarning(p.userOutDir, exists);
555
+ return { warnings: trap ? [trap] : [], error: argConflict(p) };
556
+ }
557
+
558
+ // readArgs + the combination guards, throwing the refusal as a UsageError: the one-call form the test
559
+ // suites drive. Warnings are not computed here — cli() prints them from validateArgs.
560
+ function parseArgs(args: string[]): ParsedArgs {
561
+ const parsed = readArgs(args);
562
+ const error = argConflict(parsed);
563
+ if (error) throw new UsageError(error);
564
+ return parsed;
565
+ }
566
+
567
+ // --list-libraries prints for a HUMAN (and for an agent skimming a terminal), not raw JSON: the
568
+ // payload is a handful of rows, and an aligned table is the difference between "I can see at a glance
569
+ // which library this file draws on" and "here is 80 lines of JSON to parse by eye". --list stays JSON
570
+ // because its payload is an id catalogue you copy from; this one is a decision aid you READ.
571
+ // Pure string-in/string-out so the test suite can drive the empty case (the one that must not look
572
+ // like a failure) without a plugin on the other end.
573
+ // The connected-files table. Same contract as formatLibraries below: prints for a HUMAN (and an agent
574
+ // skimming a terminal), and the EMPTY case is the one that has to explain itself — "no files
575
+ // connected" is the normal state before you open the plugin, not a broken bridge.
576
+ /** A client row as formatClients reads it: every non-null object (each field is optional and checked where read). */
577
+ function isClientRowLike(x: unknown): x is Partial<ClientRow> {
578
+ return typeof x === "object" && x !== null;
579
+ }
580
+ function formatClients(rows: unknown): string {
581
+ // Rows from this process's own bridge, or relayed by a daemon (possibly an older one — see the
582
+ // daemon-staleness note below), or hand-built by the test suite: every field is optional here.
583
+ const list: Array<Partial<ClientRow>> = isUnknownArray(rows) ? rows.filter(isClientRowLike) : [];
584
+ const first = list[0];
585
+ if (first === undefined) {
586
+ return "No Figma files are connected to the bridge.\n" +
587
+ " Open a file in Figma and run \"Design Twin\" (Plugins → Development). The plugin\n" +
588
+ " auto-connects and announces itself. You can open it in SEVERAL files at once — each one\n" +
589
+ " becomes a separate row here, addressable with --client.";
590
+ }
591
+ const lines = [list.length + " Figma file" + (list.length === 1 ? "" : "s") + " connected:", ""];
592
+ for (const c of list) {
593
+ const mins = Math.round((c.uptimeMs || 0) / 60000);
594
+ // An unidentified row is a real state, not a bug: the socket is up but the plugin has not sent its
595
+ // `hello` yet (or is an older build that never does), so say what it IS addressable by.
596
+ lines.push(" " + (c.connId || "?").padEnd(5) + " " + (c.file ? JSON.stringify(c.file) : "(unidentified — address it by connId)"));
597
+ const bits = [];
598
+ if (c.page) bits.push("page " + JSON.stringify(c.page));
599
+ bits.push(c.fileKey ? "fileKey " + c.fileKey : "no fileKey (private-plugin API not in effect)");
600
+ bits.push("up " + (mins >= 1 ? mins + "m" : Math.round((c.uptimeMs || 0) / 1000) + "s"));
601
+ // A version per row, so a stale plugin bundle is visible right where the user is
602
+ // already looking to pick a file — not just buried in a separate `dtwin doctor` run. No version at
603
+ // all is itself the signal (a plugin bundle that predates version reporting, or a `dtwin serve` daemon old enough that its
604
+ // own `describe()` never learned to relay one) — named explicitly rather than a bare "unknown",
605
+ // which read as "couldn't be determined" instead of "definitely predates this".
606
+ if (c.identified) bits.push("plugin v" + (c.pluginVersion || "unknown (pre-versioning)"));
607
+ lines.push(" " + bits.join(" · "));
608
+ if (c.pluginStale) lines.push(" ! " + c.pluginStale);
609
+ const daemonStale = daemonRowStalenessNote(c);
610
+ if (daemonStale) lines.push(" ! " + daemonStale);
611
+ }
612
+ lines.push("");
613
+ lines.push("Address one with --client <connId | fileKey | part of the file name>, e.g. --client " +
614
+ (first.file ? JSON.stringify(first.file.split(/\s+/)[0]) : first.connId) + ".");
615
+ lines.push("Note: c1/c2 are reconnect-order LABELS for this bridge's current lifetime, not stable ids —" +
616
+ " a restart without `dtwin serve` can hand a different file the same label next time. Prefer the" +
617
+ " file name or fileKey when you script this.");
618
+ return lines.join("\n");
619
+ }
620
+
621
+ /** The renderer tolerates gaps (`--json` fixtures, older plugins): every row field is read as optional. */
622
+ type LibraryRowView = Partial<ListLibrariesReply["libraries"][number]>;
623
+
624
+ function formatLibraries(r: { libraries?: LibraryRowView[]; warnings?: string[] } | null | undefined): string {
625
+ const libs = (r && r.libraries) || [];
626
+ const lines: string[] = [];
627
+ if (!libs.length) {
628
+ // The empty result is a NORMAL outcome, and saying so is the whole job here. Figma's team-library
629
+ // reads return nothing when no library is enabled for the file, and library VARIABLE data is
630
+ // unavailable on a free plan — both look exactly like a broken bridge unless the output says
631
+ // otherwise. A stale plugin (imported before the manifest gained "teamlibrary") also lands here,
632
+ // which is why it is named as the FIRST thing to check.
633
+ lines.push("No libraries reported for this file.");
634
+ lines.push("");
635
+ lines.push("This is a normal outcome, not necessarily an error. In order of likelihood:");
636
+ lines.push(' 1. The plugin in Figma predates the "teamlibrary" permission — re-import/reload');
637
+ lines.push(" \"Design Twin\" in Figma, then run this again.");
638
+ lines.push(" 2. No team library is ENABLED for this file. Libraries can only be enabled from the");
639
+ lines.push(" Figma UI (Assets panel -> Libraries) — no API can enable one.");
640
+ lines.push(" 3. Free plan: library variable collections are not exposed to plugins.");
641
+ return lines.join("\n");
642
+ }
643
+ // Column widths from the data, so a long library name doesn't shear the table apart.
644
+ const rows = libs.map((l) => {
645
+ const cols = (l.variableCollections || []);
646
+ return {
647
+ kind: String(l.kind || "?"),
648
+ name: String(l.name || "(unnamed)"),
649
+ collections: String(cols.length),
650
+ variables: String(cols.reduce((n, c) => n + (Number(c.variableCount) || 0), 0)),
651
+ components: String(Number(l.componentCount) || 0),
652
+ cols,
653
+ note: l.note,
654
+ };
655
+ });
656
+ const head = { kind: "KIND", name: "NAME", collections: "COLLECTIONS", variables: "VARIABLES", components: "COMPONENTS (used here)" };
657
+ const w = (k: keyof typeof head) => Math.max(head[k].length, ...rows.map((x) => x[k].length));
658
+ const wk = w("kind"), wn = w("name"), wc = w("collections"), wv = w("variables");
659
+ const line = (x: Record<keyof typeof head, string>) => ` ${x.kind.padEnd(wk)} ${x.name.padEnd(wn)} ${x.collections.padStart(wc)} ${x.variables.padStart(wv)} ${x.components}`;
660
+ lines.push(`LIBRARIES (${libs.length})`);
661
+ lines.push("");
662
+ lines.push(line(head));
663
+ for (const x of rows) {
664
+ lines.push(line(x));
665
+ // Collections are the thing you actually scope a token pull by, so they're worth the indent.
666
+ for (const c of x.cols) lines.push(` · ${c.name} (${Number(c.variableCount) || 0} variables)${c.key ? " key=" + c.key : ""}`);
667
+ if (x.note) lines.push(` note: ${x.note}`);
668
+ }
669
+ lines.push("");
670
+ // The two limits that make a number here mean something other than what it looks like. Printed
671
+ // every time, because the counts are the part a reader is most likely to over-trust.
672
+ lines.push("Component counts are USAGE-derived (components from that library actually used in this");
673
+ lines.push("file) — Figma exposes no API to enumerate a library's full contents.");
674
+ lines.push("Only libraries ENABLED for this file (in the Figma UI) can appear at all.");
675
+ return lines.join("\n");
676
+ }
677
+
678
+ // Every layer an index reply lists: a listPages reply's top-level layers across its pages (under
679
+ // pages[].frames — empty at depth 1), or a listChildren reply's direct children. One walk for both, so
680
+ // the per-type breakdown and the SECTION hint below work for `dtwin list children` too.
681
+ const topLevelLayers = (r: ListPagesReply | ListChildrenReply): NodeSummary[] =>
682
+ "pages" in r ? r.pages.flatMap((p) => p.frames ?? []) : r.children;
683
+
684
+ // The closing line of an export — where the time went. A direct pull opens a fresh bridge and waits
685
+ // out the plugin's reconnect (a retry timer walking three ports, slower still in a background window),
686
+ // which measured 27 s against an export of under 3 s; naming the wait is what makes that visible, and `dtwin serve` is the fix.
687
+ // `connectMs` is the own-bridge wait for the plugin (0 / ignored behind a daemon, which waits inside the request).
688
+ const CONNECT_HINT_MS = 5000;
689
+ const secs = (ms: number) => (ms / 1000).toFixed(1) + "s";
690
+ function formatDone(t: { totalMs: number; connectMs: number; exportMs: number; writeMs: number; viaDaemon: boolean }): string[] {
691
+ // Behind a daemon whose plugin is not connected yet, the daemon waits INSIDE the request, so that wait is
692
+ // part of the export figure: the line says so instead of claiming there was no reconnect.
693
+ const wait = t.viaDaemon ? "via the running daemon" : `waited ${secs(t.connectMs)} for the plugin to connect`;
694
+ const exp = t.viaDaemon ? "export (incl. any wait for the plugin)" : "export";
695
+ const lines = [`done in ${secs(t.totalMs)} — ${wait} · ${exp} ${secs(t.exportMs)} · write ${secs(t.writeMs)}`];
696
+ if (!t.viaDaemon && t.connectMs >= CONNECT_HINT_MS) {
697
+ lines.push("the wait was the plugin reconnecting to this fresh bridge (it retries every second, one port at a time, and slower in a background window)" +
698
+ " — `dtwin serve` in another terminal keeps one bridge open, so the next command skips it.");
699
+ }
700
+ return lines;
701
+ }
702
+
703
+ // ---- token lifecycle commands: they touch only the local token file (no bridge, no daemon, no plugin).
704
+
705
+ function tokenStatus(): void {
706
+ const st = tokenStore.status();
707
+ console.log(JSON.stringify(st, null, 2));
708
+ console.error("[dtwin] token file: " + st.path + (st.stored ? "" : " (none saved yet)"));
709
+ console.error("[dtwin] in use: " + (
710
+ st.activeSource === "env" ? "FIGMA_BRIDGE_TOKEN from the environment"
711
+ : st.activeSource === "token-file" ? "the file given by --token-file"
712
+ : st.activeSource === "file" ? "the saved token"
713
+ : "a per-run token (nothing saved yet — the next bridge start will save one)"
714
+ ) + (st.fingerprint ? ` (${st.fingerprint})` : ""));
715
+ // The genuinely confusing state: a saved token exists, but the env var overrides it, so editing
716
+ // the file changes nothing and the user has no way to see why.
717
+ if (st.shadowed) {
718
+ console.error("[dtwin] note: FIGMA_BRIDGE_TOKEN is set, so it WINS over the saved token. Unset it to use the file.");
719
+ }
720
+ if (st.loosePerms) console.error("[dtwin] warning: " + st.path + " is readable by other users — chmod 600 it.");
721
+ }
722
+
723
+ function showToken(settings: BridgeSettings): void {
724
+ if (!settings.token.ok) throw settings.token.error;
725
+ const { token, source } = settings.token.resolved;
726
+ // stdout, alone, so it pipes: `dtwin --show-token | pbcopy`. Every note goes to stderr.
727
+ console.log(token);
728
+ if (source === "ephemeral") {
729
+ console.error("[dtwin] note: nothing is saved yet, so this token is NOT what a future run will use. Start a bridge once to save one.");
730
+ }
731
+ }
732
+
733
+ // A running daemon keeps the token it started with: its plugin connection, and on Windows the pipe it is
734
+ // named and found by (daemon.ts daemonAddress). Replaced or deleted from under it, it would hold the port while no
735
+ // later command could find it any more — each would then fail on the held port. So it is stopped FIRST,
736
+ // while the old token still finds it. Not when an env token or token file is what the bridge actually uses:
737
+ // the stored file changing then changes nothing for it (the env token as token-store reads it: trimmed).
738
+ // Every allowed port, not just FIGMA_BRIDGE_PORT's: the saved token is shared by all three, so a daemon
739
+ // another shell started on 8788 would be stranded the same way.
740
+ async function stopDaemonForTokenChange(settings: BridgeSettings): Promise<void> {
741
+ if (tokenOverridden(settings.token)) return;
742
+ for (const port of ALLOWED_PORTS) {
743
+ const at = daemonAddressOf(settings, port);
744
+ // Looked up first (status: creates nothing), so a token command never makes a socket directory.
745
+ if (!(await daemon.status(at).catch(() => null))) continue;
746
+ let stopped = false;
747
+ try { stopped = await daemon.stop(at); } catch { /* a refused socket location: no daemon of ours is there */ }
748
+ if (stopped) console.error(`[dtwin] stopped the running daemon on port ${port} first — it was using the old token. Start it again with \`dtwin serve\`.`);
749
+ }
750
+ }
751
+
752
+ async function rotateToken(settings: BridgeSettings): Promise<void> {
753
+ await stopDaemonForTokenChange(settings);
754
+ const had = tokenStore.readFrom(tokenStore.tokenPath());
755
+ const token = tokenStore.generate();
756
+ const file = tokenStore.write(token);
757
+ console.log(token);
758
+ console.error(`[dtwin] ${had ? "replaced" : "saved"} the bridge token in ${file}.`);
759
+ // The whole point of naming this: the plugin has the OLD token in clientStorage and will
760
+ // fail every handshake, retrying on its own, until someone re-pastes.
761
+ console.error('[dtwin] re-paste it into the plugin\'s "Bridge token" field and press Save — until you do, the plugin cannot connect.');
762
+ if (settings.token.ok && settings.token.resolved.source === "env") {
763
+ console.error("[dtwin] warning: FIGMA_BRIDGE_TOKEN is set and OVERRIDES this file, so the bridge will keep using the env value. Unset it for the rotation to take effect.");
764
+ }
765
+ }
766
+
767
+ async function forgetToken(settings: BridgeSettings): Promise<void> {
768
+ await stopDaemonForTokenChange(settings);
769
+ const file = tokenStore.tokenPath();
770
+ const removed = tokenStore.remove(file);
771
+ console.error("[dtwin] " + (removed ? `deleted ${file}. The next bridge start mints and saves a new token.` : `nothing to delete — no token saved at ${file}.`));
772
+ }
773
+
774
+ // ---- daemon lifecycle commands. Each owns the whole invocation.
775
+
776
+ async function stopDaemon(settings: BridgeSettings): Promise<void> {
777
+ const at = daemonAddressOf(settings);
778
+ const stopped = await daemon.stop(at);
779
+ // Nothing at the current location: a daemon an earlier build started (before an upgrade) still
780
+ // listens at the old one, and is what holds the port.
781
+ const old = stopped ? null : await daemon.stopLegacy(at);
782
+ console.error("[dtwin] " + (stopped ? "daemon stopped." : old ? `stopped a daemon started by an earlier dtwin version (socket ${old}).` : "no daemon is running."));
783
+ }
784
+
785
+ async function daemonStatus(settings: BridgeSettings): Promise<void> {
786
+ const st = await daemon.status(daemonAddressOf(settings));
787
+ console.log(JSON.stringify(st || { daemon: false }, null, 2));
788
+ // Fields an OLDER daemon may not report (daemon.ts DaemonStatusView) print as "?", never as a guess.
789
+ console.error("[dtwin] " + (st
790
+ ? `daemon up (pid ${st.pid ?? "?"}, port ${st.port ?? "?"}) — plugin ${st.pluginConnected ? "CONNECTED" : "not connected"}` +
791
+ (st.idleMs ? `, idle ${humanMs(st.idleForMs ?? 0)}/${humanMs(st.idleMs)} before auto-shutdown.` : ", no idle shutdown.")
792
+ : "no daemon is running — start one with --serve."));
793
+ }
794
+
795
+ // `dtwin serve`: the bridge holder's "serve" lifetime (bridge-holder.ts) — refused when a daemon already runs (naming
796
+ // --stop), then this process's bridge, published on the daemon socket with the settings' idle window.
797
+ async function serveDaemon(settings: BridgeSettings): Promise<void> {
798
+ const holder = bridgeHolder("serve", { settings, createBridge, daemon: daemonSide, log: (m) => console.error("[dtwin] " + m) });
799
+ const up = await holder.upstream();
800
+ const h = holder.held();
801
+ console.error("[dtwin] bridge listening on " + up.address + " — socket " + (h.kind === "own" ? h.sock ?? "?" : "?"));
802
+ console.error('[dtwin] Open your Figma file and run "Design Twin" (it auto-connects).');
803
+ console.error("[dtwin] The connection stays open until you run --stop (or Ctrl-C here).");
804
+ const idleMs = settings.idleMs; // the ONE rule the served socket uses (an unparsable value disables it)
805
+ console.error("[dtwin] " + (idleMs > 0
806
+ ? `It also shuts down after ${humanMs(idleMs)} idle, so an abandoned daemon can't hold port 8787 forever (FIGMA_DAEMON_IDLE_MIN=0 disables).`
807
+ : "Idle shutdown is DISABLED — remember to --stop it, or it holds port 8787 until you do."));
808
+ // the socket + WS server keep the event loop alive; no close() here, by design
809
+ }
810
+
811
+ // Typed per command (commands.ts). Every send also answers WHICH connected Figma file the bridge
812
+ // routed the command to — the client resolveClient() picked for --client, described as `dtwin list
813
+ // clients` would — so a screen export can be stamped with its real source without this
814
+ // file re-implementing the bridge's matching rules. `client` is null only from a daemon older than
815
+ // that field.
816
+ // A read is not sent this way: it runs through Session.read, which stamps it before anything can write it.
817
+ type Send = <C extends Exclude<Cmd, ReadCmd>>(cmd: C, args: Commands[C]["args"], timeoutMs: number) => Promise<Sent<C>>;
818
+
819
+ // What every ordinary command runs on: where its requests go (a running daemon, or a bridge of its own),
820
+ // and the timing and shutdown helpers the commands share.
821
+ interface Session {
822
+ /** Where requests, the client listing and the socket stats come from (route.ts). */
823
+ route: Route;
824
+ /** A command that reads no design (whoami, an index listing), timed for the closing `done in …` line. */
825
+ send: Send;
826
+ /** A read plan, run (read-run.ts): stamped, its request and its write timed for the closing `done in …` line. */
827
+ read: <C extends ReadCmd>(plan: PlanOf<C>) => Promise<ReadRun<C>>;
828
+ doneLine: () => string[];
829
+ /** Closes this process's bridge, if it has one. */
830
+ finish: () => void;
831
+ }
832
+
833
+ // ---- ordinary commands. Route through a running daemon when there is one: it already holds the
834
+ // bridge (and port 8787), so opening our own here would hit EADDRINUSE and exit. When there is no
835
+ // daemon this is exactly the one-shot path it always was.
836
+ async function openSession(parsed: ParsedArgs, settings: BridgeSettings): Promise<Session> {
837
+ const { command, client, connectWaitMs } = parsed;
838
+ // Timing (see `doneLine` below): the clock starts before the daemon probe.
839
+ const tStart = Date.now();
840
+ const timing = { connectMs: 0, exportMs: 0, writeMs: 0 };
841
+ // DTWIN_TIMINGS (timings.ts): the same split plus plugin phase arrival, payload size and peak RSS, as one JSON record
842
+ const timingsTo = timingsTarget();
843
+ const clock = timingsTo ? stageClock() : null;
844
+ // Where requests go: the running daemon, or a bridge of our own for this command only — the bridge holder's
845
+ // "one-shot" lifetime (bridge-holder.ts). Never shared: a process that finds the port held exits on it.
846
+ const holder = bridgeHolder("one-shot", { settings, createBridge, daemon: daemonSide, log: (m) => console.error("[dtwin] " + m) });
847
+ const up = await holder.upstream();
848
+ const viaDaemon = up.kind === "daemon";
849
+ if (viaDaemon) console.error("[dtwin] using the running daemon (" + up.address + ") — no reconnect needed.");
850
+
851
+ // outDir is created lazily by each writer (writeExport/writeScreen/writeScreenshot all
852
+ // fs.mkdirSync(dir, {recursive:true}) themselves) rather than eagerly here. Creating it up front —
853
+ // before the client was even resolved — would let a command that fails on the multi-client check (or any
854
+ // other pre-export validation) leave a stray empty outDir behind: `dtwin
855
+ // nonexistent` with two clients connected exited 1 on "say which one to use" and left `./nonexistent/`
856
+ // on disk. `--list`/`--children` never write outDir at all, so they need no mkdir either way.
857
+
858
+ // An export-class command is the one shape a stalled one-shot pull hits: a pull or a screenshot — never
859
+ // an index command (cli-commands.ts FAMILIES `writes`), which is cheap and already excluded from the
860
+ // mkdir guard above for the same reason. Only these get the upfront no-daemon warning and the stall check.
861
+ const isExportCmd = FAMILIES[command.family].writes;
862
+ // Without `dtwin serve`, every pull opens a throwaway bridge and the plugin's
863
+ // reconnect is what actually costs the time (measured 300s/908s vs 7.5-8.8s with a daemon warm).
864
+ // Said up front, before anything is sent, not just diagnosed after the fact by `dtwin doctor`.
865
+ if (!viaDaemon && isExportCmd) {
866
+ console.error("[dtwin] no `dtwin serve` daemon is running — this pull opens its own bridge and waits out the plugin's full reconnect, which can take minutes on a cold connection. Run `dtwin serve` in another terminal for a fast, reliable connection.");
867
+ }
868
+ // The plugin sends a `queued` progress frame when this command waits behind another run in the
869
+ // same file (a `start` frame when it begins — never worth a line). Say so once, so a silent wait is
870
+ // not mistaken for a hang; --timeout still bounds it.
871
+ const onQueued = (): void => console.error("[dtwin] queued behind another export in that file — waiting (up to --timeout)");
872
+
873
+ // The route (route.ts) owns the rules either way — the connect window (`--timeout`; a named --client waits at least NAMED_CLIENT_WAIT_MS), the stall
874
+ // check (own bridge, export commands: if NOTHING is heard back within FIGMA_BRIDGE_STALL_MS of sending, abort
875
+ // rather than sit out the full export timeout), the "no plugin ever connected" error, and which commands
876
+ // hear progress ticks.
877
+ // Through a daemon, the DAEMON does the waiting: the plugin may not have reconnected yet after a Figma restart,
878
+ // and the daemon is the side holding the socket. Each request carries the connect window, and an export's
879
+ // progress frames re-arm the outer guard (daemonGuardMs), so a long walk that is still reporting is not cut off
880
+ // by a budget sized for silence — the daemon's own per-command `timeoutMs` still bounds it.
881
+ if (!viaDaemon) {
882
+ console.error("[dtwin] listening on " + up.address);
883
+ console.error('[dtwin] Open your Figma file and run "Design Twin" (it auto-connects)…');
884
+ console.error("[dtwin] tip: --serve keeps this connection open so later pulls skip the reconnect.");
885
+ }
886
+ const route = createRoute(async () => up, { connectWaitMs, oneShot: true });
887
+ // Our own bridge spends the connect window here, once, before anything is sent — timed for the done line.
888
+ // --list-clients IS meaningful with nothing ever connecting ("which files can I talk to?" → "none, open
889
+ // one"), but with no daemon running it is talking to a bridge it JUST opened, and every plugin window
890
+ // currently open in Figma is mid-reconnect to it (timeouts.ts PLUGIN_REDIAL_MS) — so it waits too, and a
891
+ // window with no plugin is its legitimate empty answer. A command instead fails on its first request with
892
+ // the never-connected text (route.ts). The wait (server-core awaitClient) covers the target too: a named
893
+ // one gets more time once a plugin is connected, and with no target or a file name the fresh bridge
894
+ // settles first (timeouts.ts SETTLE_MS), so a second open file a redial behind is connected — and the
895
+ // command refused as ambiguous, or listed — instead of the first file to land being taken silently.
896
+ timing.connectMs = (await route.connect(client)).waitedMs;
897
+ // Where the time went, for the closing `done in …` line. The connect wait is measured above
898
+ // (own bridge only — a daemon does its waiting inside the request), the export is the time inside
899
+ // every request, the write is every write-out (read-run.ts adds both to `timing`). All measured from
900
+ // this side: nothing in server-core.
901
+ const metering = { meter: clock, tally: timing };
902
+ const send: Send = async (cmd, args, timeoutMs) => (await meteredRequest(() => route.request(cmd, args, { timeoutMs, target: client }), metering)).sent;
903
+ const read = <C extends ReadCmd>(plan: PlanOf<C>): Promise<ReadRun<C>> => runRead(route, plan, { ...metering, target: client, onQueued });
904
+ // Every command that prints the done line calls this once, at its end — so it is also where the opt-in record goes
905
+ const doneLine = () => {
906
+ if (clock && timingsTo) emitTimings(clock.record({ command: command.id, viaDaemon, ...timing }), timingsTo);
907
+ return formatDone({ totalMs: Date.now() - tStart, ...timing, viaDaemon });
908
+ };
909
+ // With a daemon there is no bridge of ours to close, and closing the DAEMON's would be wrong — the holder knows
910
+ // which, so no exit path below has to.
911
+ const finish = () => { holder.release(); };
912
+ return { route, send, read, doneLine, finish };
913
+ }
914
+
915
+ // Which Figma files are connected right now. Prints a table for a human (and for an agent skimming
916
+ // a terminal); the connId in the first column is what --client takes. Deliberately does NOT talk to
917
+ // the plugin at all — the answer lives entirely in the bridge, so this works even while every
918
+ // connected file is busy with a long export.
919
+ async function listClientsCmd({ route, finish }: Session, json: boolean): Promise<void> {
920
+ const rows = await route.listClients();
921
+ console.log(json ? JSON.stringify({ clients: rows }, null, 2) : formatClients(rows));
922
+ if (rows.length > 1) {
923
+ console.error("[dtwin] " + rows.length + " files connected — pass --client <id|fileKey|name> " +
924
+ "to pick one, or commands that need a target will refuse rather than guess.");
925
+ }
926
+ finish();
927
+ }
928
+
929
+ // The identity/liveness probe. Prints BOTH halves: what the plugin says it is, and what the socket
930
+ // did. Run it in each of two open files (a second terminal, or twice in a row after switching the
931
+ // focused file) and compare — the interpretation notes below are printed with the result so the
932
+ // reading does not depend on remembering what each field means.
933
+ async function whoamiCmd({ route, send, finish }: Session): Promise<void> {
934
+ const { reply: r, client: whoamiClient } = await send("whoami", {}, TIMEOUTS.command);
935
+ // The socket half comes from whoever holds the bridge (route.ts): this process, or — with a daemon in
936
+ // front — the daemon, rather than emitting `connection: null` under a help text that promises socket
937
+ // uptime and takeovers. The socket described is the ADDRESSED file's (the client send() routed to), not
938
+ // whichever connection happens to be first — with two files open `--client <name>` would print the other's.
939
+ const addressed = whoamiClient ? whoamiClient.connId : null;
940
+ const { connection: conn, owner } = await route.connectionInfo(addressed).catch(() => ({ connection: null, owner: null }));
941
+ const connFrom = !conn || !owner ? null : owner.kind === "own" ? "this process" : `the daemon (pid ${owner.pid ?? "?"})`;
942
+ console.log(JSON.stringify({ plugin: r, connection: conn, connectionFrom: connFrom || undefined }, null, 2));
943
+ console.error("[dtwin] plugin instance " + r.instanceId + " — file " + JSON.stringify(r.file) +
944
+ ", up " + Math.round(r.uptimeMs / 1000) + "s.");
945
+ // Printed even when nothing is stale, so "no version at all" (an old bundle that
946
+ // predates this field) is visibly different from "version reported, and it's current".
947
+ console.error("[dtwin] plugin version: " + (r.pluginVersion || "unknown (older bundle — predates version reporting)"));
948
+ const stale = pluginStalenessNote(r.pluginVersion);
949
+ if (stale) console.error("[dtwin] ! " + stale);
950
+ console.error("[dtwin] fileKey: " + (r.fileKeyAvailable
951
+ ? r.fileKey + " (available — usable as a stable routing key)"
952
+ : "UNAVAILABLE (gated to private plugins; routing must use a server-minted id)"));
953
+ if (conn) {
954
+ console.error("[dtwin] socket " + conn.connId + " up " + Math.round(conn.connectionUptimeMs / 1000) +
955
+ "s; connections this run: " + conn.connectionsThisRun + ", takeovers: " + conn.takeovers +
956
+ (conn.takeovers ? " — a second plugin instance DID connect and displace an earlier one." : ".") +
957
+ (connFrom && connFrom !== "this process" ? " (from " + connFrom + ")" : ""));
958
+ } else {
959
+ console.error("[dtwin] socket stats unavailable — no daemon is running and this process holds no bridge of its own.");
960
+ }
961
+ console.error("[dtwin] reading it: run this from BOTH open files. Two different instanceIds => " +
962
+ "two instances coexist. A CHANGED instanceId on a repeat call => Figma restarted the plugin runtime. " +
963
+ "takeovers > 0 => the bridge's one-connection limit is what disconnected the other file, not Figma.");
964
+ finish();
965
+ }
966
+
967
+ // The library discovery command. Same cost/budget tier as the two below (cheap relative to an
968
+ // export) and the same "print, never write outDir" contract — but its own branch, because it
969
+ // prints a TABLE rather than the JSON id-catalogue those two exist to hand you.
970
+ async function listLibrariesCmd({ send, finish }: Session, json: boolean, listTimeoutMs: number): Promise<void> {
971
+ console.error("[dtwin] plugin connected — listing libraries…");
972
+ const { reply: r } = await send("listLibraries", {}, listTimeoutMs);
973
+ // Plugin-side warnings first, on stderr, so they survive a `| less` of stdout and can never be
974
+ // mistaken for part of the table.
975
+ for (const w of r.warnings) console.error("[dtwin] warn " + w);
976
+ console.log(json ? JSON.stringify(r, null, 2) : formatLibraries(r));
977
+ console.error("[dtwin] next: dtwin --list then --page <id> (pull only the pages you need)");
978
+ finish(); // see the close()-not-exit note below
979
+ }
980
+
981
+ // The two cheap index commands. --list is the file-wide map (consult it to pick a target, THEN
982
+ // deep-pull just that one); --children <id> is its node-scoped twin, for peeking inside a frame
983
+ // --list surfaced. Both PRINT to stdout and never write outDir — a decision aid, not a build input —
984
+ // and both share the same warn/print/hint/shutdown tail, so only the command differs.
985
+ async function listIndexCmd({ send, finish }: Session, childrenId: string | null, listDepth: 1 | 2, listTimeoutMs: number): Promise<void> {
986
+ console.error("[dtwin] plugin connected — " + (childrenId ? "listing children of " + childrenId : "listing structure") + "…");
987
+ const r: ListPagesReply | ListChildrenReply = childrenId
988
+ ? (await send("listChildren", { nodeId: childrenId }, listTimeoutMs)).reply
989
+ : (await send("listPages", { depth: listDepth }, listTimeoutMs)).reply;
990
+ for (const w of r.manifest.warnings) console.error("[dtwin] warn " + w);
991
+ console.log(JSON.stringify(r, null, 2));
992
+ // "top-level frame(s)" was a lie on every real file: the array holds SECTION, GROUP, INSTANCE,
993
+ // TEXT and RECTANGLE too, and on a sectioned file the actual screens are one level deeper.
994
+ // Say "layer", which is Figma's own word for any object, and break the count down
995
+ // so "deep-pull next" points somewhere real — for a children listing as much as for the page map.
996
+ const byType: Record<string, number> = {};
997
+ for (const f of topLevelLayers(r)) byType[f.type] = (byType[f.type] || 0) + 1;
998
+ const kinds = Object.entries(byType).sort((a, b) => b[1] - a[1]).map(([t, n]) => `${n} ${t}`).join(", ");
999
+ const summary = "children" in r
1000
+ ? `${r.children.length} direct child(ren) of "${r.name}" (${r.type})${kinds ? ` — ${kinds}` : ""}.`
1001
+ : `${r.manifest.pages} page(s)${r.manifest.frames === undefined ? "" : `, ${r.manifest.frames} top-level layer(s)`} in "${r.file}"${kinds ? ` — ${kinds}` : ""}.`;
1002
+ console.error("[dtwin] " + summary);
1003
+ // A SECTION is a container, not a screen — pulling one deep-serializes every screen inside it.
1004
+ // On the file this was found on, the sections were 8898-35975px wide.
1005
+ if (topLevelLayers(r).some((f) => f.type === "SECTION")) {
1006
+ console.error("[dtwin] note: some top-level layers are SECTIONs — containers, not screens. The screens are INSIDE them:");
1007
+ console.error("[dtwin] dtwin list children <section id> then pull the frame you want.");
1008
+ }
1009
+ console.error("[dtwin] next: dtwin pull --page <id> (repeatable; add --no-assets to skip the render pass)");
1010
+ console.error("[dtwin] or: dtwin pull --node <id> (just ONE node, fully exported with its own assets)");
1011
+ console.error("[dtwin] unsure which of two same-named frames? dtwin screenshot <id> renders one cheaply.");
1012
+ // The WS server keeps the event loop alive, so without an explicit shutdown these commands hung
1013
+ // forever after printing (found live: still resident and holding port 8787 a minute later,
1014
+ // blocking every subsequent pull). close() rather than process.exit(0) — same effect on the hang,
1015
+ // without truncating the JSON these commands exist to print. See close() in server-core.ts.
1016
+ finish();
1017
+ }
1018
+
1019
+ // --screenshot: one node's reference PNG, on demand — the visual-validation counterpart to --list/
1020
+ // --children above. Cheap next to a full export (skips serialize() and the recursive asset walk —
1021
+ // see collectScreenshot's comment in collect.ts) but it DOES write a file, unlike the two commands
1022
+ // it sits next to, so it gets its own branch rather than joining their print-only one.
1023
+ async function screenshotCmd({ read, doneLine, finish }: Session, plan: PlanOf<"screenshot">, outDir: string): Promise<void> {
1024
+ console.error("[dtwin] plugin connected — rendering a reference screenshot…");
1025
+ const run = await read(plan);
1026
+ const r = run.reply;
1027
+ for (const w of r.manifest.warnings) console.error("[dtwin] warn " + w);
1028
+ // Print the path writeScreenshot actually wrote, not the plugin's own relative `reference`
1029
+ // string: those can disagree, and a printed path that
1030
+ // finds nothing is worse than no path at all.
1031
+ const scale = plan.args.scale;
1032
+ const shot = run.write((stamped) => OUT.writeAny(outDir, stamped, plog, { scale }));
1033
+ const ref = shot.reference || r.reference;
1034
+ if (!ref) throw new Error(`the plugin rendered ${r.name} (${r.type}) but named no reference file — nothing was written`);
1035
+ // The node's size, the render scale and the PNG's own pixel size. Each is omitted when its
1036
+ // source did not send it (an older plugin has no w/h/scale; png needs PNG bytes).
1037
+ console.log(JSON.stringify({
1038
+ id: r.id, name: r.name, type: r.type,
1039
+ ...ifDefined("w", r.w), ...ifDefined("h", r.h), ...ifDefined("scale", r.scale), ...ifDefined("png", shot.png),
1040
+ reference: ref,
1041
+ }, null, 2));
1042
+ console.error(`[dtwin] wrote ${path.join(outDir, ref)} — ${r.name} (${r.type}).`);
1043
+ for (const l of doneLine()) console.error("[dtwin] " + l);
1044
+ finish();
1045
+ }
1046
+
1047
+ // A pull: one node, the selection, a library catalog, the design system alone, or pages — written to outDir.
1048
+ async function exportCmd({ read, doneLine, finish }: Session, plan: Exclude<ReadPlan, PlanOf<"screenshot">>, parsed: ParsedArgs): Promise<void> {
1049
+ const { allPages, outDir } = parsed;
1050
+ // Measured: --all-pages did not finish in 15 minutes on a real 25-page/119-frame file, even with
1051
+ // --no-assets. Kept (it's slow, not unsafe — and it's fine on small files) but it should not be the
1052
+ // path anyone reaches for by default, so say so up front rather than after a quarter-hour.
1053
+ if (allPages) {
1054
+ console.error("[dtwin] note: --all-pages deep-serializes EVERY frame on EVERY page and can exceed 15 min on a large file.");
1055
+ console.error("[dtwin] prefer: --list then --page <id> (repeatable, e.g. --page 1:2 --page 3:4)");
1056
+ }
1057
+ console.error(`[dtwin] plugin connected — pulling ${plan.label}… (timeout ${Math.round(plan.timeout / 1000)}s)`);
1058
+
1059
+ // WHICH connected Figma file this pull talked to — the client the bridge's own
1060
+ // resolveClient() picked for --client, handed back with the reply (server-core request;
1061
+ // a daemon relays it). A screen export result has no field of its own naming its source file
1062
+ // (unlike design-system.json / a library catalog, which the plugin itself stamps), so `doctor` could
1063
+ // only ever report whichever file's export happened to sort first, attributing every screen to it.
1064
+ // Stamped onto the result by the read run (read-run.ts) before write-out.ts sees it, which persists it onto
1065
+ // the screen JSON and its index row. Unstamped (never guessed) when a daemon too old to relay the client
1066
+ // answered. A catalog's writers never read it.
1067
+ const run = await read(plan);
1068
+ // The SAME writer the MCP export tools use (write-out.ts's writeAny), so a CLI pull and an MCP pull of
1069
+ // the same read land in identical shape: a screen (--node / --selection) through writeScreen, a library
1070
+ // catalog (routed on the plugin's own `source.role`, under libraries/<slug>-<fileKey8>/ — it can never
1071
+ // overwrite design-system/), the design system alone or a page walk through writeExport's split. The
1072
+ // limited-library-variables note of a --design-system pull rides in designSystem.hygiene (see collect.ts),
1073
+ // which writeDesignSystem persists to hygiene.json.
1074
+ run.write((r) => OUT.writeAny(outDir, r, plog));
1075
+ if ("designSystem" in run.reply) printHygiene(run.reply);
1076
+
1077
+ for (const l of doneLine()) console.error("[dtwin] " + l);
1078
+ finish(); // NOT process.exit — see close() in server-core.ts (a daemon-routed run has nothing to close)
1079
+ }
1080
+
1081
+ // Each command's runner, keyed by its id in the command table (cli-commands.ts) and typed by it: a row
1082
+ // without a runner, or a runner on the wrong side of the session line, does not compile.
1083
+ // The token and daemon commands run WITHOUT a session: they need no plugin (the token ones touch only the
1084
+ // local token file), and opening one would be wrong — the token commands must still work while a daemon
1085
+ // holds the port.
1086
+ type Sessionless = CommandIdIn<Exclude<Family, SessionFamily>>;
1087
+ const RUN_SESSIONLESS = {
1088
+ tokenStatus: () => tokenStatus(),
1089
+ showToken: (_p, s) => showToken(s),
1090
+ rotateToken: (_p, s) => rotateToken(s),
1091
+ forgetToken: (_p, s) => forgetToken(s),
1092
+ serve: (_p, s) => serveDaemon(s),
1093
+ stop: (_p, s) => stopDaemon(s),
1094
+ daemonStatus: (_p, s) => daemonStatus(s),
1095
+ } satisfies Record<Sessionless, (p: ParsedArgs, settings: BridgeSettings) => void | Promise<void>>;
1096
+ const RUN_ON_SESSION = {
1097
+ listClients: (s, p) => listClientsCmd(s, p.json),
1098
+ whoami: (s) => whoamiCmd(s),
1099
+ listLibraries: (s, p) => listLibrariesCmd(s, p.json, p.listTimeoutMs),
1100
+ list: (s, p) => listIndexCmd(s, p.childrenId, p.listDepth, p.listTimeoutMs),
1101
+ listPages: (s, p) => listIndexCmd(s, p.childrenId, p.listDepth, p.listTimeoutMs),
1102
+ children: (s, p) => listIndexCmd(s, p.childrenId, p.listDepth, p.listTimeoutMs),
1103
+ screenshot: readCmd,
1104
+ pull: readCmd,
1105
+ selection: readCmd,
1106
+ allPages: readCmd,
1107
+ page: readCmd,
1108
+ designSystem: readCmd,
1109
+ asLibrary: readCmd,
1110
+ node: readCmd,
1111
+ } satisfies Record<CommandIdIn<SessionFamily>, (s: Session, p: ParsedArgs) => Promise<void>>;
1112
+ const sessionless = (id: CommandId): id is Sessionless => Object.hasOwn(RUN_SESSIONLESS, id);
1113
+
1114
+ // A read: the plan this command line sends (read-plan.ts), as a screenshot or a pull.
1115
+ function readCmd(session: Session, parsed: ParsedArgs): Promise<void> {
1116
+ const plan = cliReadPlan(parsed);
1117
+ // parseArgs/validateArgs refused a read option this read does not take before anything was opened.
1118
+ if (!plan || "refused" in plan) throw new Error("internal: no read plan for this command line");
1119
+ if (plan.cmd === "screenshot") return screenshotCmd(session, plan, parsed.outDir);
1120
+ return exportCmd(session, plan, parsed);
1121
+ }
1122
+
1123
+ // Runs the one command the parsed command line names.
1124
+ async function main(parsed: ParsedArgs, settings: BridgeSettings): Promise<void> {
1125
+ const { id } = parsed.command;
1126
+ if (sessionless(id)) return RUN_SESSIONLESS[id](parsed, settings);
1127
+ return RUN_ON_SESSION[id](await openSession(parsed, settings), parsed);
1128
+ }
1129
+
1130
+ // The verbs routed to their own entry point (cli-commands.ts VERB_ROWS `to: "routed"`), by name. Each runs
1131
+ // before the CLI reads its settings and shares none of the pull flags, so each gets its own parser:
1132
+ // mcp the MCP server (figma-mcp.ts), on stdio — it reads its own settings
1133
+ // init project setup (init.ts) — no bridge
1134
+ // doctor diagnose the setup (doctor.ts) — doctor must decide for ITSELF whether it is safe to open a
1135
+ // bridge (a bad FIGMA_BRIDGE_PORT and a held port are findings to report, not ways for the diagnostic
1136
+ // to die)
1137
+ // seed components.json from Code Connect files (seed-components.ts) — reads source files only: no
1138
+ // bridge, no token (a bridge would resolve, and may mint, one), no plugin
1139
+ const ROUTES = {
1140
+ mcp: runMcp,
1141
+ init: async (args) => { (await import("./init.ts")).main(args); },
1142
+ doctor: async (args) => { await (await import("./doctor.ts")).main(args); },
1143
+ seed: async (args) => { (await import("./seed-components.ts")).main(args); },
1144
+ } satisfies Record<RoutedVerb, (args: string[]) => Promise<void>>;
1145
+ const routed = (word: string | undefined): word is RoutedVerb => word !== undefined && Object.hasOwn(ROUTES, word);
1146
+
1147
+ async function runMcp(args: string[]): Promise<void> {
1148
+ // `--help` must never be the thing that STARTS a server. Probing a CLI's help surface silently
1149
+ // would launch a second MCP process attached to the live bridge and hold stdio until the caller kills
1150
+ // it — the one command in the table whose --help would have a side effect.
1151
+ if (args.includes("--help") || args.includes("-h")) {
1152
+ console.log(MCP_HELP);
1153
+ exitCli(0);
1154
+ }
1155
+ // Hide the subcommand from the MCP entry: it should see the argv of a plain `figma-mcp.ts` run.
1156
+ process.argv.splice(2, 1);
1157
+ // Awaited only until the module has LOADED; then its server is started (runServer — loading it starts nothing, so a
1158
+ // suite can load it) and runs on, unawaited, after cli() returns. A failure to load it (a CliExit thrown while still
1159
+ // inside runCli lands here) ends the process on its exit code, naturally: nothing holds the loop once the module
1160
+ // failed. Once cli() has returned, runCli is done with it, and the server runs as `bun figma-mcp.ts` would (stdio.ts
1161
+ // says why) — an invalid FIGMA_BRIDGE_PORT included: the server reports it and exits 1.
1162
+ try {
1163
+ (await import("./figma-mcp.ts")).runServer();
1164
+ } catch (e) {
1165
+ if (e instanceof CliExit) { process.exitCode = e.code; return; }
1166
+ console.error("[dtwin mcp] failed to start the MCP server: " + errMsg(e));
1167
+ process.exitCode = 1;
1168
+ }
1169
+ }
1170
+
1171
+ // The CLI entry. Runs ONLY when this file is the process entry (see the guard at the bottom): routing
1172
+ // first, then --version/--help, then the arguments are parsed, THEN the settings are read (settings.ts — port, token
1173
+ // with --token-file, limits) and the command runs.
1174
+ async function cli(argv: string[]): Promise<void> {
1175
+ // `dtwin mcp|init|doctor|seed` — their own entry points, before anything else runs (in particular before
1176
+ // the settings are read): `dtwin mcp` is taken ONLY when the first argument is exactly "mcp", so `dtwin …` and
1177
+ // importing this module from the test suites are untouched, and `bun bridge/src/figma-mcp.ts` still works.
1178
+ const word = argv[0];
1179
+ if (routed(word)) return ROUTES[word](argv.slice(1));
1180
+ // `dtwin <verb>` — verbs.ts rewrites a leading verb into the flags parsed below (`dtwin list pages` →
1181
+ // `--list-pages`), so there is still ONE parser and one set of guards. Done here, before the --help
1182
+ // check and the argument parse, so both see the translated argv. verbs.ts is pure (no imports,
1183
+ // no I/O), so `dtwin help` keeps --help's zero-side-effects guarantee.
1184
+ {
1185
+ // Help wins over the verb's own argument check. `dtwin screenshot --help` must not be answered with
1186
+ // "needs a node id" because translate ran first; a help probe must never be an
1187
+ // error, and must never have a side effect (see `dtwin mcp --help` above).
1188
+ const vh = verbHelp(argv);
1189
+ if (vh) {
1190
+ console.log(vh);
1191
+ exitCli(0);
1192
+ }
1193
+ try {
1194
+ argv = translate(argv, (p) => fs.existsSync(p));
1195
+ } catch (e) {
1196
+ if (!(e instanceof VerbError)) throw e;
1197
+ console.error("[dtwin] error: " + e.message);
1198
+ exitCli(1);
1199
+ }
1200
+ }
1201
+ if (argv[0] === "--version" || argv[0] === "-v") {
1202
+ console.log(pkg.version);
1203
+ exitCli(0);
1204
+ }
1205
+ if (argv.some((a) => a === "--help" || a === "-h")) {
1206
+ console.log(usageText());
1207
+ exitCli(0);
1208
+ }
1209
+
1210
+ // DTWIN_PORT is not a thing this tool reads (FIGMA_BRIDGE_PORT is); say so once rather than
1211
+ // let a person who set it believe the bridge moved. After --help/--version, so those stay silent.
1212
+ const portWarning = dtwinPortWarning(process.env);
1213
+ if (portWarning) console.error("[dtwin] warning: " + portWarning);
1214
+
1215
+ // Parse once, turning a UsageError back into the message + exit 1 it replaced, then print the
1216
+ // validation's warnings and, after them, its refusal (exit 1). (When this file is IMPORTED — the test
1217
+ // suite pulling in parseArgs/writeJson — cli() never runs, so nothing is parsed: the runner's own
1218
+ // argv is not ours to interpret or exit over.)
1219
+ let parsed: ParsedArgs;
1220
+ try {
1221
+ parsed = readArgs(argv);
1222
+ } catch (e) {
1223
+ if (!(e instanceof UsageError)) throw e;
1224
+ console.error("[dtwin] error: " + errMsg(e));
1225
+ exitCli(1);
1226
+ }
1227
+ const check = validateArgs(parsed);
1228
+ for (const w of check.warnings) console.error("[dtwin] warn: " + w);
1229
+ if (check.error !== null) {
1230
+ console.error("[dtwin] error: " + check.error);
1231
+ exitCli(1);
1232
+ }
1233
+
1234
+ // The bridge settings, resolved ONCE (settings.ts) — --token-file is an argument here, not an environment variable
1235
+ // written for a module that read it at load. A port no plugin can reach leaves nothing for any command to do.
1236
+ const resolved = readSettings({ tokenFile: parsed.tokenFile });
1237
+ if (!resolved.ok) {
1238
+ console.error(resolved.problem);
1239
+ exitCli(1);
1240
+ }
1241
+
1242
+ // A failed command may leave the bridge it opened listening (it holds the loop), so this one ends with an explicit
1243
+ // exit — once the error line is out (exitFlushed), never cutting it off.
1244
+ await main(parsed, resolved.settings).catch((e: unknown) => {
1245
+ if (e instanceof CliExit) throw e;
1246
+ console.error("[dtwin] error:", errMsg(e));
1247
+ process.exitCode = 1;
1248
+ exitFlushed(1);
1249
+ });
1250
+ }
1251
+
1252
+ // Only actually pull when RUN. Importing this file (test/bridge.test.ts drives parseArgs and
1253
+ // writeJson directly) must not open a WebSocket server and sit there waiting for Figma.
1254
+ // runCli (stdio.ts): console through the queued streams, exitCli unwinding to the exit code, which is process.exitCode —
1255
+ // the process ends once its output has drained (a piped --list / --json is never cut at the pipe buffer).
1256
+ if (import.meta.main) runCli(async (argv) => { await cli(argv); return Number(process.exitCode ?? 0); }, { name: "[dtwin] error" });
1257
+
1258
+ // writeJson is exported for the test suite: it's the ONE place a design-system.json / screen.json
1259
+ // actually lands on disk, and the freshness stamp (`exportedAt`/`file`, stamped by the plugin itself —
1260
+ // see collect.ts/components.ts) must survive that write byte-for-byte. figma-pull never adds, strips,
1261
+ // or re-derives the stamp; it only writes what the plugin sent.
1262
+ // formatLibraries is exported for the same reason parseArgs is: the case that MUST NOT look like a
1263
+ // failure (zero libraries — free plan, or none enabled in the UI) is unreachable from a test that
1264
+ // needs a live plugin, so the renderer is driven directly.
1265
+ export { parseArgs, readArgs, validateArgs, cliReadPlan, writeJson, formatLibraries, formatClients, formatDone, UsageError };