@toghen/figma 0.0.0-stage → 0.1.1

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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/cli/bin.mjs +19 -0
  4. package/cli/bridge.ts +243 -0
  5. package/cli/check.ts +62 -0
  6. package/cli/figcraft.ts +764 -0
  7. package/cli/figcraftApply.ts +1067 -0
  8. package/cli/figcraftFile.ts +476 -0
  9. package/cli/figcraftLibrary.ts +147 -0
  10. package/cli/figcraftRecord.ts +121 -0
  11. package/cli/figcraftScene.ts +661 -0
  12. package/cli/main.ts +218 -0
  13. package/cli/members.ts +65 -0
  14. package/cli/run.ts +756 -0
  15. package/component-files.md +665 -0
  16. package/dist/code.js +24161 -0
  17. package/dist/manifest.json +23 -0
  18. package/dist/ui.html +6918 -0
  19. package/guide.md +985 -0
  20. package/main/code.ts +50 -0
  21. package/main/components/apply.ts +704 -0
  22. package/main/components/endOfRun.ts +168 -0
  23. package/main/components/errors.ts +17 -0
  24. package/main/components/fieldWriter.ts +497 -0
  25. package/main/components/file.ts +263 -0
  26. package/main/components/receipt.ts +228 -0
  27. package/main/components/resolve.ts +1193 -0
  28. package/main/components/run.ts +273 -0
  29. package/main/components/snapshot.ts +341 -0
  30. package/main/components/stamp.ts +312 -0
  31. package/main/components/update.ts +465 -0
  32. package/main/dispatcher.ts +33 -0
  33. package/main/engines/batchRunner.ts +200 -0
  34. package/main/engines/ioAdapter.ts +132 -0
  35. package/main/engines/operationExecutor.ts +512 -0
  36. package/main/engines/propertyMapper.ts +221 -0
  37. package/main/engines/queryEngine.ts +45 -0
  38. package/main/engines/styleSyncEngine.ts +444 -0
  39. package/main/engines/variableSyncEngine.ts +561 -0
  40. package/main/handlers/auditSelection.ts +60 -0
  41. package/main/handlers/boundLayers.ts +51 -0
  42. package/main/handlers/cancelOperation.ts +18 -0
  43. package/main/handlers/executeApplyBindings.ts +69 -0
  44. package/main/handlers/executeScaffold.ts +59 -0
  45. package/main/handlers/executeStyleSync.ts +121 -0
  46. package/main/handlers/executeVariableSync.ts +82 -0
  47. package/main/handlers/exportDocument.ts +114 -0
  48. package/main/handlers/getSelection.ts +43 -0
  49. package/main/handlers/index.ts +30 -0
  50. package/main/handlers/inspectCorrespondence.ts +172 -0
  51. package/main/handlers/loadConventions.ts +86 -0
  52. package/main/handlers/loadDocument.ts +253 -0
  53. package/main/handlers/lock.ts +270 -0
  54. package/main/handlers/planApply.ts +157 -0
  55. package/main/handlers/planApplyBindings.ts +79 -0
  56. package/main/handlers/planScaffold.ts +84 -0
  57. package/main/handlers/planStyleSync.ts +49 -0
  58. package/main/handlers/planVariableSync.ts +49 -0
  59. package/main/handlers/readSelectionBindings.ts +160 -0
  60. package/main/handlers/resizeUi.ts +105 -0
  61. package/main/handlers/runComponent.ts +65 -0
  62. package/main/handlers/templates.ts +462 -0
  63. package/main/handlers/tokenGraph.ts +122 -0
  64. package/main/runtimeState.ts +66 -0
  65. package/main/utils/abortController.ts +11 -0
  66. package/main/utils/boundVariables.ts +84 -0
  67. package/main/utils/buildPayloads.ts +85 -0
  68. package/main/utils/correspondence.ts +658 -0
  69. package/main/utils/designSource.ts +122 -0
  70. package/main/utils/figmaCollectionIndex.ts +120 -0
  71. package/main/utils/figmaStyleAdapter.ts +268 -0
  72. package/main/utils/figmaTokenAdapter.ts +255 -0
  73. package/main/utils/flattenDesignSystem.ts +38 -0
  74. package/main/utils/folds.ts +203 -0
  75. package/main/utils/fontStack.ts +57 -0
  76. package/main/utils/lists.ts +4 -0
  77. package/main/utils/lockStore.ts +72 -0
  78. package/main/utils/moves.ts +67 -0
  79. package/main/utils/pageRoots.ts +58 -0
  80. package/main/utils/planSymbols.ts +96 -0
  81. package/main/utils/purpose.ts +351 -0
  82. package/main/utils/recentStore.ts +166 -0
  83. package/main/utils/resolverModes.ts +271 -0
  84. package/main/utils/rootRecord.ts +137 -0
  85. package/main/utils/shardedData.ts +142 -0
  86. package/main/utils/standing.ts +106 -0
  87. package/main/utils/styleTarget.ts +51 -0
  88. package/main/utils/sum.ts +612 -0
  89. package/main/utils/symbolicIdRegistry.ts +26 -0
  90. package/main/utils/syncState.ts +133 -0
  91. package/main/utils/templateStore.ts +73 -0
  92. package/main/utils/tokenPath.ts +28 -0
  93. package/main/utils/tokenStamps.ts +91 -0
  94. package/manifest.json +15 -0
  95. package/package.json +64 -4
  96. package/reference.md +1609 -0
  97. package/shared/components/artboard.ts +126 -0
  98. package/shared/components/axes.ts +420 -0
  99. package/shared/components/bindings.ts +64 -0
  100. package/shared/components/blueprint.ts +248 -0
  101. package/shared/components/check.ts +705 -0
  102. package/shared/components/diff.ts +668 -0
  103. package/shared/components/fields.ts +404 -0
  104. package/shared/components/groups.ts +306 -0
  105. package/shared/components/index.ts +140 -0
  106. package/shared/components/members.ts +280 -0
  107. package/shared/components/model.ts +273 -0
  108. package/shared/components/order.ts +53 -0
  109. package/shared/components/read.ts +480 -0
  110. package/shared/components/stack.ts +124 -0
  111. package/shared/components/steps.ts +186 -0
  112. package/shared/components/validate.ts +565 -0
  113. package/shared/conventions.ts +28 -0
  114. package/shared/inputs.ts +40 -0
  115. package/shared/operations.ts +233 -0
  116. package/shared/pendingNames.ts +39 -0
  117. package/shared/protocol.ts +588 -0
  118. package/shared/tokenTypeMap.ts +21 -0
package/cli/run.ts ADDED
@@ -0,0 +1,756 @@
1
+ /**
2
+ * A run against a Figma file, through the plugin: `variables`, `components` and `sync`.
3
+ *
4
+ * The same steps in the same order, whichever of the three asked (specs.md, *The command line*, *A run*):
5
+ * read the files from disk, pair with the plugin, sync the variables and the styles, run each component file.
6
+ * The command line is a courier: a component file travels as its text and its name, and the plugin reads,
7
+ * validates and builds it (`main/components/file.ts`), so a problem with a file is the plugin's to report, with
8
+ * the file name and line. A dry run goes through every step and writes nothing, giving each component run the
9
+ * names the earlier plans would have created, so a name a run would make resolves where an unmade one would stop it.
10
+ * With a library, two plugins pair: the steps run in the library file, and the library's keys go to the target file's
11
+ * lock (`copyKeys`).
12
+ */
13
+
14
+ import { resolve } from 'node:path';
15
+ import { load, Resolver, type ResolverDocument } from '@toghen/lib';
16
+ import { namesOfFiles } from '../main/components/file.js';
17
+ import { declaredLibraries } from '../main/components/resolve.js';
18
+ import { orderFiles } from '../shared/components/order.js';
19
+ import { inputsFromDocument } from '../shared/inputs.js';
20
+ import type { Pending, RunOptions } from '../shared/components/steps.js';
21
+ import type { Operation, SkipCause } from '../shared/operations.js';
22
+ import { pendingNames } from '../shared/pendingNames.js';
23
+ import type { Command, LockedLibrary, Overrides, PlanPayload, Response } from '../shared/protocol.js';
24
+ import { PORT, newCode, pairing, serve, type Bridge } from './bridge.js';
25
+ import { bridgeOn, closeTab, openFigcraft, pinPath, type FigcraftBridge } from './figcraft.js';
26
+ import { counted, describeTarget, enable, libraryName, lockedFrom, publish } from './figcraftLibrary.js';
27
+
28
+ export interface Io {
29
+ /** Where relative paths are resolved: the directory the person ran the command from. */
30
+ cwd: string;
31
+ readFile(path: string): string;
32
+ stdout(text: string): void;
33
+ stderr(text: string): void;
34
+ }
35
+
36
+ /** What a run needs beyond reading and printing: where the pairing code is kept, and, for tests, the port and how long to wait for a plugin paired earlier. */
37
+ export interface RunIo extends Io {
38
+ /** Where the pairing code is kept between runs. */
39
+ pairingFile: string;
40
+ /** Tests only: the port to listen on. The plugin's manifest names 3055, which is the only port it may reach. */
41
+ port?: number;
42
+ /** How long to wait for a plugin paired in an earlier run to reconnect before asking for a new code. */
43
+ pairedWaitMs?: number;
44
+ }
45
+
46
+ /** How the entry is loaded: `--name` and `--overrides`, as the person typed them. */
47
+ export interface LoadArguments {
48
+ /** Token files only: the collection's name. */
49
+ name?: string;
50
+ /** The path of a JSON object: a `$ref` as written → the location to read instead. */
51
+ overrides?: string;
52
+ }
53
+
54
+ export interface RunArguments extends LoadArguments {
55
+ /** What to sync, when the run syncs variables and styles: a resolver document, one token file, or several in order. */
56
+ entry?: string | string[];
57
+ /** A resolver document that is only loaded into the plugin (`components --resolver`): the files take their axes from it, and nothing is synced. */
58
+ designResolver?: string;
59
+ files: string[];
60
+ /** A resolver document whose resolved tokens are the data of the artboard files among `files`. */
61
+ input?: string;
62
+ collection?: string;
63
+ page?: string;
64
+ dryRun: boolean;
65
+ allowBreaking: boolean;
66
+ /** Write the document's value over a mode, or a component's field, edited in Figma. */
67
+ overwriteDrift?: boolean;
68
+ /** List every skip, the quiet ones too, and every patch of a component set with its fields. */
69
+ verbose?: boolean;
70
+ /** Run into the file FigCraft shows, through its control socket on this port, instead of through the plugin. */
71
+ figcraft?: number;
72
+ /** Run into this `.figcraft` file headless, with `figcraft-cli mcp` and no app, and save it once the run has completed. */
73
+ figcraftFile?: string;
74
+ /**
75
+ * The library's name, as Figma names it: pair a second plugin, in the library file, build there, and copy the
76
+ * library's keys into the target file's lock. With no entry and no files (`lock <name>`), only the copy.
77
+ */
78
+ library?: string;
79
+ /**
80
+ * The libraries the files may take names from (`RunOptions.libraries`): their names as the lock holds them, in Figma;
81
+ * their `.figcraft` files, in FigCraft, which the run enables and refreshes in the lock first.
82
+ */
83
+ uses?: string[];
84
+ }
85
+
86
+ /** What the command line loaded: the bundle the core made, and the `LOAD_DOCUMENT` that has the plugin make it again. */
87
+ export interface Source {
88
+ /** `load`'s self-contained document. */
89
+ document: ResolverDocument;
90
+ /**
91
+ * The payload to send: every file the load read, by its absolute path, with the entry, the name and the map. The
92
+ * plugin runs the same `load` over them, so its screen shows the composition and the collisions it found, and a
93
+ * relative `$ref` resolves there as it did here.
94
+ */
95
+ load: Omit<Extract<Command, { type: 'LOAD_DOCUMENT' }>['payload'], 'dryRun'>;
96
+ }
97
+
98
+ /** The label of an entry in a message: its paths as typed, in order. */
99
+ export const entryLabel = (entry: string | string[]): string => (typeof entry === 'string' ? entry : entry.join(', '));
100
+
101
+ /** Loads the entry, reading only what its references reach, and keeps each file read for the plugin. */
102
+ export async function readSource(entry: string | string[], io: Io, { name, overrides: mapFile }: LoadArguments = {}): Promise<Source> {
103
+ const at = (path: string): string => resolve(io.cwd, path);
104
+ const absolute = typeof entry === 'string' ? at(entry) : entry.map(at);
105
+ let overrides: Overrides | undefined;
106
+ if (mapFile !== undefined) {
107
+ let map: unknown;
108
+ try {
109
+ map = JSON.parse(io.readFile(at(mapFile)));
110
+ } catch (error) {
111
+ throw new Error(`--overrides ${mapFile} cannot be read as JSON: ${(error as Error).message}`);
112
+ }
113
+ if (map === null || typeof map !== 'object' || Array.isArray(map)) {
114
+ throw new Error(`--overrides ${mapFile} must hold one JSON object: a $ref as written → the location to read instead`);
115
+ }
116
+ overrides = map as Overrides;
117
+ }
118
+ const read = new Map<string, string>();
119
+ const { document } = await load(absolute, {
120
+ read: async (location) => {
121
+ const text = io.readFile(location);
122
+ read.set(location, text);
123
+ return text;
124
+ },
125
+ ...(name !== undefined ? { name } : {}),
126
+ ...(overrides !== undefined ? { overrides } : {}),
127
+ });
128
+ return {
129
+ document,
130
+ load: {
131
+ files: [...read].map(([path, text]) => ({ path, text })),
132
+ entry: absolute,
133
+ ...(name !== undefined ? { name } : {}),
134
+ ...(overrides !== undefined ? { overrides } : {}),
135
+ },
136
+ };
137
+ }
138
+
139
+ /** The data of the artboard files: the `--input` document, bundled with its source files, and resolved at its defaults. */
140
+ export async function readInputs(file: string, io: Io): Promise<Record<string, unknown>> {
141
+ const { document } = await load(resolve(io.cwd, file), { read: async (location) => io.readFile(location) });
142
+ return inputsFromDocument(document);
143
+ }
144
+
145
+ /** An artboard file by its name. */
146
+ const isArtboard = (file: string): boolean => /\.artboard\.kdl$/.test(file);
147
+
148
+ /** A component file as it travels: its text, and its name as the person typed it, for messages. */
149
+ interface ComponentText {
150
+ fileName: string;
151
+ text: string;
152
+ }
153
+
154
+ const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`;
155
+
156
+ /** A skip's cause in a summary's words. */
157
+ const CAUSE_WORDS: Record<SkipCause, string> = {
158
+ unchanged: 'unchanged',
159
+ drifted: 'drifted',
160
+ copy: 'copies',
161
+ 'unsupported-type': 'unsupported type',
162
+ 'refused-routing': 'refused',
163
+ 'closed-collection': 'in a closed collection',
164
+ 'type-conflict': 'type conflict',
165
+ 'cannot-write': 'cannot be written',
166
+ 'cannot-build': 'cannot be built',
167
+ 'no-font': 'no font',
168
+ 'font-not-loaded': 'font not loaded',
169
+ };
170
+
171
+ /** How many skips of one cause are listed before the rest are only counted. */
172
+ const LISTED = 10;
173
+
174
+ /** What a plan says it would do, in the counts the plugin's own surface shows, its skips by cause, and each warning it raised. */
175
+ function describePlan(label: string, plan: PlanPayload, io: Io, verbose: boolean): void {
176
+ const s = plan.summary ?? {};
177
+ // The most frequent cause first; the drifted count is among them, so it is not said again beside them (R14).
178
+ const causes = Object.entries(s.skipsByCause ?? {}).sort(([, a], [, b]) => b - a) as [SkipCause, number][];
179
+ const byCause = causes.length > 0 ? ` (${causes.map(([cause, n]) => `${n} ${CAUSE_WORDS[cause]}`).join(' · ')})` : '';
180
+ const parts = [`${s.creates ?? 0} creates`, `${s.updates ?? 0} updates`, `${s.skips ?? 0} skips${byCause}`];
181
+ if (s.modes) parts.push(`${s.modes} modes`);
182
+ if (s.scopes) parts.push(`${s.scopes} scopes`);
183
+ if (s.drifted && s.skipsByCause === undefined) parts.push(`${s.drifted} drifted`);
184
+ if (s.warnings) parts.push(`${s.warnings} warnings`);
185
+ if (s.breaking) parts.push(`${s.breaking} breaking`);
186
+ io.stdout(`${label} plan: ${parts.join(' · ')}\n`);
187
+ describeRows(plan.operations, io);
188
+ describeSkips(plan.operations, io, verbose);
189
+ }
190
+
191
+ /**
192
+ * The skips `describeRows` leaves: each problem cause's rows, ten of them and then how many more (R12: the counts above
193
+ * are whole, only the list is cut). `unchanged` is quiet: counted, and listed only with `--verbose`, which lists every row.
194
+ */
195
+ function describeSkips(operations: Operation[], io: Io, verbose: boolean): void {
196
+ const groups = new Map<SkipCause, string[]>();
197
+ for (const operation of operations) {
198
+ if (operation.kind !== 'SKIP' || !operation.cause || operation.reported || operation.cause === 'drifted') continue;
199
+ if (operation.cause === 'unchanged' && !verbose) continue;
200
+ const line = operation.cause === 'unchanged' ? `unchanged: ${operation.path}` : `skipped: ${operation.reason}`;
201
+ groups.set(operation.cause, [...(groups.get(operation.cause) ?? []), line]);
202
+ }
203
+ for (const lines of groups.values()) {
204
+ const listed = verbose ? lines : lines.slice(0, LISTED);
205
+ for (const line of listed) io.stdout(` ${line}\n`);
206
+ if (listed.length < lines.length) io.stdout(` …and ${lines.length - listed.length} more; --verbose lists them\n`);
207
+ }
208
+ }
209
+
210
+ /** The steps that write to a member that exists: a member a real run counts as updated has at least one. */
211
+ const MEMBER_WRITES = new Set(['CREATE_LAYER', 'REPLACE_LAYER', 'MOVE_LAYER', 'PATCH_LAYER', 'SWAP_COMPONENT', 'DELETE_LAYER']);
212
+
213
+ /** The rows a person must not have to count to find: breaking steps, notes, warnings, reported skips, drift. */
214
+ function describeRows(operations: Operation[], io: Io): void {
215
+ for (const operation of operations) {
216
+ if (operation.kind === 'BREAKING') io.stdout(` breaking: ${operation.message}\n`);
217
+ else if (operation.kind === 'NOTE') io.stdout(` note: ${operation.message}\n`);
218
+ else if (operation.kind === 'WARNING') io.stdout(` warning: ${operation.path ? `${operation.path} — ` : ''}${operation.message}\n`);
219
+ else if (operation.kind === 'SKIP' && operation.reported) io.stdout(` skipped: ${operation.reason}\n`);
220
+ else if (operation.kind === 'SKIP' && operation.reason === 'drifted')
221
+ io.stdout(` drifted: ${operation.path}${operation.message ? ` — ${operation.message}` : ''}\n`);
222
+ }
223
+ }
224
+
225
+ /** The plugins a run holds: the one in the target file, and, with `--library`, the one in the library file. */
226
+ type RoleName = 'library' | 'target';
227
+
228
+ /** Where a role's code is kept between runs: the target's in the pairing file, the library's beside it. */
229
+ const keptIn = (io: RunIo, role: RoleName): string => (role === 'target' ? io.pairingFile : `${io.pairingFile}.library`);
230
+
231
+ /**
232
+ * Waits for the plugins. A plugin paired in an earlier run reconnects on its own with its role's kept code; for every
233
+ * role nobody came back for, the person is told a new code, with the file it is for.
234
+ */
235
+ async function pairUp(io: RunIo, port: number, library?: string): Promise<Partial<Record<RoleName, Bridge>> & { target: Bridge }> {
236
+ const roles: RoleName[] = library === undefined ? ['target'] : ['library', 'target'];
237
+ const kept = new Map(roles.map((role) => [role, pairing.read(keptIn(io, role))]));
238
+ // A role with no kept code listens with one nobody was told, until it is given one below.
239
+ const bridges = await serve(
240
+ port,
241
+ Object.fromEntries(roles.map((role) => [role, kept.get(role) ?? newCode()])) as Record<RoleName, string>,
242
+ );
243
+ const close = async (error: unknown): Promise<never> => {
244
+ await bridges.target.close();
245
+ throw error;
246
+ };
247
+ const paired = new Set<RoleName>();
248
+ for (const role of roles)
249
+ bridges[role].paired.then(
250
+ () => paired.add(role),
251
+ () => {},
252
+ );
253
+
254
+ const back = roles.filter((role) => kept.get(role) !== undefined);
255
+ if (back.length > 0) {
256
+ io.stdout(`Looking for the plugin${back.length === 1 ? '' : 's'} paired earlier on ws://localhost:${bridges.target.port}…\n`);
257
+ let timer: ReturnType<typeof setTimeout> | undefined;
258
+ await Promise.race([
259
+ Promise.all(back.map((role) => bridges[role].paired)),
260
+ new Promise((done) => (timer = setTimeout(done, io.pairedWaitMs ?? 3000))),
261
+ ])
262
+ .finally(() => clearTimeout(timer))
263
+ .catch(close);
264
+ }
265
+
266
+ const fresh = roles.filter((role) => !paired.has(role));
267
+ if (fresh.length > 0) {
268
+ const codes = new Map<RoleName, string>();
269
+ for (const role of fresh) {
270
+ // A kept code nobody came back with is replaced, so a plugin still open with it is refused from now on.
271
+ const code = newCode();
272
+ bridges[role].renew(code);
273
+ pairing.write(keptIn(io, role), code);
274
+ codes.set(role, code);
275
+ }
276
+ if (library === undefined) {
277
+ io.stdout(
278
+ `Pairing code: ${codes.get('target')}\nOpen the Toghen plugin in the target file, type the code into the Source card at the foot of its Load pane, and press Connect.\n`,
279
+ );
280
+ } else {
281
+ const said = fresh
282
+ .map((role) =>
283
+ role === 'library'
284
+ ? `in the library file "${library}", enter ${codes.get(role)}`
285
+ : `in the target file, enter ${codes.get(role)}`,
286
+ )
287
+ .join('; ');
288
+ io.stdout(
289
+ `${said[0].toUpperCase()}${said.slice(1)}.\nOpen the Toghen plugin in ${fresh.length === 1 ? 'that file' : 'each file'}, type the code into the Source card at the foot of its Load pane, and press Connect.\n`,
290
+ );
291
+ }
292
+ io.stdout(`Waiting for the plugin${fresh.length === 1 ? '' : 's'} on ws://localhost:${bridges.target.port}…\n`);
293
+ await Promise.all(fresh.map((role) => bridges[role].paired)).catch(close);
294
+ }
295
+ io.stdout(roles.length === 1 ? 'The plugin is paired.\n' : 'Both plugins are paired.\n');
296
+ return bridges;
297
+ }
298
+
299
+ /** Runs the steps. Returns the exit code: 0 when every step succeeded, 1 when a check failed or Figma refused a step. */
300
+ export async function run(args: RunArguments, io: RunIo): Promise<number> {
301
+ // 1. Read everything from disk. Nothing is read for meaning here but the order the component files go in: the plugin does the rest.
302
+ const entry = args.entry ?? args.designResolver;
303
+ let source: Source | undefined;
304
+ if (entry !== undefined) {
305
+ try {
306
+ source = await readSource(entry, io, args);
307
+ } catch (error) {
308
+ io.stderr(`${entryLabel(entry)}: ${(error as Error).message}\n`);
309
+ return 1;
310
+ }
311
+ }
312
+ let inputs: Record<string, unknown> | undefined;
313
+ if (args.input !== undefined) {
314
+ try {
315
+ inputs = await readInputs(args.input, io);
316
+ } catch (error) {
317
+ io.stderr(`${args.input}: ${(error as Error).message}\n`);
318
+ return 1;
319
+ }
320
+ }
321
+ const components: ComponentText[] = [];
322
+ let unreadable = false;
323
+ for (const file of args.files) {
324
+ try {
325
+ components.push({ fileName: file, text: io.readFile(resolve(io.cwd, file)) });
326
+ } catch (error) {
327
+ io.stderr(`${file} cannot be read: ${(error as Error).message}\n`);
328
+ unreadable = true;
329
+ }
330
+ }
331
+ if (unreadable) return 1;
332
+
333
+ // 2. Wait for the plugin, or both with a library, or reach FigCraft, which needs no plugin.
334
+ let bridge: Bridge;
335
+ let target: Bridge | undefined;
336
+ /** A headless FigCraft file's save, made only once the run has completed. */
337
+ let save: (() => Promise<string>) | undefined;
338
+ /** With FigCraft and a library: the target's bridge, and the library's when the run builds there. */
339
+ let figcraft: { target: FigcraftBridge; library?: FigcraftBridge & { made?: true }; path: string } | undefined;
340
+ try {
341
+ if (args.figcraftFile !== undefined || args.figcraft !== undefined) {
342
+ const opened =
343
+ args.figcraftFile !== undefined
344
+ ? await openFigcraft({ file: resolve(io.cwd, args.figcraftFile) }, io)
345
+ : await openFigcraft(args.figcraft!, io);
346
+ io.stdout(
347
+ args.figcraftFile !== undefined
348
+ ? `Writing into ${args.figcraftFile} headless, with figcraft-cli ${opened.version}.\n`
349
+ : `Writing into "${opened.pin.tab.name}" in FigCraft on port ${args.figcraft}.\n`,
350
+ );
351
+ bridge = opened;
352
+ save = opened.save;
353
+ if (args.library !== undefined) {
354
+ const path = resolve(io.cwd, args.library);
355
+ figcraft = { target: opened, path };
356
+ if (opened.pin.tab.path === path)
357
+ throw new Error(`The target is the library ${args.library} itself: show, or name, the file that uses it.`);
358
+ // `lock` builds nothing, so it needs only the target; a build happens in the library file.
359
+ if (args.entry !== undefined || args.files.length > 0) {
360
+ const pin = await pinPath(opened.control, path);
361
+ const library = Object.assign(await bridgeOn(opened.control, pin, io, false), pin.made ? { made: true as const } : {});
362
+ figcraft.library = library;
363
+ bridge = library;
364
+ io.stdout(
365
+ pin.made
366
+ ? `Building the library ${args.library}, a new file.\n`
367
+ : `Building the library ${args.library}, in "${pin.tab.name}".\n`,
368
+ );
369
+ }
370
+ }
371
+ } else {
372
+ const paired = await pairUp(io, io.port ?? PORT, args.library);
373
+ // With a library, everything is built in the library file, where components are made; the target gets the lock.
374
+ bridge = paired.library ?? paired.target;
375
+ target = paired.target;
376
+ }
377
+ } catch (error) {
378
+ io.stderr(`${(error as Error).message}\n`);
379
+ await figcraft?.target.close();
380
+ return 1;
381
+ }
382
+
383
+ const by = args.entry !== undefined || args.files.length > 0 ? 'build' : 'command line';
384
+ try {
385
+ // FigCraft: each library the files use is enabled in the target, and its entry in the lock read again from the
386
+ // target's listing of it, before anything is resolved. A dry run does neither, and resolves through the lock as it is.
387
+ if (args.uses && (args.figcraft !== undefined || args.figcraftFile !== undefined) && !args.dryRun) {
388
+ const target = bridge as FigcraftBridge;
389
+ for (const use of args.uses) {
390
+ const path = resolve(io.cwd, use);
391
+ const entry = lockedFrom(await enable(target.control, target.pin, path, false, io), path, 'command line');
392
+ const written = await target.request({ type: 'LOCK_WRITE', payload: { library: libraryName(path), entry } }, ['LOCK']);
393
+ if (written.type === 'ERROR') throw new Error(`The lock in "${target.pin.tab.name}" was not written: ${written.payload.message}`);
394
+ io.stdout(`Using ${use}: the lock in "${target.pin.tab.name}" holds "${libraryName(path)}", ${counted(entry)}.\n`);
395
+ }
396
+ }
397
+ let built = await runSteps(args, source, components, inputs, bridge, io);
398
+ // FigCraft: the library is published and saved, then enabled in the target, its updates accepted and the lock
399
+ // written, all before the target is saved.
400
+ if (figcraft && args.library !== undefined) {
401
+ if (built !== 0)
402
+ io.stderr(`The library was not published and the lock in the target was not written: the build in the library did not complete.\n`);
403
+ else built = await finishFigcraftLibrary(args.library, figcraft, by, args.dryRun, io);
404
+ }
405
+ // A headless file is saved whole or not at all: a run that stopped partway, or wrote nothing, leaves it as it was.
406
+ if (save && !args.dryRun) {
407
+ if (built === 0) io.stdout(`Saved ${await save()}.\n`);
408
+ else io.stderr(`${args.figcraftFile} was not saved: the run did not complete.\n`);
409
+ }
410
+ if (figcraft || args.library === undefined || target === undefined) return built;
411
+ if (built !== 0) {
412
+ io.stderr(`The lock in the target file was not written: the build in the library file did not complete.\n`);
413
+ return built;
414
+ }
415
+ return await copyKeys(args.library, bridge, target, by, args.dryRun, io);
416
+ } catch (error) {
417
+ io.stderr(`${(error as Error).message}\n`);
418
+ return 1;
419
+ } finally {
420
+ if (figcraft) {
421
+ if (figcraft.library) {
422
+ await figcraft.library.close();
423
+ // A file the run made and did not save is not left behind as an empty tab.
424
+ if (figcraft.library.made && figcraft.library.pin.tab.path === null)
425
+ await closeTab(figcraft.target.control, figcraft.library.pin).catch(() => {});
426
+ }
427
+ await figcraft.target.close();
428
+ } else await bridge.close();
429
+ }
430
+ }
431
+
432
+ /**
433
+ * The end of a FigCraft library run: the library published and saved, then, in the target, enabled (or pointed at its
434
+ * new path after a move), that library's updates accepted, and the lock written from the target's own listing of it.
435
+ * `lock` does the target's half alone. A dry run does none of it and says what is waiting.
436
+ */
437
+ async function finishFigcraftLibrary(
438
+ given: string,
439
+ figcraft: { target: FigcraftBridge; library?: FigcraftBridge; path: string },
440
+ by: LockedLibrary['by'],
441
+ dryRun: boolean,
442
+ io: Io,
443
+ ): Promise<number> {
444
+ const { target, library, path } = figcraft;
445
+ const control = target.control;
446
+ if (dryRun) {
447
+ await describeTarget(control, target.pin, path, io);
448
+ io.stdout('Dry run: the library was not published or saved, and the lock in the target was not written.\n');
449
+ return 0;
450
+ }
451
+ if (library) {
452
+ // The library's own record goes in first, so the file saved holds it.
453
+ await library.flush();
454
+ await publish(control, library.pin, path, io);
455
+ }
456
+ const listed = await enable(control, target.pin, path, true, io);
457
+ const name = libraryName(path);
458
+ const entry = lockedFrom(listed, path, by);
459
+ const written = await target.request({ type: 'LOCK_WRITE', payload: { library: name, entry } }, ['LOCK']);
460
+ if (written.type === 'ERROR') {
461
+ io.stderr(`The lock in "${target.pin.tab.name}" was not written: ${written.payload.message}\n`);
462
+ return 1;
463
+ }
464
+ io.stdout(`The lock in "${target.pin.tab.name}" holds "${name}" (${given}), written by the ${by}: ${counted(entry)}.\n`);
465
+ return 0;
466
+ }
467
+
468
+ async function runSteps(
469
+ args: RunArguments,
470
+ source: Source | undefined,
471
+ components: ComponentText[],
472
+ inputs: Record<string, unknown> | undefined,
473
+ bridge: Bridge,
474
+ io: RunIo,
475
+ ): Promise<number> {
476
+ /** A response of the expected type, or an error that stops the run with what the plugin said. */
477
+ const ask = async (command: Command, answer: Response['type']): Promise<Response> => {
478
+ const response = await bridge.request(command, [answer]);
479
+ if (response.type === 'ERROR') throw new Error(`${command.type}: ${response.payload.message}`);
480
+ return response;
481
+ };
482
+
483
+ // 3. The design system, when there is one: the plugin answers a file's `(modifier)` axes and `tokens=` groups from it.
484
+ // The files themselves travel, not the bundle: the plugin loads them as the screen's own pick is loaded, so the
485
+ // composition it carries is its own, and the load's collisions and the map's unused keys come back as rows, printed here.
486
+ if (source) {
487
+ const loaded = await ask(
488
+ { type: 'LOAD_DOCUMENT', payload: { ...source.load, ...(args.dryRun ? { dryRun: true } : {}) } },
489
+ 'DOCUMENT_LOADED',
490
+ );
491
+ if (loaded.type === 'DOCUMENT_LOADED') describeRows(loaded.payload.report?.operations ?? [], io);
492
+ }
493
+
494
+ // The plugin reads every file before anything is written: a design half applied is worse than none. A sync also has the
495
+ // names each file writes checked against the design system it is about to build.
496
+ if (components.length > 0) {
497
+ const checked = await bridge.request(
498
+ {
499
+ type: 'CHECK_COMPONENTS',
500
+ payload: {
501
+ files: components,
502
+ ...(args.entry !== undefined ? { names: true } : {}),
503
+ ...(inputs ? { inputs } : {}),
504
+ dryRun: args.dryRun,
505
+ ...(args.allowBreaking ? { allowBreaking: true } : {}),
506
+ },
507
+ },
508
+ ['COMPONENTS_CHECKED'],
509
+ );
510
+ if (checked.type === 'ERROR') {
511
+ for (const problem of checked.payload.problems ?? [checked.payload.message]) io.stderr(`${problem}\n`);
512
+ return 1;
513
+ }
514
+ // Members outside any set are breaking: a dry run lists them, a run with --allow-breaking removes them at its end.
515
+ if (checked.type === 'COMPONENTS_CHECKED') for (const line of checked.payload.strays ?? []) io.stdout(`breaking: ${line}\n`);
516
+ }
517
+
518
+ // The component files go in dependency order, in a real run and a dry run alike (`shared/components/order.ts`):
519
+ // read here, against the design system, only for what each defines and names. A name a declared library holds is not a file's.
520
+ let ordered = components;
521
+ if (components.length > 1) {
522
+ let design: Resolver | null = null;
523
+ try {
524
+ design = source ? new Resolver(source.document) : null;
525
+ } catch {
526
+ // A document the core refuses was the load's to say.
527
+ }
528
+ let fromLibraries = new Set<string>();
529
+ if (args.uses) {
530
+ const lock = await ask({ type: 'GET_LOCK', payload: {} }, 'LOCK');
531
+ if (lock.type === 'LOCK') {
532
+ const declared = args.uses.map((use) => (args.figcraft !== undefined || args.figcraftFile !== undefined ? libraryName(use) : use));
533
+ fromLibraries = new Set(
534
+ declaredLibraries(declared, lock.payload.overrides).flatMap((library) =>
535
+ Object.keys(lock.payload.libraries[library]?.components ?? {}),
536
+ ),
537
+ );
538
+ }
539
+ }
540
+ try {
541
+ ordered = orderFiles(namesOfFiles(components, inputs, design), fromLibraries);
542
+ } catch (error) {
543
+ io.stderr(`${(error as Error).message}; nothing was written\n`);
544
+ return 1;
545
+ }
546
+ }
547
+
548
+ const planned: Operation[] = [];
549
+ if (source && args.entry !== undefined) {
550
+ // 4. The variables.
551
+ // Both plans are given the same collection name: the style plan binds a style's members to the variables the variable plan put there.
552
+ const collection = args.collection !== undefined ? { collectionName: args.collection } : {};
553
+ const steps: { label: string; plan: Command; execute: Command }[] = [
554
+ {
555
+ label: 'Variable',
556
+ plan: {
557
+ type: 'PLAN_VARIABLE_SYNC',
558
+ payload: {
559
+ ...collection,
560
+ ...(args.allowBreaking ? { allowBreaking: true } : {}),
561
+ ...(args.overwriteDrift ? { overwriteDrift: true } : {}),
562
+ },
563
+ },
564
+ execute: { type: 'EXECUTE_VARIABLE_SYNC', payload: {} },
565
+ },
566
+ // 5. The styles, since a component's text names a text style.
567
+ {
568
+ label: 'Style',
569
+ plan: { type: 'PLAN_STYLE_SYNC', payload: { ...collection, ...(args.overwriteDrift ? { overwriteDrift: true } : {}) } },
570
+ execute: { type: 'EXECUTE_STYLE_SYNC', payload: {} },
571
+ },
572
+ ];
573
+ for (const step of steps) {
574
+ const response = await ask(step.plan, 'PLAN_READY');
575
+ if (response.type !== 'PLAN_READY') continue;
576
+ describePlan(step.label, response.payload, io, args.verbose === true);
577
+ planned.push(...response.payload.operations);
578
+ // A plan that stopped (the base collection built by an earlier sync, a modifier with no default) stops the
579
+ // whole run, a dry run included: a style or a component built against variables that were not synced would bind the wrong things.
580
+ const stop = response.payload.operations.find((operation) => operation.kind === 'WARNING' && operation.stops);
581
+ if (stop) {
582
+ io.stderr(`${step.label} sync stopped, so nothing was written and no style or component was run.\n`);
583
+ return 1;
584
+ }
585
+ // Breaking steps are advisory in a dry run and a gate in a real one, as a component's are: nothing is written before someone who read them says so.
586
+ const breaking = response.payload.operations.filter((operation) => operation.kind === 'BREAKING').length;
587
+ if (breaking > 0 && !args.dryRun && !args.allowBreaking) {
588
+ io.stderr(
589
+ `${step.label} sync will not run: ${plural(breaking, 'step')} would lose something in Figma, and nothing was written; pass --allow-breaking to take ${breaking === 1 ? 'it' : 'them'}\n`,
590
+ );
591
+ return 1;
592
+ }
593
+ if (args.dryRun) continue;
594
+ const done = await ask(step.execute, 'OPERATION_COMPLETE');
595
+ if (done.type !== 'OPERATION_COMPLETE') continue;
596
+ const { applied, skipped, failed, cancelled } = done.payload;
597
+ io.stdout(
598
+ `${step.label} sync: ${applied} applied · ${skipped} skipped${failed ? ` · ${failed} failed` : ''}${cancelled ? ` · ${cancelled} cancelled` : ''}\n`,
599
+ );
600
+ for (const result of done.payload.results) {
601
+ if (result.status === 'skipped' && result.operation.kind === 'SKIP' && result.operation.reported)
602
+ io.stdout(` skipped: ${result.reason}\n`);
603
+ }
604
+ if (failed > 0) {
605
+ for (const result of done.payload.results)
606
+ if (result.status === 'failed') io.stderr(` failed: ${result.reason ?? 'no reason given'}\n`);
607
+ io.stderr(`${step.label} sync did not complete, so no component was run.\n`);
608
+ return 1;
609
+ }
610
+ }
611
+ }
612
+
613
+ // A dry run writes nothing, so what the plans would have made is told to each component run.
614
+ let pending: Pending | undefined;
615
+ if (args.dryRun && args.entry !== undefined) {
616
+ const collections = await ask({ type: 'GET_FIGMA_COLLECTIONS', payload: {} }, 'FIGMA_COLLECTIONS');
617
+ if (collections.type === 'FIGMA_COLLECTIONS') pending = pendingNames(planned, collections.payload);
618
+ }
619
+
620
+ // 6. Each component file, one at a time. A dry run tells each the sets the files before it would create (ruling 7a).
621
+ let exit = 0;
622
+ const created: string[] = [];
623
+ for (const { fileName, text } of ordered) {
624
+ if (created.length > 0) pending = { ...(pending ?? { variables: [], textStyles: [] }), components: [...created] };
625
+ const options: RunOptions = {
626
+ ...(args.page !== undefined ? { page: args.page } : {}),
627
+ ...(args.uses
628
+ ? { libraries: args.uses.map((use) => (args.figcraft !== undefined || args.figcraftFile !== undefined ? libraryName(use) : use)) }
629
+ : {}),
630
+ dryRun: args.dryRun,
631
+ ...(args.allowBreaking ? { allowBreaking: true } : {}),
632
+ ...(args.overwriteDrift ? { overwriteDrift: true } : {}),
633
+ ...(pending ? { pending } : {}),
634
+ };
635
+ const response = await bridge.request(
636
+ { type: 'RUN_COMPONENT', payload: { text, fileName, options, ...(inputs && isArtboard(fileName) ? { inputs } : {}) } },
637
+ ['COMPONENT_RUN'],
638
+ );
639
+ if (response.type === 'ERROR') {
640
+ io.stderr(`${fileName}: was not ${args.dryRun ? 'planned' : 'built'}: ${response.payload.message.split('\n')[0]}\n`);
641
+ for (const problem of response.payload.problems ?? []) io.stderr(` - ${problem}\n`);
642
+ if (!response.payload.problems) io.stderr(` ${response.payload.message}\n`);
643
+ exit = 1;
644
+ } else if (response.type === 'COMPONENT_RUN') {
645
+ const { component: name, report } = response.payload;
646
+ if (report.dryRun && report.steps.some((step) => step.kind === 'CREATE_SET')) created.push(name);
647
+ // A dry run of a set applies nothing, so what it would update is counted from its steps: the members a write step names.
648
+ const updated =
649
+ report.dryRun && !report.artboard
650
+ ? `${new Set(report.steps.filter((step) => MEMBER_WRITES.has(step.kind)).map((step) => step.member)).size} would update`
651
+ : `${report.updated.length} updated`;
652
+ io.stdout(
653
+ `${name}${report.dryRun ? ' (dry run)' : ''}: ${plural(report.steps.length, 'step')} · ${report.created.length} created · ${updated} · ${report.deleted.length} deleted${report.breaking.length ? ` · ${report.breaking.length} breaking` : ''}\n`,
654
+ );
655
+ // An artboard's plan is its layers: what is made, patched, moved or removed, one line each.
656
+ if (report.artboard) {
657
+ const verbs: Partial<Record<string, string>> = {
658
+ CREATE_ARTBOARD: 'create the artboard and every layer in it',
659
+ CREATE_LAYER: 'create',
660
+ REPLACE_LAYER: 'replace',
661
+ MOVE_LAYER: 'move',
662
+ PATCH_LAYER: 'patch',
663
+ DELETE_LAYER: 'remove',
664
+ SWAP_COMPONENT: 'swap',
665
+ };
666
+ for (const step of report.steps)
667
+ if (verbs[step.kind]) io.stdout(` ${verbs[step.kind]}${step.address !== undefined ? ` ${step.address}` : ''}\n`);
668
+ } else if (args.verbose) {
669
+ // A set's patches, one line each, with the fields each writes where the reply names them.
670
+ for (const step of report.steps) {
671
+ if (step.kind === 'PATCH_LAYER')
672
+ io.stdout(` patch ${step.member} ${step.address}${step.changes?.length ? `: ${step.changes.join(', ')}` : ''}\n`);
673
+ }
674
+ }
675
+ for (const step of report.steps)
676
+ if ((step.kind === 'RENAME_AXIS' || step.kind === 'DELETE_LAYER') && step.message) io.stdout(` ${step.message}\n`);
677
+ for (const [what, lines] of [
678
+ ['pending', report.pending],
679
+ ['breaking', report.breaking],
680
+ ['warning', report.warnings],
681
+ ['left', report.notes],
682
+ ] as const) {
683
+ for (const line of lines) io.stdout(` ${what}: ${line}\n`);
684
+ }
685
+ }
686
+ }
687
+ // The end of the run, once: the strays removed when allowed, and each page's sets and artboards stacked again.
688
+ if (components.length > 0) {
689
+ const finished = await bridge.request(
690
+ { type: 'FINISH_COMPONENTS', payload: { dryRun: args.dryRun, ...(args.allowBreaking ? { allowBreaking: true } : {}) } },
691
+ ['COMPONENTS_FINISHED'],
692
+ );
693
+ if (finished.type === 'ERROR') {
694
+ io.stderr(`${finished.payload.message}\n`);
695
+ exit = 1;
696
+ } else if (finished.type === 'COMPONENTS_FINISHED') {
697
+ for (const line of [...finished.payload.removed, ...finished.payload.moved]) io.stdout(`${line}\n`);
698
+ for (const line of finished.payload.notes) io.stdout(` note: ${line}\n`);
699
+ }
700
+ }
701
+ // 7. Exit. The plugin stays open, and finds the next run by itself.
702
+ return exit;
703
+ }
704
+
705
+ /**
706
+ * The lock from the command line: asks the plugin in the library file for its own keys, and has the plugin in the
707
+ * target file write them as the library's entry, whole. Every set or style not published as it is now is one line: its
708
+ * key is in the lock, and it imports once a person publishes the library and accepts its update. A dry run writes nothing.
709
+ */
710
+ async function copyKeys(library: string, from: Bridge, to: Bridge, by: LockedLibrary['by'], dryRun: boolean, io: Io): Promise<number> {
711
+ const read = await from.request({ type: 'LIBRARY_KEYS', payload: {} }, ['LIBRARY_KEYS_READ']);
712
+ if (read.type === 'ERROR') throw new Error(`LIBRARY_KEYS: ${read.payload.message}`);
713
+ if (read.type !== 'LIBRARY_KEYS_READ') return 1;
714
+ const keys = read.payload;
715
+ const entry: LockedLibrary = {
716
+ ...(keys.fileKey !== undefined ? { fileKey: keys.fileKey } : {}),
717
+ by,
718
+ components: Object.fromEntries(Object.entries(keys.components).map(([name, { key, set }]) => [name, { key, set }])),
719
+ styles: Object.fromEntries(Object.entries(keys.styles).map(([path, { key, kind }]) => [path, { key, kind }])),
720
+ variables: Object.fromEntries(Object.entries(keys.variables).map(([id, { key }]) => [id, key])),
721
+ };
722
+ const counts = [
723
+ plural(Object.keys(entry.components).length, 'component'),
724
+ plural(Object.keys(entry.styles).length, 'style'),
725
+ plural(Object.keys(entry.variables).length, 'variable'),
726
+ ];
727
+ io.stdout(
728
+ `Library "${library}": ${counts.join(' · ')} · ${entry.fileKey !== undefined ? `file key ${entry.fileKey}` : 'no file key (only a private or development plugin reads it)'}\n`,
729
+ );
730
+
731
+ const then = 'publish the library, then accept its update in the target file.';
732
+ for (const [name, { status }] of Object.entries(keys.components))
733
+ if (status !== 'CURRENT') io.stdout(`"${name}" is not published as it is now (${status}): ${then}\n`);
734
+ for (const [path, { kind, status }] of Object.entries(keys.styles)) {
735
+ if (status !== undefined && status !== 'CURRENT')
736
+ io.stdout(`The ${kind.toLowerCase()} style "${path.split('.').join('/')}" is not published as it is now (${status}): ${then}\n`);
737
+ }
738
+ for (const line of keys.warnings ?? []) io.stdout(`warning: ${line}\n`);
739
+ const variables = Object.values(keys.variables).filter(({ status }) => status !== 'CURRENT').length;
740
+ if (variables > 0)
741
+ io.stdout(
742
+ `${plural(variables, 'variable')} ${variables === 1 ? 'is not published as it is' : 'are not published as they are'} now: ${then}\n`,
743
+ );
744
+
745
+ if (dryRun) {
746
+ io.stdout('Dry run: the lock in the target file was not written.\n');
747
+ return 0;
748
+ }
749
+ const written = await to.request({ type: 'LOCK_WRITE', payload: { library, entry } }, ['LOCK']);
750
+ if (written.type === 'ERROR') {
751
+ io.stderr(`The lock in the target file was not written: ${written.payload.message}\n`);
752
+ return 1;
753
+ }
754
+ io.stdout(`The lock in the target file holds "${library}", written by the ${by}.\n`);
755
+ return 0;
756
+ }