@penpair/marwes-ui 0.1.3 → 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
@@ -1,35 +1,104 @@
1
+ import { lintThemeFamily } from "./theme-lint.js";
2
+ import { componentsCommand } from "./components.js";
3
+ import { runModeTrial } from "./mode-trial.js";
4
+ import { ownershipReport, readSyncAncestor, saveSyncAncestor } from "./ownership.js";
5
+ import { mkdir } from "node:fs/promises";
1
6
  import { createRequire } from "node:module";
2
7
  import path from "node:path";
3
- import { parseArgs } from "node:util";
4
- import { loadConfig, readRawConfig } from "@penpair/core";
8
+ import { projectTheme, parseCommandArgs, readDesignJson, atomicWriteFile, resolveProjectOutput, readJournalTarget, guardFile, resolveProjectInput, createCheckpoint, loadConfig, readRawConfig, } from "@penpair/core";
5
9
  import { readMarwesSettings } from "./config.js";
6
10
  import { familyProblems, loadInstalledMarwes } from "./marwes.js";
7
- import { applyPlan, compareToPenpot, describePlan, formatDrift, impactOf, planPush, readLive } from "./penpot.js";
8
- import { diffSnapshots, formatDiff, 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";
9
13
  import { loadThemeInput } from "./theme-input.js";
10
14
  import { buildSnapshot } from "./tokens.js";
15
+ const print = (context, ...values) => (context.output?.log ?? console.log)(...values);
16
+ const diagnostic = (context, ...values) => (context.output?.error ?? console.error)(...values);
11
17
  const adapterVersion = createRequire(import.meta.url)("../package.json").version;
12
18
  export async function computeExpectation(root, rawConfig, instance) {
13
19
  const settings = readMarwesSettings(rawConfig, instance);
14
- const installed = await loadInstalledMarwes(path.resolve(root, settings.project));
15
- const [light, dark] = await Promise.all([settings.themes.light, settings.themes.dark].map((reference) => (reference ? loadThemeInput(root, reference) : undefined)));
20
+ const installed = await loadInstalledMarwes(await resolveProjectInput(root, settings.project));
21
+ const [light, dark] = await Promise.all([
22
+ settings.themes.light,
23
+ settings.themes.dark,
24
+ ].map((reference) => (reference ? loadThemeInput(root, reference) : undefined)));
16
25
  const fresh = buildSnapshot({
17
26
  core: installed.core,
18
27
  version: installed.version,
19
28
  name: settings.name,
20
- project: { ...(light ? { light: light.input } : {}), ...(dark ? { dark: dark.input } : {}) },
29
+ extras: settings.extras,
30
+ project: {
31
+ ...(light
32
+ ? {
33
+ light: light.input,
34
+ }
35
+ : {}),
36
+ ...(dark
37
+ ? {
38
+ dark: dark.input,
39
+ }
40
+ : {}),
41
+ },
21
42
  generatedBy: `@penpair/marwes-ui@${adapterVersion}`,
22
43
  });
23
- return { settings, installed, fresh, problems: familyProblems(installed) };
44
+ return {
45
+ settings,
46
+ installed,
47
+ fresh,
48
+ problems: familyProblems(installed),
49
+ };
24
50
  }
25
51
  export async function checkSnapshotFor(root, rawConfig, instance) {
26
52
  const expectation = await computeExpectation(root, rawConfig, instance);
27
53
  const committed = await readSnapshot(root, expectation.settings.snapshot);
28
54
  const diff = diffSnapshots(committed, expectation.fresh);
29
- const problems = [...expectation.problems];
30
- if (!committed)
31
- problems.push(`there is no snapshot yet at ${expectation.settings.snapshot}; run \`penpair marwes tokens snapshot --write\` and commit it`);
32
- 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
+ };
33
102
  }
34
103
  /**
35
104
  * For a project's own test suite: resolves the project's penpair.config.json and checks that the committed Marwes token snapshot
@@ -41,135 +110,426 @@ export async function checkTokenSnapshot(root) {
41
110
  }
42
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)}`;
43
112
  /** What the snapshot says it does not cover, so a reader is never left thinking everything Marwes provides is in Penpot. */
44
- 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);
45
- const flags = (args) => parseArgs({ args, allowPositionals: true, options: { write: { type: "boolean" }, apply: { type: "boolean" }, prune: { type: "boolean" }, json: { 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
+ });
46
170
  const staleAdvice = "Run `penpair marwes tokens snapshot --write`, review the change in git, and commit it.";
47
171
  async function snapshotCommand(context, args) {
48
172
  const { values } = flags(args);
49
173
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
50
174
  const fresh = check.expectation.fresh;
51
- if (values.json)
52
- console.log(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
+ }
53
184
  else {
54
- console.log(`Expected from the installed ${summary(fresh)}`);
55
- for (const line of formatDiff(check.diff))
56
- console.log(` ${line}`);
57
- if (coverage(fresh))
58
- console.log(`note ${coverage(fresh)}`);
59
- for (const problem of check.problems.filter((p) => !p.startsWith("there is no snapshot")))
60
- console.log(`FAIL ${problem}`);
61
- }
62
- if (check.expectation.problems.length > 0)
185
+ print(context, `Expected from the installed ${summary(fresh)}`);
186
+ for (const line of formatDiff(check.diff)) {
187
+ print(context, ` ${line}`);
188
+ }
189
+ if (coverage(fresh)) {
190
+ print(context, `note ${coverage(fresh)}`);
191
+ }
192
+ for (const problem of check.problems.filter((p) => !p.startsWith("there is no snapshot"))) {
193
+ print(context, `FAIL ${problem}`);
194
+ }
195
+ }
196
+ if (check.expectation.problems.length > 0) {
63
197
  return 1;
198
+ }
64
199
  if (check.current) {
65
- if (!values.json)
66
- console.log(`ok ${check.expectation.settings.snapshot} is current`);
200
+ if (!values.json) {
201
+ print(context, `ok ${check.expectation.settings.snapshot} is current`);
202
+ }
67
203
  return 0;
68
204
  }
69
205
  if (!values.write) {
70
- if (!values.json)
71
- console.log(`${check.committed ? "The committed snapshot is stale." : "There is no snapshot yet."} Run again with --write to save ${check.expectation.settings.snapshot}.`);
206
+ if (!values.json) {
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
+ }
72
209
  return 2;
73
210
  }
74
211
  await writeSnapshot(context.root, check.expectation.settings.snapshot, fresh);
75
- if (!values.json)
76
- console.log(`wrote ${check.expectation.settings.snapshot}. Review the change in git and commit it; then \`penpair marwes tokens diff\` shows what Penpot needs.`);
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) {
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
+ }
77
221
  return 0;
78
222
  }
79
223
  async function checkCommand(context, args) {
80
224
  const { values } = flags(args);
81
225
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
82
226
  if (values.json) {
83
- console.log(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));
84
234
  return check.current ? 0 : check.expectation.problems.length > 0 ? 1 : 2;
85
235
  }
86
236
  if (check.current) {
87
- console.log(`ok ${check.expectation.settings.snapshot} is current (${summary(check.expectation.fresh)})`);
237
+ print(context, `ok ${check.expectation.settings.snapshot} is current (${summary(check.expectation.fresh)})`);
88
238
  return 0;
89
239
  }
90
- for (const line of formatDiff(check.diff))
91
- console.log(` ${line}`);
92
- for (const problem of check.problems)
93
- console.log(`FAIL ${problem}`);
94
- if (check.committed && check.expectation.problems.length === 0) {
95
- 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";
96
- console.log(`FAIL the committed snapshot is stale: ${cause}. ${staleAdvice}`);
240
+ for (const line of formatDiff(check.diff)) {
241
+ print(context, ` ${line}`);
242
+ }
243
+ for (const problem of check.problems) {
244
+ print(context, `FAIL ${problem}`);
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";
252
+ print(context, `FAIL the committed snapshot is stale: ${cause}. ${staleAdvice}`);
97
253
  }
98
254
  return check.expectation.problems.length > 0 ? 1 : 2;
99
255
  }
100
256
  async function readPenpot(context, committed, wantImpact) {
101
257
  return context.withPenpot(async (client) => {
102
- const live = await readLive(client);
103
- const drift = compareToPenpot(committed, live);
104
- return { live, drift, ...(wantImpact && !drift.inSync ? { impact: await impactOf(client).catch(() => undefined) } : {}) };
258
+ const target = await readJournalTarget(client);
259
+ const pinned = guardFile(client, {
260
+ id: target.fileId,
261
+ });
262
+ const live = await readLive(pinned);
263
+ const after = await readJournalTarget(pinned);
264
+ if (after.fileId !== target.fileId || after.revision !== target.revision) {
265
+ throw new Error("File changed during token inspection; inspect again");
266
+ }
267
+ const ancestor = await readSyncAncestor(context.root, target.fileId);
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
+ };
105
281
  });
106
282
  }
107
283
  async function diffCommand(context, args) {
108
284
  const { values } = flags(args);
285
+ if (values.resolved) {
286
+ return modeCommand(context, args);
287
+ }
109
288
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
110
289
  if (!check.committed) {
111
- console.error(`FAIL ${check.problems.join("; ")}`);
290
+ diagnostic(context, `FAIL ${check.problems.join("; ")}`);
112
291
  return 1;
113
292
  }
114
293
  const { drift, impact } = await readPenpot(context, check.committed, true);
115
294
  if (values.json) {
116
- console.log(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));
117
300
  return drift.inSync ? 0 : 2;
118
301
  }
119
- if (!check.current)
120
- console.log(`note: the committed snapshot is stale against the installed Marwes (${staleAdvice}); the comparison below is against the committed one.`);
302
+ if (!check.current) {
303
+ print(context, `note: the committed snapshot is stale against the installed Marwes (${staleAdvice}); the comparison below is against the committed one.`);
304
+ }
121
305
  if (drift.inSync) {
122
- console.log(`ok Penpot matches ${summary(check.committed)}`);
306
+ for (const line of formatDrift(drift, impact)) {
307
+ print(context, `note preserved: ${line}`);
308
+ }
309
+ print(context, `ok Penpot matches ${summary(check.committed)}`);
123
310
  return 0;
124
311
  }
125
- console.log(`Penpot differs from ${summary(check.committed)}:`);
126
- for (const line of formatDrift(drift, impact))
127
- console.log(` ${line}`);
128
- console.log("`penpair marwes tokens push --apply` brings Penpot to the snapshot (Marwes is authoritative: a value changed by hand in Penpot is put back).");
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)) {
316
+ print(context, ` ${line}`);
317
+ }
318
+ print(context, "`penpair marwes tokens push --apply` brings Penpot to the snapshot (designer edits require explicit adoption or overwrite).");
129
319
  return 2;
130
320
  }
321
+ async function modeCommand(context, args, frameId) {
322
+ const { values } = flags(args);
323
+ if ((values.resume || values.checkpoint || values["force-lock"] || values.trace) &&
324
+ !values.apply) {
325
+ throw new Error("Mode resume/checkpoint/force-lock require --apply");
326
+ }
327
+ if (frameId && (!values.page || !values.out)) {
328
+ throw new Error("verify-modes requires one frame ID, --page and --out");
329
+ }
330
+ if (values.out) {
331
+ await resolveProjectOutput(context.root, values.out);
332
+ }
333
+ const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
334
+ if (!check.committed || !check.current) {
335
+ diagnostic(context, "Mode verification requires a current committed snapshot. " + staleAdvice);
336
+ return 2;
337
+ }
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,
345
+ requireCheckpoint: values.checkpoint === true,
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
+ : {}),
365
+ }));
366
+ context.output?.result(report);
367
+ print(context, JSON.stringify(report, null, 2));
368
+ if (!values.apply) {
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.");
370
+ return 0;
371
+ }
372
+ return report.uncertain
373
+ ? 4
374
+ : !report.restoration.verified
375
+ ? 1
376
+ : !report.complete
377
+ ? 3
378
+ : report.verified
379
+ ? 0
380
+ : 2;
381
+ }
131
382
  async function pushCommand(context, args) {
132
383
  const { values } = flags(args);
384
+ if ((values.resume || values.checkpoint || values["force-lock"] || values.trace) &&
385
+ !values.apply) {
386
+ throw new Error("Resume/checkpoint require --apply; inspect the journal and current design first");
387
+ }
133
388
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
134
389
  if (!check.committed) {
135
- console.error(`FAIL ${check.problems.join("; ")}`);
390
+ diagnostic(context, `FAIL ${check.problems.join("; ")}`);
136
391
  return 1;
137
392
  }
138
393
  if (!check.current) {
139
- for (const line of formatDiff(check.diff))
140
- console.log(` ${line}`);
141
- console.error(`STOP the committed snapshot is stale against the installed Marwes or the theme, and the repository's record must lead. ${staleAdvice} Then push.`);
394
+ for (const line of formatDiff(check.diff)) {
395
+ print(context, ` ${line}`);
396
+ }
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.`);
142
398
  return 2;
143
399
  }
144
- const { drift, impact, live } = await readPenpot(context, check.committed, true);
145
- const plan = planPush(check.committed, live, { prune: values.prune === true });
146
- if (drift.inSync && plan.length === 0) {
147
- console.log(`ok Penpot already matches ${summary(check.committed)}; nothing to write`);
400
+ const { drift, impact, live, target } = await readPenpot(context, check.committed, true);
401
+ if (values.adopt && (values.apply || values.overwrite || values.resume)) {
402
+ throw new Error("--adopt writes a local review proposal and cannot be combined with apply/overwrite/resume");
403
+ }
404
+ const ancestor = await readSyncAncestor(context.root, target.fileId);
405
+ const ownership = ownershipReport(check.committed, live, ancestor);
406
+ if (values.adopt) {
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) {
417
+ throw new Error("Adoption evidence requires credential redaction; no proposal was published");
418
+ }
419
+ const out = await resolveProjectOutput(context.root, values.adopt);
420
+ await mkdir(path.dirname(out), {
421
+ recursive: true,
422
+ });
423
+ await atomicWriteFile(out, proposal);
424
+ context.output?.result({
425
+ phase: "adoption-proposed",
426
+ file: values.adopt,
427
+ ownership,
428
+ complete: false,
429
+ });
430
+ print(context, "Saved adoption proposal. Review declarations and regenerate the snapshot before pushing; no Penpot write or ancestor update occurred.");
431
+ return 3;
432
+ }
433
+ const plan = planPush(check.committed, live, {
434
+ prune: values.prune === true,
435
+ ancestor,
436
+ });
437
+ if (ownership.blockers.length && !values.overwrite && !values.resume) {
438
+ context.output?.result({
439
+ phase: "conflict",
440
+ ownership,
441
+ plan,
442
+ verified: false,
443
+ });
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.`);
445
+ return 2;
446
+ }
447
+ if (drift.inSync && plan.length === 0 && !values.resume) {
448
+ const evidence = JSON.stringify(check.committed);
449
+ if (context.redact && context.redact(evidence) !== evidence) {
450
+ throw new Error("Sync evidence requires credential redaction");
451
+ }
452
+ if (values.apply) {
453
+ await saveSyncAncestor(context.root, target.fileId, check.committed, ancestor);
454
+ }
455
+ context.output?.result({
456
+ verified: true,
457
+ unchanged: true,
458
+ drift,
459
+ ownership,
460
+ });
461
+ print(context, `ok Penpot already matches ${summary(check.committed)}; nothing to write`);
148
462
  return 0;
149
463
  }
150
- console.log(`Penpot differs from ${summary(check.committed)}:`);
151
- for (const line of formatDrift(drift, impact))
152
- console.log(` ${line}`);
153
- console.log("Plan:");
154
- for (const line of describePlan(plan))
155
- console.log(` ${line}`);
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)) {
468
+ print(context, ` ${line}`);
469
+ }
470
+ print(context, "Plan:");
471
+ for (const line of describePlan(plan)) {
472
+ print(context, ` ${line}`);
473
+ }
156
474
  if (!values.apply) {
157
- console.log("Nothing was written. Run again with --apply to do it.");
158
- if (!values.prune && drift.sets.some((s) => s.tokens.some((t) => t.kind === "extra")))
159
- console.log("Tokens added by hand are kept unless you add --prune.");
475
+ context.output?.result({
476
+ phase: "inspected",
477
+ ownership,
478
+ plan,
479
+ drift,
480
+ snapshotCurrent: check.current,
481
+ verified: false,
482
+ });
483
+ print(context, "Nothing was written. Run again with --apply to do it.");
484
+ if (!values.prune && drift.sets.some((s) => s.tokens.some((t) => t.kind === "extra"))) {
485
+ print(context, "Tokens without a verified ownership ancestor are preserved, including with --prune.");
486
+ }
160
487
  return 0;
161
488
  }
162
- const report = await context.withPenpot((client) => applyPlan(client, check.committed, plan, (message) => context.log(message)));
163
- for (const step of report.steps)
164
- console.log(`done ${step}`);
489
+ const report = await context.withPenpot((client) => applyPlan(client, check.committed, plan, (message) => context.log(message), {
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
+ : {}),
512
+ },
513
+ }));
514
+ context.output?.result(report);
515
+ for (const step of report.steps) {
516
+ print(context, `done ${step}`);
517
+ }
165
518
  if (report.verified) {
166
- console.log(`ok verified: Penpot now matches the snapshot and no shape moved or was rebound (${report.structureAfter.count} shapes unchanged)`);
519
+ const evidence = JSON.stringify(check.committed);
520
+ if (context.redact && context.redact(evidence) !== evidence) {
521
+ throw new Error("Sync evidence requires credential redaction");
522
+ }
523
+ await saveSyncAncestor(context.root, target.fileId, check.committed, ancestor);
524
+ print(context, `ok verified: Penpot now matches the snapshot and no shape moved or was rebound (${report.structureAfter.count} shapes unchanged)`);
167
525
  return 0;
168
526
  }
169
- if (!report.structureUnchanged)
170
- console.error(`FAIL the shapes changed while pushing tokens (${report.structureBefore.hash} -> ${report.structureAfter.hash}); look at the file before going on`);
171
- for (const line of formatDrift(report.drift))
172
- console.error(`FAIL still different: ${line}`);
527
+ if (!report.structureUnchanged) {
528
+ diagnostic(context, `FAIL the shapes changed while pushing tokens (${report.structureBefore.hash} -> ${report.structureAfter.hash}); look at the file before going on`);
529
+ }
530
+ for (const line of formatDrift(report.drift)) {
531
+ diagnostic(context, `FAIL still different: ${line}`);
532
+ }
173
533
  return 1;
174
534
  }
175
535
  async function statusCommand(context, args) {
@@ -177,56 +537,237 @@ async function statusCommand(context, args) {
177
537
  const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
178
538
  const lines = [];
179
539
  let code = 0;
180
- 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(", ")}`);
181
543
  for (const problem of check.expectation.problems) {
182
544
  lines.push(`FAIL ${problem}`);
183
545
  code = 1;
184
546
  }
185
- lines.push(check.committed ? `Snapshot (${check.expectation.settings.snapshot}): ${summary(check.committed)}` : `Snapshot: none yet (${check.expectation.settings.snapshot})`);
186
- lines.push(check.current ? "ok the snapshot is current against the installed Marwes and the theme" : `FAIL the snapshot is stale or missing. ${staleAdvice}`);
187
- 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) {
188
554
  code = 2;
555
+ }
189
556
  let drift;
190
557
  if (check.committed) {
191
558
  try {
192
559
  drift = (await readPenpot(context, check.committed, false)).drift;
193
- 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\``);
194
- 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) {
195
564
  code = 2;
565
+ }
196
566
  }
197
567
  catch (error) {
568
+ if (code === 0) {
569
+ code = 3;
570
+ }
198
571
  lines.push(`skip Penpot: not checked (${String(error.message).split("\n")[0]})`);
199
572
  }
200
573
  }
201
- if (values.json)
202
- console.log(JSON.stringify({ installed: check.expectation.installed.versions, snapshotCurrent: check.current, penpotInSync: drift?.inSync ?? null, exit: code }, null, 2));
203
- else
204
- for (const line of lines)
205
- console.log(line);
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) {
585
+ print(context, line);
586
+ }
587
+ }
206
588
  return code;
207
589
  }
208
590
  export const marwesCommand = {
209
591
  name: "marwes",
210
592
  summary: "Marwes tokens in Penpot, and drift from the installed Marwes",
211
593
  usage: [
594
+ "penpair marwes theme lint [--json] advisory family/default hue gaps; no design writes",
595
+ "penpair marwes tokens schema | migrate --from FILE --ownership FILE [--out NEW_FILE] schema and explicit legacy migration",
212
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",
213
599
  "penpair marwes tokens check [--json] offline, for CI: is the committed snapshot current? (exit 2 when stale)",
214
600
  "penpair marwes tokens diff [--json] Penpot against the snapshot: missing, changed, added by hand, and what each change reaches",
215
- "penpair marwes tokens push [--apply] [--prune] bring Penpot to the snapshot (looks first; --apply writes; --prune also removes what is not expected)",
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",
602
+ "penpair marwes tokens diff --resolved [--apply] verify actual values in isolated themes and restore the complete original selection",
603
+ "penpair marwes tokens verify-modes ID --page PAGE --out DIR [--apply] capture stable per-mode PNG evidence and restore selection",
604
+ "penpair marwes components snapshot [--write] | check [--measure] | diff — explicit three-component measurement pilot; native generation gated",
216
605
  "penpair marwes tokens status one screen: installed Marwes, snapshot, Penpot",
217
606
  ],
218
607
  async run(context, args) {
219
608
  const [area, sub, ...rest] = args;
609
+ if (area === "theme" && sub === "lint") {
610
+ if (rest.some((value) => value !== "--json")) {
611
+ throw new Error("theme lint accepts only --json");
612
+ }
613
+ const expected = await computeExpectation(context.root, context.rawConfig, context.config.instance);
614
+ if (expected.problems.length) {
615
+ throw new Error(expected.problems.join("; "));
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
+ });
623
+ const report = lintThemeFamily(defaults, expected.fresh, expected.settings.themeLint);
624
+ context.output?.result(report);
625
+ print(context, JSON.stringify(report, null, 2));
626
+ return 0;
627
+ }
628
+ if (area === "components") {
629
+ return componentsCommand(context, args.slice(1));
630
+ }
220
631
  if (area !== "tokens" || !sub) {
221
- console.error(`usage:\n ${marwesCommand.usage.join("\n ")}`);
632
+ diagnostic(context, `usage:\n ${marwesCommand.usage.join("\n ")}`);
222
633
  return 1;
223
634
  }
224
- const commands = { snapshot: snapshotCommand, check: checkCommand, diff: diffCommand, push: pushCommand, status: statusCommand };
635
+ if (sub === "schema") {
636
+ if (rest.some((value) => value !== "--json")) {
637
+ throw new Error("tokens schema accepts only --json");
638
+ }
639
+ context.output?.result(tokenSnapshotSchema);
640
+ print(context, JSON.stringify(tokenSnapshotSchema, null, 2));
641
+ return 0;
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
+ }
678
+ if (sub === "migrate") {
679
+ const { values, positionals } = flags(rest);
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))) {
689
+ throw new Error("tokens migrate --from LEGACY_JSON --ownership SET_OWNERS_JSON [--out NEW_FILE]");
690
+ }
691
+ const migrated = migrateTokenSnapshot(await readDesignJson(context.root, values.from), (await readDesignJson(context.root, values.ownership)));
692
+ if (values.out) {
693
+ const target = await resolveProjectOutput(context.root, values.out);
694
+ await mkdir(path.dirname(target), {
695
+ recursive: true,
696
+ });
697
+ await atomicWriteFile(target, JSON.stringify(migrated, null, 2) + "\n");
698
+ }
699
+ context.output?.result(migrated);
700
+ print(context, JSON.stringify(migrated, null, 2));
701
+ return 0;
702
+ }
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
+ };
225
711
  const run = commands[sub];
226
712
  if (!run) {
227
- console.error(`unknown: penpair marwes tokens ${sub}\nusage:\n ${marwesCommand.usage.join("\n ")}`);
713
+ diagnostic(context, `unknown: penpair marwes tokens ${sub}\nusage:\n ${marwesCommand.usage.join("\n ")}`);
228
714
  return 1;
229
715
  }
716
+ const parsed = flags(rest);
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) {
754
+ throw new Error("Unexpected positional arguments after the token subcommand");
755
+ }
756
+ for (const key of Object.keys(parsed.values)) {
757
+ if (!allowed.has(key)) {
758
+ throw new Error(`--${key} is not supported by tokens ${sub}`);
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)) {
769
+ throw new Error("Mode write flags require diff --resolved");
770
+ }
230
771
  return run(context, rest);
231
772
  },
232
773
  };