@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.
- package/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- 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 };
|