@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,427 @@
1
+ // cli-commands.ts — THE registry of dtwin's commands, modelled on read-opts.ts.
2
+ //
3
+ // The command family used to be worked out by hand at every site that needed it: the flag lists the
4
+ // unknown-flag check knew, the index-command list the guards walked, the five booleans openSession
5
+ // re-derived "is this an export?" from, the per-command wording of a read-option refusal, the dispatch
6
+ // if-chain, the verb tables in verbs.ts and the hand routing of mcp/init/doctor/seed in cli(). Each one
7
+ // is a place a new command can be added and then forgotten, and the failure is SILENT: a guard that
8
+ // does not know a command lets it combine with another, and only one of the two runs. That is not
9
+ // hypothetical — `--list --list-pages` was accepted for exactly that reason (listCmd folded the two
10
+ // flags into one value, so the "two index commands" guard only ever saw one), and ran --list-pages.
11
+ //
12
+ // So: one COMMANDS row is a whole command — its flag, the family that decides what it may be combined
13
+ // with, the read it plans (and so which read options it takes), what it prints, its `dtwin <verb>`
14
+ // spellings. A FAMILY says whether its commands reach the plugin (open a session) and write an export;
15
+ // GUARDS are the combination refusals per family, in the order the first one wins; MODIFIERS are the
16
+ // flags that are not commands. VERB_ROWS are the first words of `dtwin <verb>`: their help page and
17
+ // their line in `dtwin --help`. figma-pull.ts derives parsing, the guards, unknown-flag detection,
18
+ // dispatch and the help from these; verbs.ts derives the verb translation. The runner of each command
19
+ // lives in figma-pull.ts, in a map typed against CommandId, so a row without a runner does not compile.
20
+ //
21
+ // Dependency-free apart from read-opts.ts (itself dependency-free): imported by verbs.ts, which must
22
+ // stay import-light so `dtwin help` keeps --help's zero-side-effects guarantee, and walked by the
23
+ // test suites without loading figma-pull.ts.
24
+
25
+ import { READ_OPT_ROWS } from "./read-opts.ts";
26
+ import type { ReadScopeKind } from "./read-opts.ts";
27
+
28
+ // ---- families
29
+
30
+ export const FAMILY_NAMES = ["scope", "screenshot", "index", "daemon", "token"] as const;
31
+ export type Family = (typeof FAMILY_NAMES)[number];
32
+
33
+ export interface FamilyDef {
34
+ /** Its commands reach the plugin: they run on a session (a running daemon, or a bridge of their own). */
35
+ session: boolean;
36
+ /** Its commands write an export (the no-daemon warning and the stall check are for these). */
37
+ writes: boolean;
38
+ }
39
+
40
+ export const FAMILIES = {
41
+ /** What a pull exports (at most one); with none it pulls the current page. */
42
+ scope: { session: true, writes: true },
43
+ /** One node's reference PNG: writes a file, but it is not a scope. */
44
+ screenshot: { session: true, writes: true },
45
+ /** The cheap structural queries: print, never write outDir. */
46
+ index: { session: true, writes: false },
47
+ /** The `dtwin serve` daemon's lifecycle: owns the invocation, needs no plugin. */
48
+ daemon: { session: false, writes: false },
49
+ /** The stored bridge token's lifecycle: touches only the local token file. */
50
+ token: { session: false, writes: false },
51
+ } as const satisfies Record<Family, FamilyDef>;
52
+
53
+ /** The families whose commands run on a session, as a literal union — what figma-pull.ts types its runner maps by. */
54
+ export type SessionFamily = { [F in Family]: (typeof FAMILIES)[F]["session"] extends true ? F : never }[Family];
55
+
56
+ // ---- commands
57
+
58
+ export interface CommandDef {
59
+ id: string;
60
+ /** Its flag; null for the one command no flag names — a pull of the current page. */
61
+ flag: string | null;
62
+ family: Family;
63
+ /** A value-taking flag: the refusal when the value is missing; `nodeId` normalises it as a node id
64
+ * (node-id.ts), `many` makes it repeatable, `verbMissing` is the refusal of `dtwin <verb>` without it. */
65
+ value?: { missing: string; nodeId?: boolean; many?: boolean; verbMissing?: string };
66
+ /** The read this command plans (read-plan.ts), which decides the read options it takes; null for one
67
+ * that reads no design, which takes none. */
68
+ reads: ReadScopeKind | null;
69
+ /** Index family: what the command prints, as its read-option refusal says it. */
70
+ emits?: string;
71
+ /** Its `dtwin <verb> [sub]` spellings ([verb] alone is the verb with no sub-noun). */
72
+ verbs?: readonly (readonly [string] | readonly [string, string])[];
73
+ }
74
+
75
+ const INDEX_EMITS = "only prints a structural index (id/name/type/size)";
76
+
77
+ // Row order is the order a refusal names the commands of one family in.
78
+ export const COMMANDS = [
79
+ // ---- scopes: WHAT a pull exports, so at most one (collectFull is `if (allPages) … else if (page)`: a
80
+ // second scope would be dropped in silence and a plausible export of the WRONG scope written).
81
+ // No scope: the current page — its frames, the design system and assets.
82
+ { id: "pull", flag: null, family: "scope", reads: "pages" },
83
+ { id: "selection", flag: "--selection", family: "scope", reads: "selection" },
84
+ { id: "allPages", flag: "--all-pages", family: "scope", reads: "pages" },
85
+ { id: "page", flag: "--page", family: "scope", reads: "pages", value: { missing: "--page needs an id or name (see --list)", many: true } },
86
+ // Tokens/styles/components/hygiene: no page/frame walk and therefore no assets either (assets are exported
87
+ // per-node during that walk). See collect.ts's collectDesignSystemOnly for its one tradeoff
88
+ // (library-variable completeness depends on nodes actually being walked).
89
+ { id: "designSystem", flag: "--design-system", family: "scope", reads: "designSystem" },
90
+ // The LIBRARY-FILE pull, run with the library file open; like --design-system it walks no page.
91
+ { id: "asLibrary", flag: "--as-library", family: "scope", reads: "library", value: { missing: "a library name, e.g. --as-library \"Acme UI\"" } },
92
+ // A REAL export (properties + assets) of exactly ONE node — the CLI twin of the MCP figma_export_url tool.
93
+ // Unlike --children (peek, no recursion) and --screenshot (PNG only, no asset walk) it walks the node's
94
+ // subtree with the full serialize() pass, as a --page pull would for that subtree.
95
+ { id: "node", flag: "--node", family: "scope", reads: "node", value: { missing: "--node needs a node id or figma.com URL (see --list / --children)", nodeId: true } },
96
+ // ---- an on-demand PNG of ONE node (see collectScreenshot in collect.ts for why it is a single-node pull
97
+ // rather than a bulk pre-render pass). It WRITES a file, so it is not an index command; nor a scope.
98
+ { id: "screenshot", flag: "--screenshot", family: "screenshot", reads: "screenshot", verbs: [["screenshot"]],
99
+ value: { missing: "--screenshot needs a node id (see --list / --children)", nodeId: true, verbMissing: "dtwin screenshot needs a node id or a Figma URL" } },
100
+ // ---- the cheap indexes: no recursion, no node properties, no assets; they print and never write outDir.
101
+ // --list is the file-wide map (pages + top-level layers) you consult before paying for a deep export;
102
+ // --list-pages is depth 1, page names only, which needs no page load at all.
103
+ { id: "list", flag: "--list", family: "index", reads: null, emits: INDEX_EMITS, verbs: [["list"], ["list", "frames"]] },
104
+ { id: "listPages", flag: "--list-pages", family: "index", reads: null, emits: INDEX_EMITS, verbs: [["list", "pages"]] },
105
+ // The node-scoped twin of --list: one node's direct children, to peek inside a frame --list surfaced.
106
+ { id: "children", flag: "--children", family: "index", reads: null, emits: INDEX_EMITS, verbs: [["list", "children"]],
107
+ value: { missing: "--children needs a node id (see --list)", nodeId: true, verbMissing: "dtwin list children needs a node id or a Figma URL (ids come from `dtwin list`)" } },
108
+ // Which libraries the file draws on — the library analogue of --list before --page. Same budget tier.
109
+ { id: "listLibraries", flag: "--list-libraries", family: "index", reads: null, emits: "only prints the libraries this file uses", verbs: [["list", "libraries"], ["list", "libs"]] },
110
+ // The identity/liveness probe: what the plugin says it is and what the socket did — evidence for whether
111
+ // two files can share the bridge, whether figma.fileKey is available, whether a connection survives idling.
112
+ { id: "whoami", flag: "--whoami", family: "index", reads: null, emits: "only prints connection/plugin identity", verbs: [["whoami"]] },
113
+ // Which Figma FILES are connected (one connection per open file): the discovery step before --client.
114
+ { id: "listClients", flag: "--list-clients", family: "index", reads: null, emits: "only prints which Figma files are connected", verbs: [["list", "clients"]] },
115
+ // ---- daemon: --serve holds the bridge open until stopped; every ordinary command then routes through it.
116
+ { id: "serve", flag: "--serve", family: "daemon", reads: null, verbs: [["serve"]] },
117
+ { id: "stop", flag: "--stop", family: "daemon", reads: null, verbs: [["stop"]] },
118
+ { id: "daemonStatus", flag: "--daemon-status", family: "daemon", reads: null, verbs: [["status"]] },
119
+ // ---- token: each acts on the local token file only (--token-file is a MODIFIER, below).
120
+ { id: "tokenStatus", flag: "--token-status", family: "token", reads: null, verbs: [["token"], ["token", "status"]] },
121
+ { id: "showToken", flag: "--show-token", family: "token", reads: null, verbs: [["token", "show"]] },
122
+ { id: "rotateToken", flag: "--rotate-token", family: "token", reads: null, verbs: [["token", "rotate"]] },
123
+ { id: "forgetToken", flag: "--forget-token", family: "token", reads: null, verbs: [["token", "forget"]] },
124
+ ] as const satisfies readonly CommandDef[];
125
+
126
+ export type CommandId = (typeof COMMANDS)[number]["id"];
127
+ /** A row as its readers take it: its id typed, every optional field optional. */
128
+ export type CommandRow = CommandDef & { id: CommandId };
129
+ export const COMMAND_ROWS: readonly CommandRow[] = COMMANDS;
130
+ /** The command no flag names: a pull of the current page. */
131
+ export const DEFAULT_COMMAND: CommandRow = COMMAND_ROWS[0] as CommandRow; // the "pull" row, first by construction
132
+ /** The ids of a family's commands, as a literal union — what figma-pull.ts types each runner map by. */
133
+ export type CommandIdIn<F extends Family> = Extract<(typeof COMMANDS)[number], { family: F }>["id"];
134
+
135
+ // ---- modifiers: the flags that are not commands
136
+
137
+ export interface ModifierDef {
138
+ flag: string;
139
+ /** A value-taking modifier; its value is checked where it is read (figma-pull.ts readArgs). */
140
+ value?: boolean;
141
+ }
142
+ /** The value-taking modifiers: --scale (--screenshot's render scale), --client (WHICH connected Figma file),
143
+ * --token-file (the token to use this run), --timeout (every budget, and the wait for the plugin). */
144
+ export const VALUE_MODIFIERS = ["--scale", "--client", "--token-file", "--timeout"] as const;
145
+ export type ValueModifier = (typeof VALUE_MODIFIERS)[number];
146
+ export const MODIFIERS: readonly ModifierDef[] = [
147
+ { flag: "--help" },
148
+ { flag: "--json" },
149
+ ...READ_OPT_ROWS.map((o) => ({ flag: o.flag })),
150
+ ...VALUE_MODIFIERS.map((flag) => ({ flag, value: true })),
151
+ ];
152
+
153
+ // The order figma-pull.ts readArgs reads the value-taking flags in, which decides which of several
154
+ // missing values one command line is refused for first. It is not the rows' order (a refusal names the
155
+ // scopes --page before --node, but --node's value was always read first), so it is kept here, as it was
156
+ // before this table, so no refusal changed. --screenshot comes before --scale, whose "only applies to
157
+ // --screenshot" check runs as it is read. test/figma-pull-args.test.ts checks it lists exactly the
158
+ // table's value-taking flags.
159
+ export const VALUE_READ_ORDER: readonly string[] = ["--children", "--node", "--screenshot", "--scale", "--page", "--as-library", "--client", "--token-file", "--timeout"];
160
+
161
+ // ---- the combination guards, in the order the first refusal wins
162
+
163
+ /** "is a read option" / "are read options", decided once for a refusal's wording. */
164
+ export function plural(n: number): { are: string; they: string; them: string } {
165
+ return n > 1 ? { are: "are read options", they: "they", them: "them" } : { are: "is a read option", they: "it", them: "it" };
166
+ }
167
+
168
+ /** What one guard asks:
169
+ * within two commands of `family` given;
170
+ * cross a command of `family` beside any of `others` (commands of those families, or "readOpts":
171
+ * any read option given), named in that order;
172
+ * readOpts a command of `family` given read options its read does not take (every one, for a
173
+ * command that reads nothing);
174
+ * json --json beside anything but an index command;
175
+ * tokenFile --token-file beside a token command that acts on the STORED token. */
176
+ export type Guard =
177
+ | { kind: "within"; family: Family; say: (flags: string[]) => string }
178
+ | { kind: "cross"; family: Family; others: readonly (Family | "readOpts")[]; say: (cmd: string, others: string[]) => string }
179
+ | { kind: "readOpts"; family: Family; say: (cmd: CommandRow, flags: string[]) => string }
180
+ | { kind: "json"; say: () => string }
181
+ | { kind: "tokenFile"; ignoredBy: readonly CommandId[]; say: (cmd: string) => string };
182
+
183
+ export const GUARDS: readonly Guard[] = [
184
+ // Scope flags are MUTUALLY EXCLUSIVE, and the loser must not be discarded in silence: collectFull is
185
+ // `if (allPages) … else if (page)`, so `--all-pages --page Foo` would export all 25 pages while the user
186
+ // watched for one; `--selection --page Foo` never forwards the page at all. Both produce a plausible
187
+ // export of the WRONG scope — the failure you don't notice.
188
+ { kind: "within", family: "scope", say: (f) => `${f.join(" and ")} select different scopes — pass only one.` },
189
+ // --screenshot exports exactly one node's reference PNG — it is not a scope modifier, so combining it
190
+ // with one is the same silent-loss class the scope guard exists for.
191
+ { kind: "cross", family: "screenshot", others: ["scope"],
192
+ say: (_c, o) => `--screenshot renders ONE node's reference image — it cannot be combined with ${o.join(" / ")}. Run it on its own.` },
193
+ // It writes a file, so it is not an index command (which never do) — but combining it with one is
194
+ // still silent loss: only one command's output would appear.
195
+ { kind: "cross", family: "screenshot", others: ["index"],
196
+ say: (_c, o) => `--screenshot cannot be combined with ${o.join(" / ")} — run them as separate commands.` },
197
+ // Read options need a serialize()/node walk that --screenshot deliberately skips, so any of them here
198
+ // would be silently ignored exactly as they would on a list command.
199
+ { kind: "readOpts", family: "screenshot",
200
+ say: (_c, f) => { const p = plural(f.length); return `${f.join(" / ")} ${p.are} for a full export — --screenshot only renders a PNG, so ${p.they} would be silently ignored.`; } },
201
+ // --json: machine output for the index commands that print a TABLE for humans (--list-clients,
202
+ // --list-libraries). The primary caller of this CLI is an agent, and scraping an aligned table is how a
203
+ // column rename becomes a silent misread. The other index commands already print JSON, so the flag is
204
+ // accepted there as a no-op (one habit for every index command) and refused everywhere else — an
205
+ // export writes files and prints no result to make JSON of.
206
+ { kind: "json",
207
+ say: () => "--json applies to the commands that PRINT (--list-clients, --list-libraries; --list / --list-pages / --children / --whoami are JSON already). An export writes files instead — read its _manifest.json." },
208
+ // The index commands print and exit before any export runs, so an export flag combined with one is a
209
+ // no-op the user has no way to see.
210
+ { kind: "cross", family: "index", others: ["scope"],
211
+ say: (c, o) => `${c} only prints a structural index — it cannot be combined with ${o.join(" / ")}. Run the list first, then pull with the ids it prints.` },
212
+ // Two index commands at once: only one of them would run and print, and the other would vanish.
213
+ { kind: "within", family: "index", say: (f) => `${f.join(" and ")} are different queries — pass only one.` },
214
+ // The index commands emit structural fields only — they never serialize a node, call getCSSAsync, read
215
+ // measurements or export an asset — so every read option is a no-op there. Refused as a group,
216
+ // --no-assets included: exempting it would put the caller back to guessing WHICH flags survive a list.
217
+ // `--timeout` is not a read option — it genuinely applies (listTimeoutMs).
218
+ { kind: "readOpts", family: "index",
219
+ say: (c, f) => { const p = plural(f.length); return `${f.join(" / ")} ${p.are} for an EXPORT — ${c.flag} ${c.emits}, so ${p.they} would be silently ignored. Drop ${p.them} here, and pass ${p.them} to the --page pull afterwards.`; } },
220
+ // --design-system and --as-library never walk a page or node, so every read option their read does not
221
+ // take (read-opts.ts `scopes`: all but --variant-visuals, which enriches the catalog they build) is
222
+ // just as inert here as on a list command.
223
+ { kind: "readOpts", family: "scope",
224
+ say: (c, f) => { const p = plural(f.length); return `${f.join(" / ")} ${p.are} for a node/page walk — ${c.flag} skips that walk entirely, so ${p.they} would be silently ignored. Drop ${p.them} here, and pass ${p.them} to a --page pull afterwards.`; } },
225
+ // A daemon command owns the invocation: --serve --all-pages would start a daemon and never run the
226
+ // export the user typed, with nothing said about it.
227
+ { kind: "cross", family: "daemon", others: ["scope", "screenshot", "index", "readOpts"],
228
+ say: (c, o) => `${c} manages the background bridge — it cannot be combined with ${o.join(" / ")}. Start the daemon, then run your pull as a separate command (it will route through it automatically).` },
229
+ { kind: "within", family: "daemon", say: () => "--serve / --stop / --daemon-status are different commands — pass only one." },
230
+ // A token command owns the invocation for the same reason: it manages the local credential and never
231
+ // opens a bridge, so pairing it with a pull would start an export the user did not ask for — or,
232
+ // worse, silently do only the token half of what they typed.
233
+ { kind: "cross", family: "token", others: ["scope", "screenshot", "daemon", "index", "readOpts"],
234
+ say: (c, o) => `${c} manages the stored bridge token — it cannot be combined with ${o.join(" / ")}. Run it on its own, then run your command.` },
235
+ { kind: "within", family: "token", say: (f) => `${f.join(" and ")} are different commands — pass only one.` },
236
+ // --token-file names a token to READ; these three act on the STORED one, so beside them it would be
237
+ // silently ignored. --show-token is the one token command where it applies (print the token in that file).
238
+ { kind: "tokenFile", ignoredBy: ["tokenStatus", "rotateToken", "forgetToken"],
239
+ say: (c) => `${c} acts on the stored token, so --token-file would be silently ignored. Drop it (or, for --rotate-token/--forget-token, edit that file directly).` },
240
+ ];
241
+
242
+ // ---- verbs: the first word of `dtwin <verb>`
243
+
244
+ export interface VerbDef {
245
+ word: string;
246
+ /** How the word becomes the argv figma-pull.ts parses:
247
+ * "commands" its spellings are on COMMANDS rows; "pull" the word is dropped (`dtwin pull X` = `dtwin X`);
248
+ * "routed" its own entry point (figma-pull.ts cli()), before anything else is parsed;
249
+ * a flag the word becomes that flag (help, version). */
250
+ to: "commands" | "pull" | "routed" | "--help" | "--version";
251
+ /** Its line(s) in `dtwin --help`'s command summary ("" for a verb another verb's line covers). */
252
+ usage: string;
253
+ /** Its own `--help` page. Absent for a routed verb whose entry point answers --help itself, and for
254
+ * `help`, whose page IS the full usage. */
255
+ help?: string;
256
+ /** A verb with sub-nouns: the refusal for a sub-noun it does not have. */
257
+ badSub?: (sub: string) => string;
258
+ }
259
+
260
+ // `dtwin seed` answers its own --help (it is routed before verbHelp runs), and so does
261
+ // `bun bridge/src/seed-components.ts --help` — both print this one string.
262
+ export const SEED_HELP =
263
+ "dtwin seed [codeRoot] [outDir] [--dry-run]\n\n" +
264
+ " Pre-populate <outDir>/components.json from the Code Connect files already in your codebase —\n" +
265
+ " template files (`// url=` + `// component=` headers), `figma.connect(...)` calls, and\n" +
266
+ " `@FigmaConnect` (SwiftUI/Compose). It only reads source files: no bridge, no plugin, no token,\n" +
267
+ " never the network.\n\n" +
268
+ " codeRoot where to look (default .); node_modules, .git, dist, build and the like are skipped\n" +
269
+ " outDir where components.json lives (default design)\n" +
270
+ " --dry-run scan and report what it would add; write nothing\n\n" +
271
+ " Merges fill-missing-only: an existing entry's hand-authored fields are never overwritten. A\n" +
272
+ " mapping that carries only a node id is named through <outDir>/design-system.json when one is\n" +
273
+ " there (export it first); without it, such a mapping is skipped. Fill in each entry's \"import\"\n" +
274
+ " and \"props\" by hand afterwards.";
275
+
276
+ // `--help` must never be the thing that STARTS a server: `dtwin mcp --help` prints this and exits.
277
+ export const MCP_HELP =
278
+ "dtwin mcp\n\n" +
279
+ " Run the Design Twin MCP server on stdio. This is what a .mcp.json entry points at\n" +
280
+ " (`dtwin init --mcp` writes one); you do not normally run it by hand.\n\n" +
281
+ " It takes no flags. While it runs it owns the bridge, so ordinary `dtwin` commands\n" +
282
+ " route through it rather than starting a second one — `dtwin doctor` says who holds the port.\n\n" +
283
+ " Its tools mirror the CLI: figma_list_clients / figma_list / figma_export_* / figma_screenshot /\n" +
284
+ " figma_status / figma_write. Pass writeToDisk:true to get files plus a compact index instead\n" +
285
+ " of node payloads inline — asset bytes are never returned inline.";
286
+
287
+ // Row order is the order of `dtwin --help`'s command summary.
288
+ export const VERB_ROWS = [
289
+ {
290
+ word: "pull", to: "pull",
291
+ usage: " dtwin pull [outDir] [flags] = dtwin [outDir] [flags]. See pull --help",
292
+ help:
293
+ "dtwin pull [outDir] [--node <id> | --page <id|name> | --all-pages | --selection | --design-system | --as-library <name>]\n\n" +
294
+ " At most one scope; with none it pulls the CURRENT page (its frames, the design system and assets).\n" +
295
+ " Everything lands under outDir (default design/export — never bare `design`, a parallel tree nothing reads):\n" +
296
+ " --node <id> ONE frame, fully serialized, with its assets. Writes\n" +
297
+ " pages/<Page>/<Screen>__<id>.json plus .vars.json and .assets.json beside it,\n" +
298
+ " merges its tokens into variables.json, and indexes it in pages/index.json.\n" +
299
+ " --page <id|name> every top-level layer on one page (repeatable)\n" +
300
+ " --all-pages every top-level layer on EVERY page (slow on a large file)\n" +
301
+ " --selection whatever is selected in Figma right now\n" +
302
+ " --design-system tokens, styles and component catalogs — no page walk, no assets\n" +
303
+ " On a file that CONSUMES a library it holds that file's own tokens plus only the\n" +
304
+ " library variables something in it references (`remote: true`) — never the library's\n" +
305
+ " catalog; for that run --as-library from inside the library file\n" +
306
+ " --as-library <n> the same, from INSIDE a library file, into libraries/<slug>/\n\n" +
307
+ " Run `dtwin --help` for the full flag reference (read options, timeouts, the daemon).",
308
+ },
309
+ {
310
+ word: "list", to: "commands",
311
+ usage:
312
+ " dtwin list [pages|libraries|clients] = --list | --list-pages | --list-libraries | --list-clients\n" +
313
+ " dtwin list children <id|url> = --children <id|url>. See list --help",
314
+ badSub: (sub: string) => `unknown \`dtwin list ${sub}\` — use: dtwin list [pages|libraries|clients], or dtwin list children <id>`,
315
+ help:
316
+ "dtwin list [pages|libraries|clients] # structural indexes — cheap, write nothing\n" +
317
+ "dtwin list children <id|figma-url> # the direct children of one node\n\n" +
318
+ " dtwin list pages + their top-level LAYERS (frames, but also sections, groups,\n" +
319
+ " instances, text) with ids. On a sectioned file the screens are one\n" +
320
+ " level deeper — use `list children <section id>` to reach them.\n" +
321
+ " dtwin list pages just the page list\n" +
322
+ " dtwin list libraries enabled libraries + their variable collections. The SLOWEST read\n" +
323
+ " there is: a few seconds to ~15 s — it walks every instance and makes one Figma\n" +
324
+ " call per enabled library variable collection (about 2 s when no\n" +
325
+ " libraries are enabled). It prints progress while it works.\n" +
326
+ " dtwin list clients which Figma files are on the bridge right now, with their connIds\n" +
327
+ " dtwin list children one row per direct child: id, name, type, size, childCount; rows that share\n" +
328
+ " name + size also get a `title` (first visible text) and one warning per group;\n" +
329
+ " when titles match too, `distinctTexts` (up to 3 texts the row shows and its\n" +
330
+ " twins do not — skipped, with a warning, past the listing's read budget)\n\n" +
331
+ " --json machine output for the two that print a table (libraries, clients)\n" +
332
+ " --client <file> WHICH connected Figma file, when more than one is open\n\n" +
333
+ " These take no read options (--css/--measurements/…): they emit structural fields only, so\n" +
334
+ " those flags are refused rather than silently ignored.",
335
+ },
336
+ {
337
+ word: "screenshot", to: "commands",
338
+ usage: " dtwin screenshot <id|url> [--scale N] = --screenshot <id|url>. See screenshot --help",
339
+ help:
340
+ "dtwin screenshot <id|figma-url> [outDir] [--scale N] [--client <file>]\n\n" +
341
+ " Render ONE node to a reference PNG and write it to <outDir>/assets/<id>_ref.png.\n" +
342
+ " Cheap: no serialize(), no asset walk — typically under a second once the plugin is warm.\n\n" +
343
+ " Use it to tell two same-named frames apart BEFORE paying for a full pull: shoot each\n" +
344
+ " candidate, look, then pull the one you meant.\n\n" +
345
+ " --scale N override the render scale (default: auto, capped at 2048px on the longest side);\n" +
346
+ " the PNG is then <id>_shot@<N>x.png, so a thumbnail never takes the reference's name\n" +
347
+ " outDir default design/export; the PNG lands in its assets/ subdirectory, which is exactly\n" +
348
+ " where a later `pull --node` of the same frame writes its own reference — so shooting\n" +
349
+ " first costs nothing — at the default scale it leaves no duplicate (a --scale shot is its own file).",
350
+ },
351
+ {
352
+ word: "whoami", to: "commands",
353
+ usage: " dtwin whoami = --whoami",
354
+ help:
355
+ "dtwin whoami [--client <file>]\n\n" +
356
+ " Who is connected, with evidence rather than inference. Prints JSON to stdout:\n" +
357
+ " plugin what the plugin says it is: instance id, file name, fileKey (and whether Figma\n" +
358
+ " exposes it), plugin version, how long its runtime has been up\n" +
359
+ " connection what the SOCKET did, for the ADDRESSED file's connection (the one --client picked,\n" +
360
+ " or the only one): its connId, how long it has been up, connections this run, and\n" +
361
+ " takeovers — how many times a new connection displaced an earlier one\n" +
362
+ " Costs nothing: no page load, no node walk, no assets, and no file is written. Read from the\n" +
363
+ " daemon when one holds the port, so the numbers are real either way.\n\n" +
364
+ " Run it from each open file and compare instanceId: two different ids mean two instances coexist;\n" +
365
+ " a changed id on a repeat call means Figma restarted the plugin runtime.\n\n" +
366
+ " --client <file> WHICH connected Figma file, when more than one is open (see `dtwin list clients`)",
367
+ },
368
+ {
369
+ word: "serve", to: "commands",
370
+ usage: " dtwin serve | stop | status = --serve | --stop | --daemon-status",
371
+ help:
372
+ "dtwin serve\n\n" +
373
+ " Hold the bridge open until stopped (Ctrl-C here, or `dtwin stop` elsewhere). Every other dtwin\n" +
374
+ " command then routes through it and skips the plugin's reconnect — the difference between a\n" +
375
+ " pull that takes seconds and one that waits out a cold connection. Only ONE process can hold the\n" +
376
+ " port, so while it (or the MCP server) is up a second bridge refuses to start.\n\n" +
377
+ " It shuts itself down after FIGMA_DAEMON_IDLE_MIN minutes without a command (default 120;\n" +
378
+ " 0 disables), so an abandoned daemon cannot hold the port forever.\n" +
379
+ " Port: 8787 by default; FIGMA_BRIDGE_PORT=8788|8789 picks another (the plugin tries all three).",
380
+ },
381
+ {
382
+ word: "stop", to: "commands", usage: "",
383
+ help:
384
+ "dtwin stop\n\n" +
385
+ " Stop the daemon `dtwin serve` started, and free its port. Says so when none is running.",
386
+ },
387
+ {
388
+ word: "status", to: "commands", usage: "",
389
+ help:
390
+ "dtwin status\n\n" +
391
+ " Is a `dtwin serve` daemon running, and is the plugin connected to it? Prints JSON to stdout\n" +
392
+ " ({ \"daemon\": false } when none is running) and a one-line summary — pid, port, plugin connected\n" +
393
+ " or not, and the idle time left before auto-shutdown — to stderr. Needs no plugin and starts nothing.",
394
+ },
395
+ {
396
+ word: "token", to: "commands",
397
+ usage: " dtwin token [status|show|rotate|forget] = --token-status | --show-token | … See token --help",
398
+ badSub: (sub: string) => `unknown \`dtwin token ${sub}\` — use: dtwin token [status|show|rotate|forget]`,
399
+ help:
400
+ "dtwin token [status|show|rotate|forget]\n\n" +
401
+ " status (default) which token is in play, and from where — never prints the token itself\n" +
402
+ " show print the token, to paste into the plugin's \"Bridge token\" field\n" +
403
+ " rotate mint a new one (you must re-paste it into the plugin)\n" +
404
+ " forget delete the saved token\n\n" +
405
+ " FIGMA_BRIDGE_TOKEN in the environment overrides the saved file. `dtwin token` says which won.",
406
+ },
407
+ { word: "doctor", to: "routed", usage: " dtwin doctor [--wait N] [--json] # diagnose the setup; changes nothing. See doctor --help" },
408
+ { word: "init", to: "routed", usage: " dtwin init [--mcp] [--dry-run] # set up the project you are building. See init --help" },
409
+ { word: "seed", to: "routed", usage: " dtwin seed [codeRoot] [outDir] # components.json from your Code Connect files. See seed --help", help: SEED_HELP },
410
+ { word: "mcp", to: "routed", usage: " dtwin mcp # run the MCP server (what .mcp.json points at). See mcp --help", help: MCP_HELP },
411
+ { word: "help", to: "--help", usage: " dtwin help = --help" },
412
+ {
413
+ word: "version", to: "--version",
414
+ usage: " dtwin --version print the installed version",
415
+ help:
416
+ "dtwin version\n\n" +
417
+ " Print the installed version (also `dtwin --version`). Needs no bridge, no plugin and no token.",
418
+ },
419
+ ] as const satisfies readonly VerbDef[];
420
+
421
+ export type VerbWord = (typeof VERB_ROWS)[number]["word"];
422
+ /** The verbs routed to their own entry point, as a literal union — what figma-pull.ts types its route map by. */
423
+ export type RoutedVerb = Extract<(typeof VERB_ROWS)[number], { to: "routed" }>["word"];
424
+ export const VERB_DEFS: readonly (VerbDef & { word: VerbWord })[] = VERB_ROWS;
425
+
426
+ /** `dtwin --help`'s command summary: every verb's line(s), in row order. */
427
+ export const COMMAND_SUMMARY: string = VERB_DEFS.map((v) => v.usage).filter(Boolean).join("\n");
@@ -0,0 +1,146 @@
1
+ // client-presence.ts — when the MCP server's client has gone, and what the process does about it.
2
+ //
3
+ // The MCP server lives as long as its client (Claude Code), which it hears only through stdio and signals: stdin
4
+ // ending or closing, a write to stdout finding the client's read end closed, or SIGTERM / SIGINT / SIGHUP. Whichever
5
+ // of those comes first decides; the rest change nothing. WHAT the process does once the client has gone — exit now,
6
+ // or keep serving other sessions that share its bridge and idle out — is the bridge holder's decision
7
+ // (bridge-holder.ts clientLeft); this module turns those events into the holder's calls, aborts every tool call in
8
+ // flight, and times the exit once the holder has let go.
9
+ //
10
+ // Its ports are the process's streams, its signals and its exit, so a suite drives it in process with fake streams
11
+ // (test/client-presence.test.ts); figma-mcp.ts wires it to the real ones (processIO).
12
+ import { exitFlushed } from "./stdio.ts";
13
+
14
+ /** What the presence needs of the bridge holder (bridge-holder.ts Holder). */
15
+ export interface PresenceHolder {
16
+ clientLeft(how: string): void;
17
+ release(why?: string): void;
18
+ readonly released: Promise<string>;
19
+ }
20
+
21
+ /** A timer the presence arms (setTimeout's handle: an unref'd one never holds the event loop open). */
22
+ export interface PresenceTimer { unref(): void }
23
+
24
+ /** The process, as the presence uses it. */
25
+ export interface PresenceIO {
26
+ stdin: { once(event: "end" | "close", fn: () => void): unknown };
27
+ stdout: { on(event: "error", fn: (e: Error) => void): unknown };
28
+ stderr: { on(event: "error", fn: (e: Error) => void): unknown };
29
+ onSignal(sig: NodeJS.Signals, fn: () => void): void;
30
+ setTimer(fn: () => void, ms: number): PresenceTimer;
31
+ setExitCode(code: number): void;
32
+ /** exit once stderr has drained, waiting at most `capMs` (stdio.ts exitFlushed) */
33
+ exit(code: number, capMs: number): void;
34
+ }
35
+
36
+ /** The real process (figma-mcp.ts). */
37
+ export function processIO(): PresenceIO {
38
+ return {
39
+ stdin: process.stdin,
40
+ stdout: process.stdout,
41
+ stderr: process.stderr,
42
+ onSignal: (sig, fn) => { process.on(sig, fn); },
43
+ setTimer: (fn, ms) => setTimeout(fn, ms),
44
+ setExitCode: (code) => { process.exitCode = code; },
45
+ exit: (code, capMs) => exitFlushed(code, capMs),
46
+ };
47
+ }
48
+
49
+ export const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
50
+
51
+ // Claude Code ends a stdio server with SIGINT, then SIGTERM ~100 ms later, then SIGKILL ~400 ms after that
52
+ // (secondary source): a signal cancels and unlinks synchronously, gives the cancel frames SIGNAL_GRACE_MS to reach
53
+ // the plugin, and exits 0 waiting at most SIGNAL_FLUSH_CAP_MS for stderr — well inside that window.
54
+ export const SIGNAL_GRACE_MS = 150;
55
+ export const SIGNAL_FLUSH_CAP_MS = 200;
56
+ // Once the holder has let go, the loop drains to exit 0 on its own; this backstop (unref'd: it never holds the loop,
57
+ // but fires if something else does) exits after RELEASED_BACKSTOP_MS, waiting at most RELEASED_FLUSH_CAP_MS for stderr.
58
+ export const RELEASED_BACKSTOP_MS = 3000;
59
+ export const RELEASED_FLUSH_CAP_MS = 1000;
60
+
61
+ export interface ClientPresence {
62
+ /** Aborted when the client goes or a signal arrives: every tool call's signal includes it, so a client that exits
63
+ * mid-export does not leave the plugin walking for nobody (the SDK aborts a call's own signal on
64
+ * notifications/cancelled, but its stdio transport never notices stdin ENDING). */
65
+ readonly gone: AbortSignal;
66
+ /** At startup, before anything writes to stdout: the stdout / stderr guards, the signal handlers, and the exit once
67
+ * the holder has let go. Once only. */
68
+ start(): void;
69
+ /** Once the transport is reading stdin: its end or close is the client going away. */
70
+ watchStdin(): void;
71
+ }
72
+
73
+ /** The presence of the MCP client, over `io`, deciding through the holder `holder()` returns (made on first use). */
74
+ export function clientPresence(io: PresenceIO, holder: () => PresenceHolder): ClientPresence {
75
+ const gone = new AbortController();
76
+ const abandon = (why: string): void => { if (!gone.signal.aborted) gone.abort(new Error(`${why} — request abandoned`)); };
77
+
78
+ // The client has gone: stdin ended/closed, or a write to stdout found the client's read end closed. When the client
79
+ // is killed both pipes break at once, and a progress tick can reach stdout before stdin's 'end' is handled —
80
+ // whichever is seen first decides; the rest are no-ops (the holder's clientLeft runs once).
81
+ //
82
+ // What happens then is the holder's: exit now unless other processes use the bridge this one shares, in which case
83
+ // it keeps serving them and idles out like `dtwin serve` (FIGMA_DAEMON_IDLE_MIN). This deliberately departs from
84
+ // the MCP stdio binding's "Servers SHOULD exit promptly when their standard input is closed" (spec revision
85
+ // 2026-07-28, the stdio transport): the process is by then the shared daemon other sessions and commands route
86
+ // through, and exiting would cut them off mid-use. The window is how it still goes away on its own once they stop.
87
+ function wentAway(how: string): void {
88
+ guard(); // installed at start(); the belt for any path that runs earlier
89
+ abandon(`the MCP client went away (${how})`);
90
+ holder().clientLeft(how);
91
+ }
92
+
93
+ // Once the client has gone, its end of our stdout (and maybe stderr) is closed, and the next write — the result of
94
+ // a call the abort just settled, a progress notification — raises EPIPE as an 'error' event. The SDK's
95
+ // StdioServerTransport has no stdout 'error' listener, so that event is an uncaught exception and would kill the
96
+ // process that may now be serving other sessions. Installed at STARTUP, not when the client is seen to go: a killed
97
+ // client breaks both pipes at once, and a progress tick written before stdin's 'end' is handled must not be the
98
+ // thing that kills the orphan. A stdout error IS the client going away (EPIPE: its read end is closed;
99
+ // ERR_STREAM_DESTROYED: a write after that); a stderr one is swallowed — there is nobody left to tell.
100
+ let guarded = false;
101
+ function guard(): void {
102
+ if (guarded) return;
103
+ guarded = true;
104
+ io.stdout.on("error", (e: Error) => {
105
+ const code = "code" in e ? e.code : undefined;
106
+ wentAway(code === "EPIPE" || code === "ERR_STREAM_DESTROYED" ? "stdout closed" : `stdout failed: ${String(code ?? e.message)}`);
107
+ });
108
+ io.stderr.on("error", () => { /* the client is gone */ });
109
+ }
110
+
111
+ // SIGTERM / SIGINT / SIGHUP end this process — even while it serves other sessions (a signal is an explicit
112
+ // request). The runtime's default handler would kill it outright: no 'exit' event (so the daemon socket file stays
113
+ // behind) and no cancel frame for a read in flight. Once only: Claude Code's SIGINT is followed by a SIGTERM.
114
+ let signalled = false;
115
+ function onSignal(sig: NodeJS.Signals): void {
116
+ if (signalled) return;
117
+ signalled = true;
118
+ guard();
119
+ abandon(sig);
120
+ // The holder lets go of what this process holds: cancel frames go to the plugin for every request still in
121
+ // flight (server-core's close() sends them before it closes the sockets). It releases once: stdin EOF, a signal
122
+ // and the idle shutdown can race.
123
+ holder().release(`${sig} — closing the bridge and exiting`);
124
+ io.setTimer(() => io.exit(0, SIGNAL_FLUSH_CAP_MS), SIGNAL_GRACE_MS);
125
+ }
126
+
127
+ let started = false;
128
+ return {
129
+ gone: gone.signal,
130
+ start() {
131
+ if (started) return;
132
+ started = true;
133
+ const onReleased = (): void => {
134
+ io.setExitCode(0);
135
+ io.setTimer(() => io.exit(0, RELEASED_FLUSH_CAP_MS), RELEASED_BACKSTOP_MS).unref();
136
+ };
137
+ holder().released.then(onReleased, onReleased);
138
+ guard();
139
+ for (const sig of SIGNALS) io.onSignal(sig, () => onSignal(sig));
140
+ },
141
+ watchStdin() {
142
+ io.stdin.once("end", () => wentAway("stdin closed"));
143
+ io.stdin.once("close", () => wentAway("stdin closed"));
144
+ },
145
+ };
146
+ }
package/src/clock.ts ADDED
@@ -0,0 +1,45 @@
1
+ // clock.ts — the one seam every timed rule in the bridge runs on: what time it is, and "call me in ms".
2
+ //
3
+ // The rules themselves (a request's budget and stall check, pending-requests.ts; the daemon client's silence guard,
4
+ // daemon.ts) take a Clock instead of reaching for setTimeout and Date.now, so the suites drive them with a fake one
5
+ // (test/fake-clock.ts) and step time by hand instead of sleeping on the wall clock. systemClock is the adapter every
6
+ // running process uses. No load-time effects.
7
+
8
+ /** What a timed rule reads and arms. `now` is monotonic ms (only differences mean anything). `after` runs `fn` once,
9
+ * `ms` from now, and answers the function that cancels it — a no-op once it has run, or when called twice. `unref`:
10
+ * the timer must never be what keeps the process alive (an idle check); without it a pending timer holds the loop
11
+ * open, as a plain setTimeout does. */
12
+ export interface Clock {
13
+ now(): number;
14
+ after(ms: number, fn: () => void, o?: { unref?: boolean }): () => void;
15
+ }
16
+
17
+ /** The real timers: performance.now() (a wall-clock step can neither end nor stretch a wait) and setTimeout. */
18
+ export const systemClock: Clock = {
19
+ now: () => performance.now(),
20
+ after(ms, fn, o) {
21
+ const t = setTimeout(fn, ms);
22
+ if (o?.unref) t.unref();
23
+ return () => { clearTimeout(t); };
24
+ },
25
+ };
26
+
27
+ /** A deadline `ms` after it was (re)armed. `rearm` starts the whole window over from now — whether it was still
28
+ * armed or not; `cancel` disarms it for good (until a rearm). It expires at most once per arming. */
29
+ export interface Deadline {
30
+ rearm(): void;
31
+ cancel(): void;
32
+ readonly armed: boolean;
33
+ }
34
+
35
+ /** Arms a deadline on `clock` now: `onExpire` runs `ms` from now unless it is cancelled or re-armed first. */
36
+ export function deadline(clock: Clock, ms: number, onExpire: () => void): Deadline {
37
+ let stop: (() => void) | null = null;
38
+ const cancel = (): void => { if (stop) { stop(); stop = null; } };
39
+ const rearm = (): void => {
40
+ cancel();
41
+ stop = clock.after(ms, () => { stop = null; onExpire(); });
42
+ };
43
+ rearm();
44
+ return { rearm, cancel, get armed() { return stop !== null; } };
45
+ }