@penpair/marwes-ui 0.2.0 → 0.3.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/dist/commands.js CHANGED
@@ -5,11 +5,11 @@ import { ownershipReport, readSyncAncestor, saveSyncAncestor } from "./ownership
5
5
  import { mkdir } from "node:fs/promises";
6
6
  import { createRequire } from "node:module";
7
7
  import path from "node:path";
8
- import { parseCommandArgs, readDesignJson, atomicWriteFile, resolveProjectOutput, readJournalTarget, guardFile, resolveProjectInput, createCheckpoint, loadConfig, readRawConfig } from "@penpair/core";
8
+ import { projectTheme, parseCommandArgs, readDesignJson, atomicWriteFile, resolveProjectOutput, readJournalTarget, guardFile, resolveProjectInput, createCheckpoint, loadConfig, readRawConfig, } from "@penpair/core";
9
9
  import { readMarwesSettings } from "./config.js";
10
10
  import { familyProblems, loadInstalledMarwes } from "./marwes.js";
11
- import { applyPlan, compareToPenpot, describePlan, formatDrift, impactOf, planPush, readLive } from "./penpot.js";
12
- import { diffSnapshots, formatDiff, tokenSnapshotSchema, migrateTokenSnapshot, readSnapshot, writeSnapshot } from "./snapshot-file.js";
11
+ import { applyPlan, compareToPenpot, describePlan, formatDrift, impactOf, planPush, readLive, } from "./penpot.js";
12
+ import { diffSnapshots, formatDiff, tokenSnapshotSchema, migrateTokenSnapshot, readSnapshot, writeSnapshot, } from "./snapshot-file.js";
13
13
  import { loadThemeInput } from "./theme-input.js";
14
14
  import { buildSnapshot } from "./tokens.js";
15
15
  const print = (context, ...values) => (context.output?.log ?? console.log)(...values);
@@ -18,25 +18,87 @@ const adapterVersion = createRequire(import.meta.url)("../package.json").version
18
18
  export async function computeExpectation(root, rawConfig, instance) {
19
19
  const settings = readMarwesSettings(rawConfig, instance);
20
20
  const installed = await loadInstalledMarwes(await resolveProjectInput(root, settings.project));
21
- const [light, dark] = await Promise.all([settings.themes.light, settings.themes.dark].map((reference) => (reference ? loadThemeInput(root, reference) : undefined)));
21
+ const [light, dark] = await Promise.all([
22
+ settings.themes.light,
23
+ settings.themes.dark,
24
+ ].map((reference) => (reference ? loadThemeInput(root, reference) : undefined)));
22
25
  const fresh = buildSnapshot({
23
26
  core: installed.core,
24
27
  version: installed.version,
25
28
  name: settings.name,
26
29
  extras: settings.extras,
27
- project: { ...(light ? { light: light.input } : {}), ...(dark ? { dark: dark.input } : {}) },
30
+ project: {
31
+ ...(light
32
+ ? {
33
+ light: light.input,
34
+ }
35
+ : {}),
36
+ ...(dark
37
+ ? {
38
+ dark: dark.input,
39
+ }
40
+ : {}),
41
+ },
28
42
  generatedBy: `@penpair/marwes-ui@${adapterVersion}`,
29
43
  });
30
- return { settings, installed, fresh, problems: familyProblems(installed) };
44
+ return {
45
+ settings,
46
+ installed,
47
+ fresh,
48
+ problems: familyProblems(installed),
49
+ };
31
50
  }
32
51
  export async function checkSnapshotFor(root, rawConfig, instance) {
33
52
  const expectation = await computeExpectation(root, rawConfig, instance);
34
53
  const committed = await readSnapshot(root, expectation.settings.snapshot);
35
54
  const diff = diffSnapshots(committed, expectation.fresh);
36
- const problems = [...expectation.problems];
37
- if (!committed)
38
- problems.push(`there is no snapshot yet at ${expectation.settings.snapshot}; run \`penpair marwes tokens snapshot --write\` and commit it`);
39
- return { current: !!committed && diff.identical && committed.hash === expectation.fresh.hash && expectation.problems.length === 0, committed, diff, problems, expectation };
55
+ const reasons = expectation.problems.map((message) => ({
56
+ code: "package-mismatch",
57
+ message,
58
+ }));
59
+ const migration = `Preview migration with \`penpair marwes tokens migrate --from ${expectation.settings.snapshot} --ownership ownership.json\`; explicitly classify every set as adapter or project, then use --out NEW_FILE to preserve the original.`;
60
+ if (!committed) {
61
+ reasons.push({
62
+ code: "missing-snapshot",
63
+ message: `there is no snapshot yet at ${expectation.settings.snapshot}; run \`penpair marwes tokens snapshot --write\` and commit it`,
64
+ });
65
+ }
66
+ else {
67
+ if (committed.schemaVersion !== expectation.fresh.schemaVersion) {
68
+ reasons.push({
69
+ code: "schema-migration",
70
+ message: `Snapshot schema ${committed.schemaVersion} needs migration to ${expectation.fresh.schemaVersion}. ${migration}`,
71
+ });
72
+ }
73
+ if (!committed.ownership) {
74
+ reasons.push({
75
+ code: "missing-ownership",
76
+ message: `Legacy snapshot has no set ownership. ${migration}`,
77
+ });
78
+ }
79
+ if (!diff.identical) {
80
+ reasons.push({
81
+ code: "content-drift",
82
+ message: "Snapshot values, sets, themes or installed package version changed; review the value diff before regenerating.",
83
+ });
84
+ }
85
+ if (committed.hash !== expectation.fresh.hash) {
86
+ reasons.push({
87
+ code: "metadata-mismatch",
88
+ message: diff.identical
89
+ ? "Token values are identical, but snapshot metadata/hash differs; review schema, ownership and provenance before replacing it."
90
+ : "Snapshot hash differs from the current expectation.",
91
+ });
92
+ }
93
+ }
94
+ return {
95
+ current: reasons.length === 0,
96
+ committed,
97
+ diff,
98
+ reasons,
99
+ problems: reasons.map((reason) => reason.message),
100
+ expectation,
101
+ };
40
102
  }
41
103
  /**
42
104
  * For a project's own test suite: resolves the project's penpair.config.json and checks that the committed Marwes token snapshot
@@ -48,59 +110,145 @@ export async function checkTokenSnapshot(root) {
48
110
  }
49
111
  const summary = (snapshot) => `Marwes ${snapshot.marwesVersion}, ${snapshot.sets.length} sets, ${snapshot.sets.reduce((n, s) => n + s.tokens.length, 0)} tokens, ${snapshot.hash.slice(0, 10)}`;
50
112
  /** What the snapshot says it does not cover, so a reader is never left thinking everything Marwes provides is in Penpot. */
51
- const coverage = (snapshot) => ((snapshot.unmapped ?? []).length > 0 ? `${snapshot.unmapped.length} Marwes variable(s) are not tokens: ${snapshot.unmapped.slice(0, 6).join(", ")}${snapshot.unmapped.length > 6 ? ", ..." : ""}` : undefined);
52
- const flags = (args) => parseCommandArgs({ args, allowPositionals: true, options: { from: { type: "string" }, ownership: { type: "string" }, resolved: { type: "boolean" }, page: { type: "string" }, out: { type: "string" }, overwrite: { type: "boolean" }, adopt: { type: "string" }, write: { type: "boolean" }, apply: { type: "boolean" }, prune: { type: "boolean" }, json: { type: "boolean" }, resume: { type: "string" }, checkpoint: { type: "boolean" }, "force-lock": { type: "boolean" } } });
113
+ const coverage = (snapshot) => (snapshot.unmapped ?? []).length > 0
114
+ ? `${snapshot.unmapped.length} Marwes variable(s) are not tokens: ${snapshot.unmapped.slice(0, 6).join(", ")}${snapshot.unmapped.length > 6 ? ", ..." : ""}`
115
+ : undefined;
116
+ const flags = (args) => parseCommandArgs({
117
+ args,
118
+ allowPositionals: true,
119
+ options: {
120
+ from: {
121
+ type: "string",
122
+ },
123
+ ownership: {
124
+ type: "string",
125
+ },
126
+ theme: {
127
+ type: "string",
128
+ },
129
+ resolved: {
130
+ type: "boolean",
131
+ },
132
+ page: {
133
+ type: "string",
134
+ },
135
+ out: {
136
+ type: "string",
137
+ },
138
+ overwrite: {
139
+ type: "boolean",
140
+ },
141
+ adopt: {
142
+ type: "string",
143
+ },
144
+ write: {
145
+ type: "boolean",
146
+ },
147
+ apply: {
148
+ type: "boolean",
149
+ },
150
+ prune: {
151
+ type: "boolean",
152
+ },
153
+ json: {
154
+ type: "boolean",
155
+ },
156
+ resume: {
157
+ type: "string",
158
+ },
159
+ checkpoint: {
160
+ type: "boolean",
161
+ },
162
+ "force-lock": {
163
+ type: "boolean",
164
+ },
165
+ trace: {
166
+ type: "boolean",
167
+ },
168
+ },
169
+ });
53
170
  const staleAdvice = "Run `penpair marwes tokens snapshot --write`, review the change in git, and commit it.";
54
171
  async function snapshotCommand(context, args) {
55
172
  const { values } = flags(args);
56
173
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
57
174
  const fresh = check.expectation.fresh;
58
- if (values.json)
59
- print(context, JSON.stringify({ current: check.current, summary: summary(fresh), diff: check.diff, problems: check.problems }, null, 2));
175
+ if (values.json) {
176
+ print(context, JSON.stringify({
177
+ current: check.current,
178
+ summary: summary(fresh),
179
+ diff: check.diff,
180
+ reasons: check.reasons,
181
+ problems: check.problems,
182
+ }, null, 2));
183
+ }
60
184
  else {
61
185
  print(context, `Expected from the installed ${summary(fresh)}`);
62
- for (const line of formatDiff(check.diff))
186
+ for (const line of formatDiff(check.diff)) {
63
187
  print(context, ` ${line}`);
64
- if (coverage(fresh))
188
+ }
189
+ if (coverage(fresh)) {
65
190
  print(context, `note ${coverage(fresh)}`);
66
- for (const problem of check.problems.filter((p) => !p.startsWith("there is no snapshot")))
191
+ }
192
+ for (const problem of check.problems.filter((p) => !p.startsWith("there is no snapshot"))) {
67
193
  print(context, `FAIL ${problem}`);
194
+ }
68
195
  }
69
- if (check.expectation.problems.length > 0)
196
+ if (check.expectation.problems.length > 0) {
70
197
  return 1;
198
+ }
71
199
  if (check.current) {
72
- if (!values.json)
200
+ if (!values.json) {
73
201
  print(context, `ok ${check.expectation.settings.snapshot} is current`);
202
+ }
74
203
  return 0;
75
204
  }
76
205
  if (!values.write) {
77
- if (!values.json)
206
+ if (!values.json) {
78
207
  print(context, `${check.committed ? "The committed snapshot is stale." : "There is no snapshot yet."} Run again with --write to save ${check.expectation.settings.snapshot}.`);
208
+ }
79
209
  return 2;
80
210
  }
81
211
  await writeSnapshot(context.root, check.expectation.settings.snapshot, fresh);
82
- context.output?.result({ current: true, snapshot: check.expectation.settings.snapshot, hash: fresh.hash, written: true });
83
- if (!values.json)
212
+ context.output?.result({
213
+ current: true,
214
+ snapshot: check.expectation.settings.snapshot,
215
+ hash: fresh.hash,
216
+ written: true,
217
+ });
218
+ if (!values.json) {
84
219
  print(context, `wrote ${check.expectation.settings.snapshot}. Review the change in git and commit it; then \`penpair marwes tokens diff\` shows what Penpot needs.`);
220
+ }
85
221
  return 0;
86
222
  }
87
223
  async function checkCommand(context, args) {
88
224
  const { values } = flags(args);
89
225
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
90
226
  if (values.json) {
91
- print(context, JSON.stringify({ current: check.current, installed: check.expectation.installed.versions, diff: check.diff, problems: check.problems }, null, 2));
227
+ print(context, JSON.stringify({
228
+ current: check.current,
229
+ installed: check.expectation.installed.versions,
230
+ diff: check.diff,
231
+ reasons: check.reasons,
232
+ problems: check.problems,
233
+ }, null, 2));
92
234
  return check.current ? 0 : check.expectation.problems.length > 0 ? 1 : 2;
93
235
  }
94
236
  if (check.current) {
95
237
  print(context, `ok ${check.expectation.settings.snapshot} is current (${summary(check.expectation.fresh)})`);
96
238
  return 0;
97
239
  }
98
- for (const line of formatDiff(check.diff))
240
+ for (const line of formatDiff(check.diff)) {
99
241
  print(context, ` ${line}`);
100
- for (const problem of check.problems)
242
+ }
243
+ for (const problem of check.problems) {
101
244
  print(context, `FAIL ${problem}`);
102
- if (check.committed && check.expectation.problems.length === 0) {
103
- const cause = check.committed.marwesVersion !== check.expectation.installed.version ? `it was made from Marwes ${check.committed.marwesVersion} and the installed Marwes is ${check.expectation.installed.version}` : "the installed Marwes or the project's theme has changed since it was made";
245
+ }
246
+ if (check.committed &&
247
+ check.expectation.problems.length === 0 &&
248
+ !check.reasons.some((reason) => reason.code === "schema-migration")) {
249
+ const cause = check.committed.marwesVersion !== check.expectation.installed.version
250
+ ? `it was made from Marwes ${check.committed.marwesVersion} and the installed Marwes is ${check.expectation.installed.version}`
251
+ : "the installed Marwes or the project's theme has changed since it was made";
104
252
  print(context, `FAIL the committed snapshot is stale: ${cause}. ${staleAdvice}`);
105
253
  }
106
254
  return check.expectation.problems.length > 0 ? 1 : 2;
@@ -108,20 +256,35 @@ async function checkCommand(context, args) {
108
256
  async function readPenpot(context, committed, wantImpact) {
109
257
  return context.withPenpot(async (client) => {
110
258
  const target = await readJournalTarget(client);
111
- const pinned = guardFile(client, { id: target.fileId });
259
+ const pinned = guardFile(client, {
260
+ id: target.fileId,
261
+ });
112
262
  const live = await readLive(pinned);
113
263
  const after = await readJournalTarget(pinned);
114
- if (after.fileId !== target.fileId || after.revision !== target.revision)
264
+ if (after.fileId !== target.fileId || after.revision !== target.revision) {
115
265
  throw new Error("File changed during token inspection; inspect again");
266
+ }
116
267
  const ancestor = await readSyncAncestor(context.root, target.fileId);
117
- const drift = compareToPenpot(committed, live, { ancestor });
118
- return { target, live, drift, ...(wantImpact && !drift.inSync ? { impact: await impactOf(pinned).catch(() => undefined) } : {}) };
268
+ const drift = compareToPenpot(committed, live, {
269
+ ancestor,
270
+ });
271
+ return {
272
+ target,
273
+ live,
274
+ drift,
275
+ ...(wantImpact && !drift.inSync
276
+ ? {
277
+ impact: await impactOf(pinned).catch(() => undefined),
278
+ }
279
+ : {}),
280
+ };
119
281
  });
120
282
  }
121
283
  async function diffCommand(context, args) {
122
284
  const { values } = flags(args);
123
- if (values.resolved)
285
+ if (values.resolved) {
124
286
  return modeCommand(context, args);
287
+ }
125
288
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
126
289
  if (!check.committed) {
127
290
  diagnostic(context, `FAIL ${check.problems.join("; ")}`);
@@ -129,41 +292,76 @@ async function diffCommand(context, args) {
129
292
  }
130
293
  const { drift, impact } = await readPenpot(context, check.committed, true);
131
294
  if (values.json) {
132
- print(context, JSON.stringify({ snapshotCurrent: check.current, drift, impact }, null, 2));
295
+ print(context, JSON.stringify({
296
+ snapshotCurrent: check.current,
297
+ drift,
298
+ impact,
299
+ }, null, 2));
133
300
  return drift.inSync ? 0 : 2;
134
301
  }
135
- if (!check.current)
302
+ if (!check.current) {
136
303
  print(context, `note: the committed snapshot is stale against the installed Marwes (${staleAdvice}); the comparison below is against the committed one.`);
304
+ }
137
305
  if (drift.inSync) {
138
- for (const line of formatDrift(drift, impact))
306
+ for (const line of formatDrift(drift, impact)) {
139
307
  print(context, `note preserved: ${line}`);
308
+ }
140
309
  print(context, `ok Penpot matches ${summary(check.committed)}`);
141
310
  return 0;
142
311
  }
143
- print(context, values.resume ? `Resuming run ${values.resume}; current snapshot: ${summary(check.committed)}` : `Penpot differs from ${summary(check.committed)}:`);
144
- for (const line of formatDrift(drift, impact))
312
+ print(context, values.resume
313
+ ? `Resuming run ${values.resume}; current snapshot: ${summary(check.committed)}`
314
+ : `Penpot differs from ${summary(check.committed)}:`);
315
+ for (const line of formatDrift(drift, impact)) {
145
316
  print(context, ` ${line}`);
317
+ }
146
318
  print(context, "`penpair marwes tokens push --apply` brings Penpot to the snapshot (designer edits require explicit adoption or overwrite).");
147
319
  return 2;
148
320
  }
149
321
  async function modeCommand(context, args, frameId) {
150
322
  const { values } = flags(args);
151
- if ((values.resume || values.checkpoint || values["force-lock"]) && !values.apply)
323
+ if ((values.resume || values.checkpoint || values["force-lock"] || values.trace) &&
324
+ !values.apply) {
152
325
  throw new Error("Mode resume/checkpoint/force-lock require --apply");
153
- if (frameId && (!values.page || !values.out))
326
+ }
327
+ if (frameId && (!values.page || !values.out)) {
154
328
  throw new Error("verify-modes requires one frame ID, --page and --out");
155
- if (values.out)
329
+ }
330
+ if (values.out) {
156
331
  await resolveProjectOutput(context.root, values.out);
332
+ }
157
333
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
158
334
  if (!check.committed || !check.current) {
159
335
  diagnostic(context, "Mode verification requires a current committed snapshot. " + staleAdvice);
160
336
  return 2;
161
337
  }
162
- const report = await context.withPenpot(client => runModeTrial(client, check.committed, {
163
- root: context.root, apply: values.apply === true, resume: values.resume, forceLock: values["force-lock"] === true, redact: context.redact, onStep: context.log,
338
+ const report = await context.withPenpot((client) => runModeTrial(client, check.committed, {
339
+ root: context.root,
340
+ apply: values.apply === true,
341
+ resume: values.resume,
342
+ forceLock: values["force-lock"] === true,
343
+ redact: context.redact,
344
+ onStep: context.log,
164
345
  requireCheckpoint: values.checkpoint === true,
165
- ...(values.checkpoint ? { checkpoint: runId => createCheckpoint(client, { label: "mode trial", runId }, { apply: true }) } : {}),
166
- ...(frameId ? { frame: { id: frameId, page: values.page, out: values.out } } : {}),
346
+ ...(values.checkpoint
347
+ ? {
348
+ checkpoint: (runId) => createCheckpoint(client, {
349
+ label: "mode trial",
350
+ runId,
351
+ }, {
352
+ apply: true,
353
+ }),
354
+ }
355
+ : {}),
356
+ ...(frameId
357
+ ? {
358
+ frame: {
359
+ id: frameId,
360
+ page: values.page,
361
+ out: values.out,
362
+ },
363
+ }
364
+ : {}),
167
365
  }));
168
366
  context.output?.result(report);
169
367
  print(context, JSON.stringify(report, null, 2));
@@ -171,89 +369,167 @@ async function modeCommand(context, args, frameId) {
171
369
  diagnostic(context, "Mode checks temporarily change active selection. Inspect planned states, then use --apply; the full original selection will be restored and verified separately.");
172
370
  return 0;
173
371
  }
174
- return report.uncertain ? 4 : !report.restoration.verified ? 1 : !report.complete ? 3 : report.verified ? 0 : 2;
372
+ return report.uncertain
373
+ ? 4
374
+ : !report.restoration.verified
375
+ ? 1
376
+ : !report.complete
377
+ ? 3
378
+ : report.verified
379
+ ? 0
380
+ : 2;
175
381
  }
176
382
  async function pushCommand(context, args) {
177
383
  const { values } = flags(args);
178
- if ((values.resume || values.checkpoint || values["force-lock"]) && !values.apply)
384
+ if ((values.resume || values.checkpoint || values["force-lock"] || values.trace) &&
385
+ !values.apply) {
179
386
  throw new Error("Resume/checkpoint require --apply; inspect the journal and current design first");
387
+ }
180
388
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
181
389
  if (!check.committed) {
182
390
  diagnostic(context, `FAIL ${check.problems.join("; ")}`);
183
391
  return 1;
184
392
  }
185
393
  if (!check.current) {
186
- for (const line of formatDiff(check.diff))
394
+ for (const line of formatDiff(check.diff)) {
187
395
  print(context, ` ${line}`);
396
+ }
188
397
  diagnostic(context, `STOP the committed snapshot is stale against the installed Marwes or the theme, and the repository's record must lead. ${staleAdvice} Then push.`);
189
398
  return 2;
190
399
  }
191
400
  const { drift, impact, live, target } = await readPenpot(context, check.committed, true);
192
- if (values.adopt && (values.apply || values.overwrite || values.resume))
401
+ if (values.adopt && (values.apply || values.overwrite || values.resume)) {
193
402
  throw new Error("--adopt writes a local review proposal and cannot be combined with apply/overwrite/resume");
403
+ }
194
404
  const ancestor = await readSyncAncestor(context.root, target.fileId);
195
405
  const ownership = ownershipReport(check.committed, live, ancestor);
196
406
  if (values.adopt) {
197
- const proposal = JSON.stringify({ schemaVersion: 1, kind: "token-adoption-proposal", fileId: target.fileId, snapshotHash: check.committed.hash, ownership, live, instructions: "Review live values and declare intentional changes in project themes/extras; regenerate the snapshot. This proposal changes no design or ownership ancestor." }, null, 2) + "\n";
198
- if (context.redact && context.redact(proposal) !== proposal)
407
+ const proposal = JSON.stringify({
408
+ schemaVersion: 1,
409
+ kind: "token-adoption-proposal",
410
+ fileId: target.fileId,
411
+ snapshotHash: check.committed.hash,
412
+ ownership,
413
+ live,
414
+ instructions: "Review live values and declare intentional changes in project themes/extras; regenerate the snapshot. This proposal changes no design or ownership ancestor.",
415
+ }, null, 2) + "\n";
416
+ if (context.redact && context.redact(proposal) !== proposal) {
199
417
  throw new Error("Adoption evidence requires credential redaction; no proposal was published");
418
+ }
200
419
  const out = await resolveProjectOutput(context.root, values.adopt);
201
- await mkdir(path.dirname(out), { recursive: true });
420
+ await mkdir(path.dirname(out), {
421
+ recursive: true,
422
+ });
202
423
  await atomicWriteFile(out, proposal);
203
- context.output?.result({ phase: "adoption-proposed", file: values.adopt, ownership, complete: false });
424
+ context.output?.result({
425
+ phase: "adoption-proposed",
426
+ file: values.adopt,
427
+ ownership,
428
+ complete: false,
429
+ });
204
430
  print(context, "Saved adoption proposal. Review declarations and regenerate the snapshot before pushing; no Penpot write or ancestor update occurred.");
205
431
  return 3;
206
432
  }
207
- const plan = planPush(check.committed, live, { prune: values.prune === true, ancestor });
433
+ const plan = planPush(check.committed, live, {
434
+ prune: values.prune === true,
435
+ ancestor,
436
+ });
208
437
  if (ownership.blockers.length && !values.overwrite && !values.resume) {
209
- context.output?.result({ phase: "conflict", ownership, plan, verified: false });
438
+ context.output?.result({
439
+ phase: "conflict",
440
+ ownership,
441
+ plan,
442
+ verified: false,
443
+ });
210
444
  diagnostic(context, `STOP ${ownership.blockers.length} designer/conflicting/untracked token properties need a decision. Use --adopt <new-file.json> for a local review proposal, or inspect --overwrite and explicitly apply it.`);
211
445
  return 2;
212
446
  }
213
447
  if (drift.inSync && plan.length === 0 && !values.resume) {
214
448
  const evidence = JSON.stringify(check.committed);
215
- if (context.redact && context.redact(evidence) !== evidence)
449
+ if (context.redact && context.redact(evidence) !== evidence) {
216
450
  throw new Error("Sync evidence requires credential redaction");
217
- if (values.apply)
451
+ }
452
+ if (values.apply) {
218
453
  await saveSyncAncestor(context.root, target.fileId, check.committed, ancestor);
219
- context.output?.result({ verified: true, unchanged: true, drift, ownership });
454
+ }
455
+ context.output?.result({
456
+ verified: true,
457
+ unchanged: true,
458
+ drift,
459
+ ownership,
460
+ });
220
461
  print(context, `ok Penpot already matches ${summary(check.committed)}; nothing to write`);
221
462
  return 0;
222
463
  }
223
- print(context, values.resume ? `Resuming run ${values.resume}; current snapshot: ${summary(check.committed)}` : `Penpot differs from ${summary(check.committed)}:`);
224
- for (const line of formatDrift(drift, impact))
464
+ print(context, values.resume
465
+ ? `Resuming run ${values.resume}; current snapshot: ${summary(check.committed)}`
466
+ : `Penpot differs from ${summary(check.committed)}:`);
467
+ for (const line of formatDrift(drift, impact)) {
225
468
  print(context, ` ${line}`);
469
+ }
226
470
  print(context, "Plan:");
227
- for (const line of describePlan(plan))
471
+ for (const line of describePlan(plan)) {
228
472
  print(context, ` ${line}`);
473
+ }
229
474
  if (!values.apply) {
230
- context.output?.result({ phase: "inspected", ownership, plan, drift, snapshotCurrent: check.current, verified: false });
475
+ context.output?.result({
476
+ phase: "inspected",
477
+ ownership,
478
+ plan,
479
+ drift,
480
+ snapshotCurrent: check.current,
481
+ verified: false,
482
+ });
231
483
  print(context, "Nothing was written. Run again with --apply to do it.");
232
- if (!values.prune && drift.sets.some((s) => s.tokens.some((t) => t.kind === "extra")))
484
+ if (!values.prune && drift.sets.some((s) => s.tokens.some((t) => t.kind === "extra"))) {
233
485
  print(context, "Tokens without a verified ownership ancestor are preserved, including with --prune.");
486
+ }
234
487
  return 0;
235
488
  }
236
489
  const report = await context.withPenpot((client) => applyPlan(client, check.committed, plan, (message) => context.log(message), {
237
- prune: values.prune === true, ancestor, expectedFileId: target.fileId, expectedLive: live,
238
- journal: { root: context.root, forceLock: values["force-lock"] === true, runId: values.resume, resume: Boolean(values.resume), redact: context.redact, requireCheckpoint: values.checkpoint === true,
239
- ...(values.checkpoint ? { checkpoint: (runId) => createCheckpoint(client, { label: "token push", runId }, { apply: true }) } : {}),
490
+ prune: values.prune === true,
491
+ ancestor,
492
+ expectedFileId: target.fileId,
493
+ expectedLive: live,
494
+ journal: {
495
+ root: context.root,
496
+ trace: values.trace === true,
497
+ forceLock: values["force-lock"] === true,
498
+ runId: values.resume,
499
+ resume: Boolean(values.resume),
500
+ redact: context.redact,
501
+ requireCheckpoint: values.checkpoint === true,
502
+ ...(values.checkpoint
503
+ ? {
504
+ checkpoint: (runId) => createCheckpoint(client, {
505
+ label: "token push",
506
+ runId,
507
+ }, {
508
+ apply: true,
509
+ }),
510
+ }
511
+ : {}),
240
512
  },
241
513
  }));
242
514
  context.output?.result(report);
243
- for (const step of report.steps)
515
+ for (const step of report.steps) {
244
516
  print(context, `done ${step}`);
517
+ }
245
518
  if (report.verified) {
246
519
  const evidence = JSON.stringify(check.committed);
247
- if (context.redact && context.redact(evidence) !== evidence)
520
+ if (context.redact && context.redact(evidence) !== evidence) {
248
521
  throw new Error("Sync evidence requires credential redaction");
522
+ }
249
523
  await saveSyncAncestor(context.root, target.fileId, check.committed, ancestor);
250
524
  print(context, `ok verified: Penpot now matches the snapshot and no shape moved or was rebound (${report.structureAfter.count} shapes unchanged)`);
251
525
  return 0;
252
526
  }
253
- if (!report.structureUnchanged)
527
+ if (!report.structureUnchanged) {
254
528
  diagnostic(context, `FAIL the shapes changed while pushing tokens (${report.structureBefore.hash} -> ${report.structureAfter.hash}); look at the file before going on`);
255
- for (const line of formatDrift(report.drift))
529
+ }
530
+ for (const line of formatDrift(report.drift)) {
256
531
  diagnostic(context, `FAIL still different: ${line}`);
532
+ }
257
533
  return 1;
258
534
  }
259
535
  async function statusCommand(context, args) {
@@ -261,34 +537,54 @@ async function statusCommand(context, args) {
261
537
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
262
538
  const lines = [];
263
539
  let code = 0;
264
- lines.push(`Marwes installed: ${Object.entries(check.expectation.installed.versions).map(([n, v]) => `${n} ${v}`).join(", ")}`);
540
+ lines.push(`Marwes installed: ${Object.entries(check.expectation.installed.versions)
541
+ .map(([n, v]) => `${n} ${v}`)
542
+ .join(", ")}`);
265
543
  for (const problem of check.expectation.problems) {
266
544
  lines.push(`FAIL ${problem}`);
267
545
  code = 1;
268
546
  }
269
- lines.push(check.committed ? `Snapshot (${check.expectation.settings.snapshot}): ${summary(check.committed)}` : `Snapshot: none yet (${check.expectation.settings.snapshot})`);
270
- lines.push(check.current ? "ok the snapshot is current against the installed Marwes and the theme" : `FAIL the snapshot is stale or missing. ${staleAdvice}`);
271
- if (!check.current && code === 0)
547
+ lines.push(check.committed
548
+ ? `Snapshot (${check.expectation.settings.snapshot}): ${summary(check.committed)}`
549
+ : `Snapshot: none yet (${check.expectation.settings.snapshot})`);
550
+ lines.push(check.current
551
+ ? "ok the snapshot is current against the installed Marwes and the theme"
552
+ : `FAIL the snapshot is stale or missing. ${staleAdvice}`);
553
+ if (!check.current && code === 0) {
272
554
  code = 2;
555
+ }
273
556
  let drift;
274
557
  if (check.committed) {
275
558
  try {
276
559
  drift = (await readPenpot(context, check.committed, false)).drift;
277
- lines.push(drift.inSync ? "ok Penpot matches the snapshot" : `FAIL Penpot differs from the snapshot (${drift.sets.reduce((n, s) => n + s.tokens.length, 0)} token(s), ${drift.themes.length} theme(s)); see \`penpair marwes tokens diff\``);
278
- if (!drift.inSync && code === 0)
560
+ lines.push(drift.inSync
561
+ ? "ok Penpot matches the snapshot"
562
+ : `FAIL Penpot differs from the snapshot (${drift.sets.reduce((n, s) => n + s.tokens.length, 0)} token(s), ${drift.themes.length} theme(s)); see \`penpair marwes tokens diff\``);
563
+ if (!drift.inSync && code === 0) {
279
564
  code = 2;
565
+ }
280
566
  }
281
567
  catch (error) {
282
- if (code === 0)
568
+ if (code === 0) {
283
569
  code = 3;
570
+ }
284
571
  lines.push(`skip Penpot: not checked (${String(error.message).split("\n")[0]})`);
285
572
  }
286
573
  }
287
- if (values.json)
288
- print(context, JSON.stringify({ installed: check.expectation.installed.versions, snapshotCurrent: check.current, penpotInSync: drift?.inSync ?? null, complete: Boolean(drift), exit: code }, null, 2));
289
- else
290
- for (const line of lines)
574
+ if (values.json) {
575
+ print(context, JSON.stringify({
576
+ installed: check.expectation.installed.versions,
577
+ snapshotCurrent: check.current,
578
+ penpotInSync: drift?.inSync ?? null,
579
+ complete: Boolean(drift),
580
+ exit: code,
581
+ }, null, 2));
582
+ }
583
+ else {
584
+ for (const line of lines) {
291
585
  print(context, line);
586
+ }
587
+ }
292
588
  return code;
293
589
  }
294
590
  export const marwesCommand = {
@@ -298,6 +594,8 @@ export const marwesCommand = {
298
594
  "penpair marwes theme lint [--json] advisory family/default hue gaps; no design writes",
299
595
  "penpair marwes tokens schema | migrate --from FILE --ownership FILE [--out NEW_FILE] schema and explicit legacy migration",
300
596
  "penpair marwes tokens snapshot [--write] build the expected Penpot tokens from the installed Marwes and your theme; --write saves the snapshot",
597
+ "penpair marwes tokens preview --from SNAPSHOT --theme NAME [--out FILE] offline projected values; no native bindings or live selection changes",
598
+ "penpair marwes tokens migrate --from LEGACY --ownership MAP [--out NEW_FILE] explicit offline ownership upgrade",
301
599
  "penpair marwes tokens check [--json] offline, for CI: is the committed snapshot current? (exit 2 when stale)",
302
600
  "penpair marwes tokens diff [--json] Penpot against the snapshot: missing, changed, added by hand, and what each change reaches",
303
601
  "penpair marwes tokens push [--apply] [--prune] inspect reconciliation; --apply writes, --overwrite resolves value conflicts, --prune removes owned retired tokens; --adopt FILE proposes local declarations",
@@ -309,59 +607,167 @@ export const marwesCommand = {
309
607
  async run(context, args) {
310
608
  const [area, sub, ...rest] = args;
311
609
  if (area === "theme" && sub === "lint") {
312
- if (rest.some(value => value !== "--json"))
610
+ if (rest.some((value) => value !== "--json")) {
313
611
  throw new Error("theme lint accepts only --json");
612
+ }
314
613
  const expected = await computeExpectation(context.root, context.rawConfig, context.config.instance);
315
- if (expected.problems.length)
614
+ if (expected.problems.length) {
316
615
  throw new Error(expected.problems.join("; "));
317
- const defaults = buildSnapshot({ core: expected.installed.core, version: expected.installed.version, name: expected.settings.name, generatedBy: `@penpair/marwes-ui@${adapterVersion}` });
616
+ }
617
+ const defaults = buildSnapshot({
618
+ core: expected.installed.core,
619
+ version: expected.installed.version,
620
+ name: expected.settings.name,
621
+ generatedBy: `@penpair/marwes-ui@${adapterVersion}`,
622
+ });
318
623
  const report = lintThemeFamily(defaults, expected.fresh, expected.settings.themeLint);
319
624
  context.output?.result(report);
320
625
  print(context, JSON.stringify(report, null, 2));
321
626
  return 0;
322
627
  }
323
- if (area === "components")
628
+ if (area === "components") {
324
629
  return componentsCommand(context, args.slice(1));
630
+ }
325
631
  if (area !== "tokens" || !sub) {
326
632
  diagnostic(context, `usage:\n ${marwesCommand.usage.join("\n ")}`);
327
633
  return 1;
328
634
  }
329
635
  if (sub === "schema") {
330
- if (rest.some(value => value !== "--json"))
636
+ if (rest.some((value) => value !== "--json")) {
331
637
  throw new Error("tokens schema accepts only --json");
638
+ }
332
639
  context.output?.result(tokenSnapshotSchema);
333
640
  print(context, JSON.stringify(tokenSnapshotSchema, null, 2));
334
641
  return 0;
335
642
  }
643
+ if (sub === "preview") {
644
+ const { values, positionals } = flags(rest);
645
+ if (positionals.length ||
646
+ !values.from ||
647
+ !values.theme ||
648
+ Object.keys(values).some((key) => ![
649
+ "from",
650
+ "theme",
651
+ "out",
652
+ "json",
653
+ ].includes(key))) {
654
+ throw new Error("tokens preview --from SNAPSHOT --theme NAME [--out FILE]");
655
+ }
656
+ const snapshot = await readSnapshot(context.root, values.from);
657
+ if (!snapshot) {
658
+ throw new Error("Preview source snapshot does not exist");
659
+ }
660
+ const preview = {
661
+ ...projectTheme(snapshot, values.theme),
662
+ sourceSnapshotHash: snapshot.hash,
663
+ };
664
+ if (values.out) {
665
+ const target = await resolveProjectOutput(context.root, values.out);
666
+ if (target === (await resolveProjectInput(context.root, values.from))) {
667
+ throw new Error("Preview cannot overwrite its source snapshot");
668
+ }
669
+ await mkdir(path.dirname(target), {
670
+ recursive: true,
671
+ });
672
+ await atomicWriteFile(target, JSON.stringify(preview, null, 2) + "\n");
673
+ }
674
+ context.output?.result(preview);
675
+ print(context, JSON.stringify(preview, null, 2));
676
+ return preview.complete ? 0 : 3;
677
+ }
336
678
  if (sub === "migrate") {
337
679
  const { values, positionals } = flags(rest);
338
- if (positionals.length || !values.from || !values.ownership || Object.keys(values).some(k => !["from", "ownership", "out", "json"].includes(k)))
680
+ if (positionals.length ||
681
+ !values.from ||
682
+ !values.ownership ||
683
+ Object.keys(values).some((k) => ![
684
+ "from",
685
+ "ownership",
686
+ "out",
687
+ "json",
688
+ ].includes(k))) {
339
689
  throw new Error("tokens migrate --from LEGACY_JSON --ownership SET_OWNERS_JSON [--out NEW_FILE]");
340
- const migrated = migrateTokenSnapshot(await readDesignJson(context.root, values.from), await readDesignJson(context.root, values.ownership));
690
+ }
691
+ const migrated = migrateTokenSnapshot(await readDesignJson(context.root, values.from), (await readDesignJson(context.root, values.ownership)));
341
692
  if (values.out) {
342
693
  const target = await resolveProjectOutput(context.root, values.out);
343
- await mkdir(path.dirname(target), { recursive: true });
694
+ await mkdir(path.dirname(target), {
695
+ recursive: true,
696
+ });
344
697
  await atomicWriteFile(target, JSON.stringify(migrated, null, 2) + "\n");
345
698
  }
346
699
  context.output?.result(migrated);
347
700
  print(context, JSON.stringify(migrated, null, 2));
348
701
  return 0;
349
702
  }
350
- const commands = { snapshot: snapshotCommand, check: checkCommand, diff: diffCommand, push: pushCommand, status: statusCommand, "verify-modes": (context, args) => modeCommand(context, args, flags(args).positionals[0]) };
703
+ const commands = {
704
+ snapshot: snapshotCommand,
705
+ check: checkCommand,
706
+ diff: diffCommand,
707
+ push: pushCommand,
708
+ status: statusCommand,
709
+ "verify-modes": (context, args) => modeCommand(context, args, flags(args).positionals[0]),
710
+ };
351
711
  const run = commands[sub];
352
712
  if (!run) {
353
713
  diagnostic(context, `unknown: penpair marwes tokens ${sub}\nusage:\n ${marwesCommand.usage.join("\n ")}`);
354
714
  return 1;
355
715
  }
356
716
  const parsed = flags(rest);
357
- const allowed = new Set(["json", ...(sub === "snapshot" ? ["write"] : sub === "push" ? ["apply", "prune", "resume", "checkpoint", "force-lock", "overwrite", "adopt"] : sub === "diff" ? ["resolved", "apply", "resume", "checkpoint", "force-lock"] : sub === "verify-modes" ? ["page", "out", "apply", "resume", "checkpoint", "force-lock"] : [])]);
358
- if (sub === "verify-modes" ? parsed.positionals.length !== 1 : parsed.positionals.length)
717
+ const allowed = new Set([
718
+ "json",
719
+ ...(sub === "snapshot"
720
+ ? [
721
+ "write",
722
+ ]
723
+ : sub === "push"
724
+ ? [
725
+ "apply",
726
+ "prune",
727
+ "resume",
728
+ "checkpoint",
729
+ "force-lock",
730
+ "trace",
731
+ "overwrite",
732
+ "adopt",
733
+ ]
734
+ : sub === "diff"
735
+ ? [
736
+ "resolved",
737
+ "apply",
738
+ "resume",
739
+ "checkpoint",
740
+ "force-lock",
741
+ ]
742
+ : sub === "verify-modes"
743
+ ? [
744
+ "page",
745
+ "out",
746
+ "apply",
747
+ "resume",
748
+ "checkpoint",
749
+ "force-lock",
750
+ ]
751
+ : []),
752
+ ]);
753
+ if (sub === "verify-modes" ? parsed.positionals.length !== 1 : parsed.positionals.length) {
359
754
  throw new Error("Unexpected positional arguments after the token subcommand");
360
- for (const key of Object.keys(parsed.values))
361
- if (!allowed.has(key))
755
+ }
756
+ for (const key of Object.keys(parsed.values)) {
757
+ if (!allowed.has(key)) {
362
758
  throw new Error(`--${key} is not supported by tokens ${sub}`);
363
- if (sub === "diff" && !parsed.values.resolved && [parsed.values.apply, parsed.values.resume, parsed.values.checkpoint, parsed.values["force-lock"]].some(Boolean))
759
+ }
760
+ }
761
+ if (sub === "diff" &&
762
+ !parsed.values.resolved &&
763
+ [
764
+ parsed.values.apply,
765
+ parsed.values.resume,
766
+ parsed.values.checkpoint,
767
+ parsed.values["force-lock"],
768
+ ].some(Boolean)) {
364
769
  throw new Error("Mode write flags require diff --resolved");
770
+ }
365
771
  return run(context, rest);
366
772
  },
367
773
  };