@design-twin/dtwin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
@@ -0,0 +1,337 @@
1
+ // variables-merge.ts — design/variables.json accumulates across single-screen pulls.
2
+ //
3
+ // A `--node`/`--selection` pull returns only the variables THAT node binds. Writing that slice
4
+ // straight to design/variables.json would make every pull silently delete
5
+ // the previous screen's tokens: pull screen A, pull screen B, and A's theme no longer resolves.
6
+ // Nothing would warn, because the screen JSON carries raw hex beside every binding — so the build would still
7
+ // render, just unthemed. That is the worst shape a data-loss bug can take.
8
+ //
9
+ // So: merge. A variable's identity is its Figma `key` (stable across files and across pulls); only
10
+ // when a key is absent (never seen on a real export, but the field is optional in the IR) do we fall
11
+ // back to collection+name, which is the weakest thing that is still not wrong. Collections merge the
12
+ // same way, unioning their `modes` — two slices of one collection can legitimately report different
13
+ // mode subsets.
14
+ //
15
+ // Merge direction: the NEW slice wins a value conflict (it is the fresher read of the same file) and
16
+ // the disagreement is recorded in `_conflicts` + `hygiene` rather than silently resolved. On the live
17
+ // runs that produced this module there were zero conflicts across five slices, which is the expected
18
+ // case: the slices are views of one variable set.
19
+ //
20
+ // Provenance is kept, not inferred: `_slices` records one entry per contributing pull — including the
21
+ // KEYS that pull carried — and the raw slice is also written verbatim beside its screen as
22
+ // <Screen>.vars.json by write-out.ts. So "which screen did this variable come from" survives the
23
+ // merge, and deleting design/export/variables.json resets everything.
24
+ //
25
+ // Two DIFFERENT variables (distinct keys) with the SAME collection and name are both kept — the key
26
+ // is the identity — but they are a conflict for every consumer that looks a token up by name, which
27
+ // is most of them (a theme generator, a slugged CSS variable, a designer reading a list). A real
28
+ // pull had two `Spacing / Space 4` (24 and 16) and two `Spacing / Space 2` in the union and
29
+ // an EMPTY `_conflicts`, because only same-key value changes were recorded. Each such pair is
30
+ // a `_conflicts` entry of kind "same-name", naming every key, its values and the screens whose
31
+ // slice carries it, and a CONFLICT line in `hygiene` — the two places the skills tell a reader to look.
32
+
33
+ // The per-pull slices are not under a "variables/" directory (none exists): bridge/src/pages-layout.ts's
34
+ // screenPaths() writes each slice BESIDE its screen, as pages/<Page>/<Screen>__<id>.vars.json (see
35
+ // design/README.md's template), not into a top-level variables/ directory.
36
+ import type {
37
+ VariableCollection, Variable, SliceEntry, ValueConflict, SameNameConflict, VariableConflict, VariablesDoc,
38
+ } from "./doc-types.ts";
39
+ import { ifDefined, isUnknownArray } from "./json-util.ts";
40
+
41
+ /** The pull a slice came from: its screen stem, its screen file, and (optionally) when it was read. */
42
+ export interface SliceInput {
43
+ screen: string;
44
+ file?: string;
45
+ at?: string;
46
+ }
47
+
48
+ /** The merged design/export/variables.json. `_note`/`exportedAt` are always set by mergeVariablesDoc
49
+ * (they are optional only because VariablesDoc declares them so); `_conflicts` only when non-empty. */
50
+ export interface MergedVariablesDoc extends VariablesDoc {
51
+ collections: VariableCollection[];
52
+ variables: Variable[];
53
+ hygiene: string[];
54
+ _slices: SliceEntry[];
55
+ }
56
+
57
+ /** What the CLI prints about one merge — this module never logs. */
58
+ export interface MergeStats {
59
+ added: number;
60
+ updated: number;
61
+ kept: number;
62
+ conflicts: ValueConflict[];
63
+ nameConflicts?: SameNameConflict[];
64
+ collections: number;
65
+ first: boolean;
66
+ }
67
+
68
+ export interface MergeResult {
69
+ doc: MergedVariablesDoc;
70
+ stats: MergeStats;
71
+ }
72
+
73
+ /** What varId / collId read — any variable or collection (or a partial one, or nothing). */
74
+ interface Keyed { key?: unknown; name?: unknown; collection?: unknown }
75
+
76
+ export const MERGE_NOTE =
77
+ "Merged across single-screen pulls: this file is the UNION of every --node/--selection export " +
78
+ "written into this directory, keyed on each variable's Figma key. Per-pull slices are kept verbatim " +
79
+ "beside each screen, as pages/<Page>/<Screen>__<id>.vars.json. Delete this file to start over.";
80
+
81
+ export function varId(v: Keyed | null | undefined): string {
82
+ if (v && typeof v.key === "string" && v.key) return "k:" + v.key;
83
+ return "n:" + String((v && v.collection) || "") + "\u0000" + String((v && v.name) || "");
84
+ }
85
+
86
+ export function collId(c: Keyed | null | undefined): string {
87
+ if (c && typeof c.key === "string" && c.key) return "k:" + c.key;
88
+ return "n:" + String((c && c.name) || "");
89
+ }
90
+
91
+ // Value equality is compared on the fields that DECIDE a token: what it resolves to per mode, and
92
+ // what kind of thing it is. Metadata that legitimately differs between slices (scopes reported by a
93
+ // partial read, hiddenFromPublishing) is not a conflict, so it does not generate noise.
94
+ function sameValue(a: Variable | null | undefined, b: Variable | null | undefined): boolean {
95
+ return JSON.stringify(a && a.values) === JSON.stringify(b && b.values) && (a && a.type) === (b && b.type);
96
+ }
97
+
98
+ function mergeModes(prev: unknown, next: unknown): string[] {
99
+ // Mode lists from two exports on disk (untyped JSON): only their string entries are modes.
100
+ const out: string[] = isUnknownArray(prev) ? prev.filter((m): m is string => typeof m === "string") : [];
101
+ for (const m of isUnknownArray(next) ? next : []) if (typeof m === "string" && !out.includes(m)) out.push(m);
102
+ return out;
103
+ }
104
+
105
+ // prev/next are whole variables.json documents ({collections, variables, hygiene}). `slice` names the
106
+ // pull that produced `next` so the provenance list can carry it. Returns the merged doc plus the
107
+ // stats the CLI prints — callers decide how loud to be, this module never logs.
108
+ //
109
+ // `prev` is whatever variables.json is on disk (untyped JSON). It is only merged when it is an object
110
+ // carrying a `variables` or `collections` array — i.e. a document this module wrote on an earlier
111
+ // pull — and from there it is read as that shape; every array is still re-checked with
112
+ // Array.isArray and every element null-guarded, exactly as the JS did.
113
+ function asMergedDoc(prev: unknown): VariablesDoc | null {
114
+ if (!prev || typeof prev !== "object") return null;
115
+ const d = prev as VariablesDoc;
116
+ return !Array.isArray(d.variables) && !Array.isArray(d.collections) ? null : d;
117
+ }
118
+
119
+ export function mergeVariablesDoc(prevRaw: unknown, next: VariablesDoc | null | undefined, slice?: SliceInput): MergeResult {
120
+ const nextDoc: VariablesDoc = next || {};
121
+ const prev = asMergedDoc(prevRaw);
122
+ if (!prev) {
123
+ // First pull into this directory: nothing to merge, but stamp the provenance so the second pull
124
+ // has a list to append to rather than an unexplained new key.
125
+ const core = {
126
+ collections: Array.isArray(nextDoc.collections) ? nextDoc.collections.slice() : [],
127
+ variables: Array.isArray(nextDoc.variables) ? nextDoc.variables.slice() : [],
128
+ hygiene: Array.isArray(nextDoc.hygiene) ? nextDoc.hygiene.slice() : [],
129
+ };
130
+ // Object.assign onto `core` keeps the JS key order: collections, variables, hygiene, _slices, …
131
+ const doc: MergedVariablesDoc = Object.assign(core, { _slices: slice ? [sliceEntry(slice, core)] : [] });
132
+ const nameConflicts = sameNameConflicts(doc.variables, doc._slices);
133
+ applyNameConflicts(doc, nameConflicts, []);
134
+ doc._note = MERGE_NOTE;
135
+ doc.exportedAt = docExportedAt(doc._slices);
136
+ return {
137
+ doc,
138
+ stats: { added: doc.variables.length, updated: 0, kept: 0, conflicts: [], nameConflicts, collections: doc.collections.length, first: true },
139
+ };
140
+ }
141
+
142
+ const collections: VariableCollection[] = [];
143
+ const collIndex = new Map<string, number>();
144
+ for (const c of Array.isArray(prev.collections) ? prev.collections : []) {
145
+ const id = collId(c);
146
+ if (collIndex.has(id)) continue;
147
+ collIndex.set(id, collections.length);
148
+ collections.push(Object.assign({}, c));
149
+ }
150
+ for (const c of Array.isArray(nextDoc.collections) ? nextDoc.collections : []) {
151
+ const id = collId(c);
152
+ const at = collIndex.get(id);
153
+ // `was` is always set when `at` is: collIndex only ever holds indexes of pushed (object) entries.
154
+ const was = at === undefined ? undefined : collections[at];
155
+ if (at === undefined || was === undefined) {
156
+ collIndex.set(id, collections.length);
157
+ collections.push(Object.assign({}, c));
158
+ continue;
159
+ }
160
+ const merged: VariableCollection = Object.assign({}, was, c);
161
+ merged.modes = mergeModes(was.modes, c.modes);
162
+ collections[at] = merged;
163
+ }
164
+
165
+ const variables: Variable[] = [];
166
+ const varIndex = new Map<string, number>();
167
+ for (const v of Array.isArray(prev.variables) ? prev.variables : []) {
168
+ const id = varId(v);
169
+ if (varIndex.has(id)) continue;
170
+ varIndex.set(id, variables.length);
171
+ variables.push(v);
172
+ }
173
+ const stats: MergeStats = { added: 0, updated: 0, kept: 0, conflicts: [], collections: collections.length, first: false };
174
+ for (const v of Array.isArray(nextDoc.variables) ? nextDoc.variables : []) {
175
+ const id = varId(v);
176
+ const at = varIndex.get(id);
177
+ // `before` is always set when `at` is: varIndex only ever holds indexes of pushed entries.
178
+ const before = at === undefined ? undefined : variables[at];
179
+ if (at === undefined || before === undefined) {
180
+ varIndex.set(id, variables.length);
181
+ variables.push(v);
182
+ stats.added++;
183
+ continue;
184
+ }
185
+ if (sameValue(before, v)) {
186
+ // Same token, same resolution — keep the richer record rather than the newer one, so a slice
187
+ // that happened to read fewer scopes cannot strip fields an earlier slice captured.
188
+ variables[at] = Object.assign({}, v, before);
189
+ stats.kept++;
190
+ continue;
191
+ }
192
+ stats.conflicts.push({
193
+ name: v.name,
194
+ ...ifDefined("collection", v.collection),
195
+ ...ifDefined("key", v.key),
196
+ was: before.values,
197
+ now: v.values,
198
+ ...ifDefined("from", slice && slice.screen),
199
+ });
200
+ variables[at] = v;
201
+ stats.updated++;
202
+ }
203
+
204
+ const hygiene: string[] = [];
205
+ const seenHy = new Set<string>();
206
+ for (const h of (Array.isArray(prev.hygiene) ? prev.hygiene : []).concat(Array.isArray(nextDoc.hygiene) ? nextDoc.hygiene : [])) {
207
+ const s = String(h);
208
+ if (seenHy.has(s)) continue;
209
+ seenHy.add(s);
210
+ hygiene.push(h);
211
+ }
212
+ for (const c of stats.conflicts) {
213
+ const line =
214
+ `CONFLICT: '${c.name}' (${c.collection}) resolves differently in ${slice && slice.screen ? "'" + slice.screen + "'" : "the newest pull"} ` +
215
+ `than in an earlier pull — the newest value is kept. Check which screen is right before generating a theme.`;
216
+ if (!seenHy.has(line)) {
217
+ seenHy.add(line);
218
+ hygiene.push(line);
219
+ }
220
+ }
221
+
222
+ const slices: SliceEntry[] = Array.isArray(prev._slices) ? prev._slices.filter((s) => !slice || s.screen !== slice.screen) : [];
223
+ if (slice) slices.push(sliceEntry(slice, nextDoc));
224
+ const doc: MergedVariablesDoc = { collections, variables, hygiene, _slices: slices };
225
+ // Value conflicts are events (a pull changed a known variable), so earlier ones are carried forward
226
+ // rather than wiped by the next pull; same-name conflicts are a property of the union, so they are
227
+ // recomputed from it every time.
228
+ const seenValue = new Set<string>();
229
+ const valueConflicts: ValueConflict[] = [];
230
+ for (const c of (Array.isArray(prev._conflicts) ? prev._conflicts : []).concat(stats.conflicts)) {
231
+ if (!c || c.kind === "same-name") continue;
232
+ const id = JSON.stringify([c.key, c.name, c.collection, c.now, c.from]);
233
+ if (seenValue.has(id)) continue;
234
+ seenValue.add(id);
235
+ valueConflicts.push(Object.assign({ kind: "value" }, c));
236
+ }
237
+ stats.nameConflicts = sameNameConflicts(variables, slices);
238
+ applyNameConflicts(doc, stats.nameConflicts, valueConflicts);
239
+ doc._note = MERGE_NOTE;
240
+ doc.exportedAt = docExportedAt(doc._slices);
241
+ return { doc, stats };
242
+ }
243
+
244
+ const SAME_NAME_PREFIX = "CONFLICT (same name): ";
245
+ const short = (k: unknown): string => (typeof k === "string" && k ? k.slice(0, 8) + "…" : "(no key)");
246
+
247
+ // Every [collection, name] held by more than one distinct variable in the union.
248
+ export function sameNameConflicts(variables: Variable[], slices: SliceEntry[] | null | undefined): SameNameConflict[] {
249
+ const groups = new Map<string, Map<string, Variable>>();
250
+ for (const v of variables) {
251
+ if (!v || !v.name) continue;
252
+ const id = String(v.collection || "") + "\u0000" + v.name;
253
+ let group = groups.get(id);
254
+ if (!group) { group = new Map(); groups.set(id, group); }
255
+ group.set(varId(v), v);
256
+ }
257
+ const out: SameNameConflict[] = [];
258
+ for (const members of groups.values()) {
259
+ if (members.size < 2) continue;
260
+ const list = [...members.values()];
261
+ const [first] = list;
262
+ if (first === undefined) continue; // cannot happen: members.size >= 2
263
+ out.push({
264
+ kind: "same-name",
265
+ name: first.name,
266
+ ...ifDefined("collection", first.collection),
267
+ sameValue: list.every((v) => resolvesAlike(v, first)),
268
+ variants: list.map((v) => ({
269
+ ...ifDefined("key", v.key),
270
+ values: v.values,
271
+ screens: (slices || []).filter((s) => Array.isArray(s.keys) && v.key !== undefined && s.keys.includes(v.key)).map((s) => s.screen),
272
+ })),
273
+ });
274
+ }
275
+ return out;
276
+ }
277
+
278
+ // Same resolution under different MODE NAMES: `Space 2` = 8 under Desktop/Tablet/Mobile and 8 under
279
+ // Mode 1 is one value, and tokens.ts emits such a pair once. Shared modes must agree; with no shared
280
+ // mode, both must be one constant, and the same one.
281
+ function resolvesAlike(a: Variable, b: Variable): boolean {
282
+ if (sameValue(a, b)) return true;
283
+ if ((a && a.type) !== (b && b.type)) return false;
284
+ const va = (a && a.values) || {}, vb = (b && b.values) || {};
285
+ const shared = Object.keys(va).filter((m) => m in vb);
286
+ if (shared.length) return shared.every((m) => JSON.stringify(va[m]) === JSON.stringify(vb[m]));
287
+ const all = [...Object.values(va), ...Object.values(vb)].map((x) => JSON.stringify(x));
288
+ return all.length > 0 && all.every((x) => x === all[0]);
289
+ }
290
+
291
+ function applyNameConflicts(doc: MergedVariablesDoc, nameConflicts: SameNameConflict[], valueConflicts: ValueConflict[]): void {
292
+ // Stale same-name lines (an earlier union's screen lists) are replaced, not accumulated.
293
+ doc.hygiene = doc.hygiene.filter((h) => !String(h).startsWith(SAME_NAME_PREFIX));
294
+ for (const c of nameConflicts) {
295
+ const parts = c.variants.map((v) => `key ${short(v.key)} = ${JSON.stringify(v.values || {})}` + (v.screens.length ? ` (from ${v.screens.join(", ")})` : ""));
296
+ doc.hygiene.push(
297
+ SAME_NAME_PREFIX +
298
+ `${c.variants.length} different variables are all called '${c.name}'${c.collection ? ` in collection '${c.collection}'` : ""} — ` +
299
+ parts.join(c.sameValue ? " and " : " vs ") + ". " +
300
+ (c.sameValue
301
+ ? "They resolve identically, so a theme emits them once, but they are still two variables."
302
+ : "They resolve DIFFERENTLY. Both are kept here (keyed by Figma key), but anything that looks a token up by NAME gets one of them — " +
303
+ "generate a screen's theme from that screen's own .vars.json (or design-system/tokens.json), not from this union.")
304
+ );
305
+ }
306
+ const all: VariableConflict[] = [...valueConflicts, ...nameConflicts];
307
+ if (all.length) doc._conflicts = all;
308
+ }
309
+
310
+ function sliceEntry(slice: SliceInput, doc: { variables?: Variable[]; collections?: VariableCollection[] } | null | undefined): SliceEntry {
311
+ const vars = doc && Array.isArray(doc.variables) ? doc.variables : [];
312
+ return {
313
+ screen: slice.screen,
314
+ ...ifDefined("file", slice.file),
315
+ at: slice.at || new Date().toISOString(),
316
+ variables: vars.length,
317
+ collections: doc && Array.isArray(doc.collections) ? doc.collections.length : 0,
318
+ // Which variables THIS pull carried, by key — the provenance a same-name conflict needs to say
319
+ // which screen each of the two variables came from.
320
+ keys: [...new Set(vars.map((v) => v && v.key).filter((k): k is string => typeof k === "string" && !!k))].sort(),
321
+ };
322
+ }
323
+
324
+ // A top-level freshness stamp, derived from `_slices[].at` (never invented separately from it) so a
325
+ // consumer that expects every export document to carry `exportedAt` (design-diff.ts's `previous()`,
326
+ // `dtwin doctor`'s export-age check) has one to read (without it the
327
+ // stale-baseline check that works for tokens.json could never fire for it). This is a MAX over the
328
+ // slice times, i.e. "when did the most recent contributing pull happen" — not a plain re-stamped
329
+ // "now", which must be avoided: `_slices[].at` legitimately re-appends on every
330
+ // re-pull of any ONE screen, so `exportedAt` moves whenever slices do, same as they do,
331
+ // and nothing here compares variable VALUES by exportedAt equality — that comparison stays keyed
332
+ // (`varId`/`sameValue` above), so a moved `exportedAt` alone can never manufacture a fake "token
333
+ // changed" the way a naive independent timestamp would.
334
+ export function docExportedAt(slices: SliceEntry[] | null | undefined): string | null {
335
+ const times = (Array.isArray(slices) ? slices : []).map((s) => s && typeof s.at === "string" ? s.at : null).filter((t): t is string => Boolean(t));
336
+ return times.length ? times.reduce((mx, t) => (t > mx ? t : mx)) : null;
337
+ }
package/src/verbs.ts ADDED
@@ -0,0 +1,140 @@
1
+ // verbs.ts — `dtwin <verb>` front door. A PURE argv → argv translation onto the flags figma-pull.ts
2
+ // already parses, so there is one parser and one set of guards, and every existing `--flag`
3
+ // invocation keeps working unchanged (clig.dev: subcommands to tame a large surface; never break the
4
+ // old spelling silently).
5
+ //
6
+ // dtwin pull [outDir] [flags] = dtwin [outDir] [flags]
7
+ // dtwin list [pages|libraries|clients] = --list | --list-pages | --list-libraries | --list-clients
8
+ // dtwin list children <id|url> = --children <id|url>
9
+ // dtwin whoami = --whoami
10
+ // dtwin screenshot <id|url> = --screenshot <id|url>
11
+ // dtwin serve | stop | status = --serve | --stop | --daemon-status
12
+ // dtwin token [status|show|rotate|forget] = --token-status | --show-token | --rotate-token | --forget-token
13
+ // dtwin help | version = --help | --version
14
+ // dtwin doctor | init | mcp | seed → their own entry points (routed in figma-pull.ts)
15
+ //
16
+ // A verb is only recognised as the FIRST argument. `dtwin design` still means "pull into ./design":
17
+ // an outDir that happens to be spelled like a verb is written `dtwin pull list` or `dtwin ./list`.
18
+
19
+ import { COMMAND_ROWS, VERB_DEFS } from "./cli-commands.ts";
20
+ import type { CommandRow } from "./cli-commands.ts";
21
+
22
+ export class VerbError extends Error {}
23
+
24
+ // Every spelling, help page and summary line is a row in cli-commands.ts (the command registry): the
25
+ // sub-nouns of `list` and `token` are the spellings on its COMMANDS rows, the verbs and their pages are
26
+ // its VERB_ROWS. Derived here, so a command added there is a verb here without a line of this file changing.
27
+ export { SEED_HELP } from "./cli-commands.ts";
28
+ export const VERBS: string[] = VERB_DEFS.map((v) => v.word);
29
+
30
+ // Words people (and agents) reach for first that are NOT verbs. Left alone they were a positional
31
+ // outDir: `dtwin export` created ./export and then waited for a plugin, looking exactly like a pull
32
+ // that was working. Same opt-outs as a near-miss: a path, an existing folder, or `dtwin pull export`.
33
+ const ALIASES: Record<string, string> = { export: "pull", get: "pull", fetch: "pull", download: "pull", sync: "pull", ls: "list", frames: "list", pages: "list pages", libraries: "list libraries", clients: "list clients",
34
+ check: "doctor", diagnose: "doctor", setup: "init", install: "init", connect: "doctor", start: "serve", daemon: "serve", kill: "stop", tokens: "token" };
35
+ // (`version` was listed here as an alias for `--version`, which made the real `dtwin version` a
36
+ // did-you-mean error that pointed at itself. It is a verb row of its own now.)
37
+
38
+ const isFlag = (a: unknown): boolean => typeof a === "string" && a.startsWith("-");
39
+
40
+ // Per-verb help. `dtwin screenshot --help` must not hit translate's "needs a node id" refusal, which
41
+ // the verb's own argument check would give if it ran before the global --help handler — a help probe
42
+ // is the one thing that must never be answered with an error. Every verb has its own page (a row's
43
+ // `help`) or answers through its own entry point (OWN_HELP_ROUTED); only `help` prints the full `dtwin --help`.
44
+ export const HELP: Record<string, string> = Object.fromEntries(VERB_DEFS.flatMap((v) => (v.help === undefined ? [] : [[v.word, v.help]])));
45
+
46
+ // The verbs that answer `--help` through their own entry point (routed in figma-pull.ts before verbHelp
47
+ // runs) and have no page here. `help` is the one other verb without a page: its page IS the full usage.
48
+ // test/cli-help.test.ts walks the verb rows and requires every one to have a page or be in this list.
49
+ export const OWN_HELP_ROUTED: readonly string[] = VERB_DEFS.filter((v) => v.to === "routed" && v.help === undefined).map((v) => v.word);
50
+
51
+ // The only port override is FIGMA_BRIDGE_PORT; DTWIN_PORT is not read, so setting it would be silently ignored.
52
+ // One sentence for the CLI and doctor, or null when DTWIN_PORT is not set.
53
+ export function dtwinPortWarning(env: Record<string, string | undefined>): string | null {
54
+ return env.DTWIN_PORT === undefined ? null : "DTWIN_PORT is not read — use FIGMA_BRIDGE_PORT (8787/8788/8789)";
55
+ }
56
+
57
+ /** A `dtwin <verb> [sub]` spelling of a command, with the command it means. */
58
+ interface Spelling { row: CommandRow; flag: string; words: readonly string[] }
59
+ const SPELLINGS: Spelling[] = COMMAND_ROWS.flatMap((row) => (row.flag === null ? [] : (row.verbs ?? []).map((words) => ({ row, flag: row.flag as string, words }))));
60
+
61
+ export function translate(argv: string[], exists: (p: string) => boolean = () => false): string[] {
62
+ const [verb, ...rest] = argv;
63
+ if (!verb || isFlag(verb)) return argv;
64
+ const def = VERB_DEFS.find((v) => v.word === verb);
65
+ if (def) {
66
+ if (def.to === "pull") return rest;
67
+ if (def.to === "routed") return argv; // doctor | init | mcp | seed: routed by figma-pull.ts, not translated here
68
+ if (def.to !== "commands") return [def.to, ...rest]; // help | version
69
+ const mine = SPELLINGS.filter((s) => s.words[0] === verb);
70
+ if (def.badSub) {
71
+ // A verb with sub-nouns (`list pages`, `token rotate`): the first word after it, unless it is a flag.
72
+ const sub = rest[0] && !isFlag(rest[0]) ? rest[0] : "";
73
+ const hit = mine.find((s) => (s.words[1] ?? "") === sub);
74
+ if (!hit) throw new VerbError(def.badSub(sub));
75
+ return takeValue(hit, rest.slice(sub ? 1 : 0));
76
+ }
77
+ const hit = mine.find((s) => s.words.length === 1);
78
+ if (hit) return takeValue(hit, rest);
79
+ }
80
+ // Not a verb: a positional outDir — unless it is a near-miss of one. `dtwin whomai` would otherwise
81
+ // start a full pull into ./whomai; a bare word one or two edits from a verb is far likelier
82
+ // a typo than a folder. A path (`./serv`), an existing folder (`exists`), or `dtwin pull serv` opts out.
83
+ const alias = ALIASES[verb.toLowerCase()];
84
+ if (alias && !/[\\/.]/.test(verb) && !exists(verb))
85
+ throw new VerbError(`unknown command \`${verb}\` — did you mean \`dtwin ${alias}\`? (to export into a folder named "${verb}": dtwin pull ${verb})`);
86
+ const near = nearestVerb(verb);
87
+ if (near && !/[\\/.]/.test(verb) && !exists(verb))
88
+ throw new VerbError(`unknown command \`${verb}\` — did you mean \`dtwin ${near}\`? (to export into a folder named "${verb}": dtwin pull ${verb})`);
89
+ return argv;
90
+ }
91
+
92
+ // A spelling's flag in front of what follows it. A value-taking command (`dtwin screenshot <id>`,
93
+ // `dtwin list children <id>`) needs its value next: it becomes the flag's value, the rest passes through.
94
+ function takeValue({ row, flag }: Spelling, rest: string[]): string[] {
95
+ if (!row.value) return [flag, ...rest];
96
+ if (!rest[0] || isFlag(rest[0])) throw new VerbError(row.value.verbMissing ?? row.value.missing);
97
+ return [flag, rest[0], ...rest.slice(1)];
98
+ }
99
+
100
+ // The verb whose help a given argv is asking for, or null. Exported so figma-pull can answer BEFORE
101
+ // translate runs its per-verb argument checks.
102
+ export function verbHelp(argv: unknown): string | null {
103
+ if (!Array.isArray(argv) || !argv.some((a) => a === "--help" || a === "-h")) return null;
104
+ const verb: unknown = argv[0];
105
+ return (typeof verb === "string" && verb && HELP[verb]) || null;
106
+ }
107
+
108
+ export function distance(a: string, b: string): number {
109
+ // Levenshtein, one row at a time. `row` is the previous row (row[j] = distance(a[0..i-1), b[0..j)));
110
+ // `diag`/`left` carry row[j-1] and the new row's [j-1] so no cell is read by index.
111
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j);
112
+ let last = b.length; // the bottom-right cell: row[b.length]
113
+ for (let i = 1; i <= a.length; i++) {
114
+ const next = [i];
115
+ let diag = i - 1; // row[0] of the previous row
116
+ let left = i; // next[0]
117
+ for (const [k, up] of row.slice(1).entries()) { // k = j - 1, up = row[j]
118
+ const cur = Math.min(up + 1, left + 1, diag + (a[i - 1] === b[k] ? 0 : 1));
119
+ next.push(cur);
120
+ diag = up;
121
+ left = cur;
122
+ }
123
+ row = next;
124
+ last = left;
125
+ }
126
+ return last;
127
+ }
128
+
129
+ // Two edits for the longer verbs. A short verb ("pull", "list", "stop", "mcp" …) is one edit from real
130
+ // folder names ("dist" ↔ "list"), so there only a dropped or extra LAST letter counts ("lis", "lists").
131
+ function nearestVerb(word: string): string | null {
132
+ const w = String(word).toLowerCase();
133
+ let best: { v: string; d: number } | null = null;
134
+ for (const v of VERBS) {
135
+ const d = distance(w, v);
136
+ const ok = v.length >= 5 ? d <= (v.length >= 6 ? 2 : 1) : d === 1 && (w.startsWith(v) || v.startsWith(w));
137
+ if (ok && (!best || d < best.d)) best = { v, d };
138
+ }
139
+ return best && best.v;
140
+ }