@taprootio/site-authoring 0.8.5 → 0.9.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/README.md CHANGED
@@ -291,10 +291,20 @@ each step names the reference it relies on.
291
291
  upload them with `media upload`, then assign `lightLogoId`, `darkLogoId`
292
292
  and `brand.faviconId` from the returned ids. There is no recolor, crop or
293
293
  SVG upload command.
294
- 5. **Validate, then push.** `validate` runs offline; `theme push` writes the
295
- footer colours, the appearance scalars and then both themes in that order,
296
- not atomically, and a failure names `completedWrites` — pull, reconcile,
297
- push again.
294
+ 5. **Validate, preview, then push.** `validate` runs offline;
295
+ `theme push --dry-run` reads the site and lists the JSON paths at which
296
+ each settings file differs from it, and whether the baseline `pull`
297
+ recorded is still current; `theme push` saves both themes, the appearance
298
+ scalars and the ten footer scheme colours in one transaction fenced by
299
+ that baseline. A concurrent change to any of those fields refuses the
300
+ whole push (`theme.concurrent_modification`) and nothing is written: keep
301
+ copies of the edited files, pull, re-apply, push again. A lost response is
302
+ safe to retry: the save is recorded as pending before it is sent, running
303
+ `theme push` again with the same files replays it under its original
304
+ baseline, and the site answers `applied: false` when it already holds the
305
+ change set. `pull` reads the four settings documents and the revision
306
+ from one snapshot, and `footer push` advances the recorded revision only
307
+ when its save replaced that same revision.
298
308
  6. **Review on staging in both schemes.** `deploy --staging` returns a
299
309
  single-use handoff in `stagingPreview.url`, on a managed Docs site too;
300
310
  open it once and use the site's theme toggle. No page draft is needed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/api.js CHANGED
@@ -103,6 +103,15 @@ export const DEPLOYMENT_STATUSES = Object.freeze([
103
103
  DEPLOYMENT_STATUS_FAILED,
104
104
  ]);
105
105
 
106
+ // DeploySite's admission outcome (TR00839): whether this request created the
107
+ // deployment or coalesced into an already-anchored one.
108
+ export const DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED = "DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED";
109
+ export const DEPLOYMENT_REQUEST_OUTCOME_COALESCED = "DEPLOYMENT_REQUEST_OUTCOME_COALESCED";
110
+ export const DEPLOYMENT_REQUEST_OUTCOMES = Object.freeze([
111
+ DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED,
112
+ DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
113
+ ]);
114
+
106
115
  export const IMAGE_PROCESSING_STATE_UNKNOWN = "IMAGE_PROCESSING_STATE_UNKNOWN";
107
116
  export const IMAGE_PROCESSING_STATE_COMPLETE = "IMAGE_PROCESSING_STATE_COMPLETE";
108
117
  export const IMAGE_PROCESSING_STATE_FAILED = "IMAGE_PROCESSING_STATE_FAILED";
@@ -351,7 +360,11 @@ const REFUSAL_GUIDANCE = Object.freeze({
351
360
  [REFUSAL_CREDENTIAL_REJECTED]:
352
361
  "The site authoring credential was rejected: it is invalid, revoked, or bound to a different site. "
353
362
  + "Stop and re-issue it; retrying cannot succeed.",
354
- [REFUSAL_THROTTLED]: "This credential exceeded its request budget. Back off and retry with delay.",
363
+ // Covers both the per-key request-budget throttle and the durable
364
+ // per-site/per-account deployment request limit (TR00839): both surface as
365
+ // gRPC ResourceExhausted / HTTP 429, and the remedy is identical either way.
366
+ [REFUSAL_THROTTLED]:
367
+ "Too many deployment requests for this site or account. Back off and retry after the delay the server reported.",
355
368
  });
356
369
 
357
370
  /**
@@ -436,6 +449,12 @@ export function announceRefusal(error, onProgress, action = "request") {
436
449
  }
437
450
  const guidance = REFUSAL_GUIDANCE[kind];
438
451
  if (guidance) onProgress(guidance);
452
+ // The retry-after trailer (TR00839) is the exact delay the server measured;
453
+ // surface it when the transport captured it rather than leaving the agent
454
+ // to guess a backoff.
455
+ if (kind === REFUSAL_THROTTLED && typeof error.retryAfterSeconds === "number") {
456
+ onProgress(` Retry after: ${error.retryAfterSeconds}s.`);
457
+ }
439
458
  }
440
459
 
441
460
  /**
@@ -692,6 +711,63 @@ export async function saveSiteFooterSettings(client, siteId, footerSettings, exp
692
711
  );
693
712
  }
694
713
 
714
+ /**
715
+ * The shape of a presentation revision as the site reports it (TR00807): the
716
+ * server's SHA-256 over the presentation change set, lowercase hex. The CLI
717
+ * never derives one — it records what it was told and sends it back — so this
718
+ * is a containment check on a server string that reaches the manifest, not a
719
+ * claim about how the value is computed.
720
+ */
721
+ export const PRESENTATION_REVISION = /^[0-9a-f]{64}$/u;
722
+
723
+ /**
724
+ * Reads the site's draft presentation — the four theme-gated settings groups
725
+ * as `GetSettings` projects them — with the revision a save must carry
726
+ * (TR00807).
727
+ */
728
+ export async function getSitePresentation(client, siteId) {
729
+ return requireSitePresentation(await client.request(sitePath(siteId, "presentation")), siteId);
730
+ }
731
+
732
+ /**
733
+ * Applies a presentation change set atomically (TR00807). `expectedRevision`
734
+ * is the revision the workspace was pulled at; `expectedFooterDraftHash` is
735
+ * the footer document's draft token from the read immediately before, because
736
+ * the server overlays the ten colours onto that document. Sent through the
737
+ * replayable-POST path: a save whose response was lost is answered as already
738
+ * current when it committed, and applied when it did not.
739
+ */
740
+ export async function saveSitePresentation(client, siteId, body) {
741
+ const response = requireObject(
742
+ await client.replaceablePost(sitePath(siteId, "presentation"), { body: { siteId, ...body } }),
743
+ "api.presentation_contract",
744
+ "presentation save",
745
+ );
746
+ return {
747
+ applied: response.applied === true,
748
+ presentation: requireSitePresentation(response.presentation, siteId),
749
+ };
750
+ }
751
+
752
+ function requireSitePresentation(value, siteId) {
753
+ const presentation = requireObject(value, "api.presentation_contract", "site presentation");
754
+ if (!PRESENTATION_REVISION.test(presentation.revision ?? "")) {
755
+ throw new SiteAuthoringError(
756
+ "api.presentation_contract",
757
+ "Taproot returned a presentation with no revision, so a later push could not be fenced against it.",
758
+ { field: "revision" },
759
+ );
760
+ }
761
+ if (typeof presentation.siteId === "string" && presentation.siteId !== "" && presentation.siteId !== siteId) {
762
+ throw new SiteAuthoringError(
763
+ "api.presentation_contract",
764
+ "Taproot returned a presentation for a different site.",
765
+ { field: "siteId" },
766
+ );
767
+ }
768
+ return presentation;
769
+ }
770
+
695
771
  export async function requestImageUpload(client, body) {
696
772
  return requireObject(
697
773
  await client.request("v1/images/request-upload", { method: "POST", body }),
@@ -1017,6 +1093,9 @@ export function normalizeDeployment(value) {
1017
1093
  errorMessage: text(deployment.errorMessage),
1018
1094
  pageCount: safeCount(deployment.pageCount),
1019
1095
  phaseTimings: normalizePhaseTimings(deployment.phaseHistory),
1096
+ // Identical requests coalesced into this deployment inside its settling
1097
+ // window (TR00839). Zero for a deployment that never anchored one.
1098
+ coalescedRequestCount: safeCount(deployment.coalescedRequestCount),
1020
1099
  };
1021
1100
  }
1022
1101
 
@@ -1074,7 +1153,19 @@ export async function deploySite(client, siteId, body) {
1074
1153
  }
1075
1154
  throw error;
1076
1155
  }
1077
- return normalizeDeployment(response.deployment);
1156
+ const deployment = normalizeDeployment(response.deployment);
1157
+ // `DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED` is the zero-adjacent default: an
1158
+ // older server that omits the field, or the immutable Standard/Docs
1159
+ // branches that never run through the admission seam, both read as this
1160
+ // request creating its own deployment (TR00839).
1161
+ const outcome = enumValue(
1162
+ response.outcome,
1163
+ DEPLOYMENT_REQUEST_OUTCOMES,
1164
+ DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED,
1165
+ "api.deployment_contract",
1166
+ "deployment request outcome",
1167
+ );
1168
+ return { ...deployment, outcome };
1078
1169
  }
1079
1170
 
1080
1171
  /**
package/src/cli.js CHANGED
@@ -290,9 +290,16 @@ const VERBS = Object.freeze([
290
290
  surface: SURFACE_DOCS_PRESENTATION,
291
291
  capabilities: [CAPABILITY_DESIGN],
292
292
  tokens: ["theme", "push"],
293
- summary: "Validate and push the workspace's complete theme and appearance settings.",
293
+ dryRun: true,
294
+ summary: "Validate and push the workspace's complete theme and appearance settings in one atomic save.",
294
295
  note: "Run pull first. Theme JSON stays decoded in the workspace; this command validates the complete light/dark"
295
296
  + " pair and encodes it only at the API boundary. Image settings reference site-owned image IDs from media upload. "
297
+ + "The complete change set — both themes, the appearance scalars, and the ten footer scheme colors — is saved in "
298
+ + "one transaction fenced by the presentation revision pull recorded: a concurrent change to any of those "
299
+ + "fields refuses the whole push (theme.concurrent_modification) and nothing is written. A save whose response "
300
+ + "was lost is replayed safely; the site answers applied=false when it already holds the change set. "
301
+ + "--dry-run reads the site, reports the JSON paths at which each settings file differs from it and whether "
302
+ + "the recorded baseline is still current, and writes nothing. "
296
303
  + "pull writes each scheme's complete effective theme — the stored theme resolved over the same defaults every "
297
304
  + "consumer renders — so a fresh workspace validates as pulled. semanticMappings holds only authored pins, listed "
298
305
  + "in explicitMappingTokens; every other token compiles from roles at render time, so never copy default "
@@ -570,6 +577,9 @@ function verbHelp(verb) {
570
577
  const jsonOption = verb.json
571
578
  ? "\n --json Emit the stable JSON contract (operational output is always JSON)."
572
579
  : "";
580
+ const dryRunOption = verb.dryRun
581
+ ? "\n --dry-run Read the site and report what a push would change, without writing."
582
+ : "";
573
583
  const nameOption = verb.keyName
574
584
  ? `\n --name <text> Name recorded on the issued key (default "${DEFAULT_LOGIN_KEY_NAME}",\n`
575
585
  + ` 1-${LOGIN_KEY_NAME_MAXIMUM} characters). The approval screen shows it.`
@@ -637,7 +647,7 @@ function verbHelp(verb) {
637
647
  ${verb.summary}
638
648
  ${boundary}
639
649
 
640
- ${options}${targetOption}${deliveryTargetOption}${rawHtmlOption}${jsonOption}${nameOption}${note}
650
+ ${options}${targetOption}${deliveryTargetOption}${rawHtmlOption}${jsonOption}${dryRunOption}${nameOption}${note}
641
651
  `;
642
652
  }
643
653
 
@@ -912,6 +922,7 @@ function parseArguments(arguments_) {
912
922
  let browser;
913
923
  let allowFailedPreview = false;
914
924
  let allowRawHtml = false;
925
+ let dryRun = false;
915
926
  let json = false;
916
927
  let keyName;
917
928
  const positionals = [];
@@ -977,6 +988,11 @@ function parseArguments(arguments_) {
977
988
  allowRawHtml = true;
978
989
  continue;
979
990
  }
991
+ if (verb.dryRun && argument === "--dry-run") {
992
+ if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
993
+ dryRun = true;
994
+ continue;
995
+ }
980
996
  if (verb.json && argument === "--json") {
981
997
  if (json) throw usageError("cli.duplicate_option", "--json may be supplied only once.");
982
998
  json = true;
@@ -1107,6 +1123,7 @@ function parseArguments(arguments_) {
1107
1123
  ...(browser === undefined ? {} : { browser }),
1108
1124
  init,
1109
1125
  allowRawHtml,
1126
+ dryRun,
1110
1127
  keyName,
1111
1128
  positionals: verb.positionals
1112
1129
  ? {
@@ -1184,6 +1201,7 @@ export async function runCli({
1184
1201
  init: parsed.init,
1185
1202
  quiet: parsed.quiet,
1186
1203
  allowRawHtml: parsed.allowRawHtml,
1204
+ dryRun: parsed.dryRun,
1187
1205
  keyName: parsed.keyName,
1188
1206
  capabilities: parsed.capabilities,
1189
1207
  surface: parsed.surface,
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.8.5";
2
+ export const CLI_VERSION = "0.9.0";
3
3
  export const CLI_BINARY_NAME = "taproot-site";
4
4
  export const RESULT_SCHEMA_VERSION = 1;
5
5
  export const CONFIG_FILE_NAME = "taproot-site.json";
@@ -66,7 +66,7 @@ function requireBoundedDepth(value) {
66
66
  }
67
67
 
68
68
  /** Deterministic JSON: sorted object keys, JSON.stringify value semantics. */
69
- function stableJson(value) {
69
+ export function stableJson(value) {
70
70
  if (value === null || typeof value !== "object") {
71
71
  return JSON.stringify(value) ?? "null";
72
72
  }
@@ -1,7 +1,8 @@
1
1
  import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
2
2
  import { appearanceImageIds } from "./appearance-contract.js";
3
3
  import { footerImageIds } from "./footer-contract.js";
4
- import { computeFooterContentHash, computeFooterDraftHash } from "./footer-draft-hash.js";
4
+ import { createHash } from "node:crypto";
5
+ import { computeFooterContentHash, computeFooterDraftHash, stableJson } from "./footer-draft-hash.js";
5
6
  import { readManifest, readMediaManifest, writeManifest } from "./workspace.js";
6
7
 
7
8
  export const FOOTER_SETTINGS_FILE = "settings/site-publishing-preferences.json";
@@ -73,7 +74,13 @@ export async function readAppearanceWorkspaceContext(workspaceDir, siteId) {
73
74
  };
74
75
  }
75
76
 
76
- export async function advanceFooterManifest(workspaceDir, siteId, footerSettings, expectedDraftHash) {
77
+ export async function advanceFooterManifest(
78
+ workspaceDir,
79
+ siteId,
80
+ footerSettings,
81
+ expectedDraftHash,
82
+ { presentationRevision } = {},
83
+ ) {
77
84
  const manifest = await readManifest(workspaceDir, siteId);
78
85
  const prior = requireFooterMetadata(manifest);
79
86
  const hash = expectedDraftHash ?? computeFooterDraftHash(footerSettings);
@@ -90,10 +97,98 @@ export async function advanceFooterManifest(workspaceDir, siteId, footerSettings
90
97
  expectedContentHash: computeFooterContentHash(footerSettings),
91
98
  imageIds: [...new Set([...prior.imageIds, ...footerImageIds(footerSettings)])].sort(),
92
99
  };
100
+ // The ten scheme colours are part of the presentation change set, so a
101
+ // footer save that moved one moved the revision too; the server reports the
102
+ // new one from the same transaction and it becomes the baseline the next
103
+ // theme push must carry (TR00807). One manifest write for both baselines.
104
+ if (presentationRevision !== undefined) {
105
+ manifest.presentation = presentationManifestEntry(presentationRevision);
106
+ }
93
107
  await writeManifest(workspaceDir, manifest);
94
108
  return hash;
95
109
  }
96
110
 
111
+ /**
112
+ * The manifest's record of the presentation revision this workspace was
113
+ * pulled at (TR00807): what `theme push` sends as its baseline, and what a
114
+ * dry run compares the site's current revision against.
115
+ */
116
+ export function presentationManifestEntry(revision) {
117
+ if (!SHA256.test(revision ?? "")) {
118
+ throw new SiteAuthoringError(
119
+ "api.presentation_contract",
120
+ "Taproot returned a presentation revision that could not be recorded.",
121
+ { field: "revision" },
122
+ );
123
+ }
124
+ return { revision };
125
+ }
126
+
127
+ /**
128
+ * The recorded presentation baseline, or `undefined` when the manifest holds
129
+ * none — a workspace pulled before the atomic save existed, or against a
130
+ * Taproot that does not serve it. Absent is a fact `theme push` refuses on,
131
+ * not a value to guess.
132
+ */
133
+ export function readPresentationBaseline(manifest) {
134
+ const presentation = manifest.presentation;
135
+ if (presentation === null || typeof presentation !== "object" || Array.isArray(presentation)) return undefined;
136
+ return SHA256.test(presentation.revision ?? "") ? { revision: presentation.revision } : undefined;
137
+ }
138
+
139
+ /**
140
+ * The content identity of one presentation change set — what `theme push`
141
+ * sends — so a later run can tell whether a save recorded as pending was for
142
+ * exactly the change set it is about to send again.
143
+ */
144
+ export function computePresentationChangeSetHash(changeSet) {
145
+ return createHash("sha256").update(`presentation-change-set-v1${stableJson(changeSet)}`, "utf8").digest("hex");
146
+ }
147
+
148
+ /**
149
+ * The record of a presentation save whose outcome this workspace does not yet
150
+ * know (TR00807): the baseline it was sent under and the change set it
151
+ * carried, written before the request and replaced by the manifest write
152
+ * that follows an authoritative answer. A save that committed while its
153
+ * response was lost leaves the site one revision ahead of the workspace; the
154
+ * next push of the same change set replays it under this baseline, and the
155
+ * site answers already-current instead of the stale refusal the workspace's
156
+ * own comparison would give. `undefined` when nothing is pending.
157
+ */
158
+ export function readPendingPresentationSave(manifest) {
159
+ const pending = manifest.presentation?.pending;
160
+ if (pending === null || typeof pending !== "object" || Array.isArray(pending)) return undefined;
161
+ if (!SHA256.test(pending.expectedRevision ?? "") || !SHA256.test(pending.changeSetHash ?? "")) return undefined;
162
+ return {
163
+ expectedRevision: pending.expectedRevision,
164
+ changeSetHash: pending.changeSetHash,
165
+ startedAt: typeof pending.startedAt === "string" ? pending.startedAt : undefined,
166
+ };
167
+ }
168
+
169
+ export async function recordPendingPresentationSave(workspaceDir, siteId, { expectedRevision, changeSetHash, startedAt }) {
170
+ const manifest = await readManifest(workspaceDir, siteId);
171
+ const baseline = readPresentationBaseline(manifest);
172
+ if (baseline === undefined) {
173
+ throw new SiteAuthoringError(
174
+ "theme.pull_required",
175
+ "The pull manifest records no presentation baseline. Run 'taproot-site pull' again.",
176
+ { field: "presentation.revision" },
177
+ );
178
+ }
179
+ manifest.presentation = { revision: baseline.revision, pending: { expectedRevision, changeSetHash, startedAt } };
180
+ await writeManifest(workspaceDir, manifest);
181
+ }
182
+
183
+ /** Drops the pending record after an authoritative refusal; the baseline stays. */
184
+ export async function clearPendingPresentationSave(workspaceDir, siteId) {
185
+ const manifest = await readManifest(workspaceDir, siteId);
186
+ const baseline = readPresentationBaseline(manifest);
187
+ if (baseline === undefined || readPendingPresentationSave(manifest) === undefined) return;
188
+ manifest.presentation = presentationManifestEntry(baseline.revision);
189
+ await writeManifest(workspaceDir, manifest);
190
+ }
191
+
97
192
  export function footerManifestEntry(footerSettings) {
98
193
  return {
99
194
  file: FOOTER_SETTINGS_FILE,
@@ -440,14 +440,34 @@ export function getAppearanceReference() {
440
440
  + "menus with many groups. The navigation face itself is not an appearance scalar: fontMenu and fontWeightMenu "
441
441
  + "are per-scheme theme fields set in settings.lightTheme/darkTheme the same way fontBrand is, and an empty "
442
442
  + "fontMenu falls back to the body font, never to headings.",
443
- mutationOrder: [
444
- "fresh footer read plus concurrency-protected ten-color overlay",
443
+ changeSet: [
444
+ "the ten footer scheme colors, overlaid onto the site's current footer document",
445
445
  "default scheme, assets, favicon, and header scalars",
446
- "complete light theme, then complete dark theme",
446
+ "the complete light theme and the complete dark theme",
447
447
  ],
448
- nonAtomic: true,
448
+ atomic: true,
449
+ revision:
450
+ "pull reads the four settings documents and the site's presentation revision — a server hash over exactly "
451
+ + "the fields theme push writes — from one snapshot, so the recorded baseline is the revision of the "
452
+ + "documents the workspace holds; theme push sends it back as the baseline. The whole change set commits in "
453
+ + "one transaction or none of it does; a change to any of those fields since the pull refuses the push "
454
+ + "(theme.concurrent_modification) and nothing is written. Footer prose, links and imagery, and the "
455
+ + "publishing scalars are outside the revision, so an unrelated concurrent edit is preserved rather than "
456
+ + "refused. footer push advances the baseline only when its save replaced that same revision.",
457
+ dryRun:
458
+ CLI_BINARY_NAME + " theme push --dry-run reads the site, lists the JSON paths at which each settings file "
459
+ + "differs from it, and says whether the recorded baseline is still current or a pending save of the same "
460
+ + "change set would be replayed; it writes nothing.",
461
+ retry:
462
+ "The save is recorded as pending in the manifest before it is sent. A save whose response was lost is "
463
+ + "replayed by running theme push again with the same settings files: it goes under the baseline it was "
464
+ + "first sent with, even after the site's revision moved, and the site answers applied=false when it already "
465
+ + "holds the change set, so nothing is applied twice and no partial write is ever reported. Editing the "
466
+ + "files before retrying makes the pending record moot and a moved revision refuses toward pull.",
449
467
  recovery:
450
- "A failure reports completedWrites. Pull, compare the remote result with the intended workspace, reconcile, and retry.",
468
+ "On theme.concurrent_modification keep copies of the edited settings files, run " + CLI_BINARY_NAME
469
+ + " pull to refresh the baseline, re-apply the edits, and push again. A Taproot without the atomic save "
470
+ + "refuses with theme.server_unsupported; theme push never falls back to sequential writes.",
451
471
  footerBoundary:
452
472
  "theme push changes only five colors per footer scheme. Use footer push for prose, links, layout, and imagery.",
453
473
  footerContentGuard:
@@ -604,9 +624,11 @@ export function getFooterReference() {
604
624
  concurrency:
605
625
  "pull records expectedDraftHash. A conflict returns footer.concurrent_modification; pull, reconcile, and retry.",
606
626
  themeInteraction:
607
- "theme push rewrites this file from a fresh server read before its ten-color overlay. It refuses while "
608
- + "unpushed footer-content edits exist (theme.unpushed_footer_content); run " + CLI_BINARY_NAME
609
- + " footer push first, or " + CLI_BINARY_NAME + " pull to discard the local edit.",
627
+ "theme push overlays its ten scheme colors onto the site's current footer document inside its atomic save and "
628
+ + "rewrites this file from the saved result. It refuses while unpushed footer-content edits exist "
629
+ + "(theme.unpushed_footer_content); run " + CLI_BINARY_NAME + " footer push first, or " + CLI_BINARY_NAME
630
+ + " pull to discard the local edit. A footer push that changed a scheme color advances the presentation "
631
+ + "baseline theme push is fenced by.",
610
632
  workflow: [
611
633
  CLI_BINARY_NAME + " pull",
612
634
  "edit settings/site-publishing-preferences.json at settings.footerSettings",
@@ -704,8 +726,11 @@ export function formatPresentationReference(reference) {
704
726
  + "\nHeader width: " + reference.headerWidthContract
705
727
  + "\nMobile menu and menu font: " + reference.menuContract
706
728
  + "\nFooter: " + reference.footerBoundary
707
- + "\nFooter content guard: " + reference.footerContentGuard + "\n\nMutation order (non-atomic):\n"
708
- + reference.mutationOrder.map((step, index) => " " + (index + 1) + ". " + step).join("\n")
729
+ + "\nFooter content guard: " + reference.footerContentGuard + "\n\nAtomic change set (one transaction):\n"
730
+ + reference.changeSet.map((step, index) => " " + (index + 1) + ". " + step).join("\n")
731
+ + "\nRevision: " + reference.revision
732
+ + "\nDry run: " + reference.dryRun
733
+ + "\nRetry: " + reference.retry
709
734
  + "\nRecovery: " + reference.recovery + "\n";
710
735
  }
711
736
  if (reference.referenceKind === "footer") {
@@ -481,9 +481,13 @@ const WORKFLOW_REFERENCES = Object.freeze({
481
481
  `Validate offline: ${CLI_BINARY_NAME} validate checks the complete pair, the appearance files and the footer against `
482
482
  + "the contracts the site enforces, and warns when a semanticMappings pin repeats the default on a token your "
483
483
  + "roles would have moved (the pin would keep the role from rendering).",
484
- `Push: ${CLI_BINARY_NAME} theme push writes the footer scheme colours, the appearance scalars, then the light and dark `
485
- + "themes in that order, not atomically. A failure reports completedWrites: pull, compare the remote result "
486
- + "with the workspace, reconcile, and push again. footer push owns footer prose, links and imagery.",
484
+ `Push: ${CLI_BINARY_NAME} theme push --dry-run first: it reads the site and lists the JSON paths at which each `
485
+ + "settings file differs from it, and says whether the baseline pull recorded is still current. Then "
486
+ + `${CLI_BINARY_NAME} theme push saves both themes, the appearance scalars and the ten footer scheme colours in `
487
+ + "one transaction fenced by that baseline: a concurrent change to any of those fields refuses the whole push "
488
+ + "(theme.concurrent_modification) and nothing is written; keep copies of the edited files, pull, re-apply, "
489
+ + "and push again. A lost response is safe to retry — the site answers applied=false when it already holds "
490
+ + "the change set. footer push owns footer prose, links and imagery.",
487
491
  `Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation and returns a single-use review handoff `
488
492
  + "in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's theme toggle to check "
489
493
  + "both schemes: text, headings, links and hover, actions and focus, the header logos, the favicon. When no "
package/src/transport.js CHANGED
@@ -125,6 +125,12 @@ const UUID_PATH = "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0
125
125
  * server answers `..._CONSUMED`, which `login` surfaces as an issued-but-
126
126
  * unreceived credential the owner must revoke. Not replaying would leave that
127
127
  * same orphan and report only a network error, which is strictly worse.
128
+ * - **Atomic presentation save** (TR00807) — the request carries the revision
129
+ * it was built against, and the server computes the revision the change set
130
+ * would produce. A replay of a save that committed finds the site already at
131
+ * that revision and answers `applied: false` with the current state; a
132
+ * replay of one that never committed applies it; anything else is refused as
133
+ * stale. No replay can apply the change set twice or report a partial one.
128
134
  */
129
135
  const REPLACEABLE_POST_PATHS = Object.freeze([
130
136
  new RegExp(
@@ -132,6 +138,7 @@ const REPLACEABLE_POST_PATHS = Object.freeze([
132
138
  "u",
133
139
  ),
134
140
  /^v1\/site-authoring\/cli-authorizations\/claim$/u,
141
+ new RegExp(`^v1/sites/${UUID_PATH}/presentation$`, "u"),
135
142
  ]);
136
143
 
137
144
  /**
@@ -396,8 +403,21 @@ export function capabilityRefusal(value) {
396
403
  return undefined;
397
404
  }
398
405
 
406
+ /**
407
+ * Reads the `retry-after` response header as a non-negative integer number of
408
+ * seconds. `headers` is a Fetch `Headers` instance when available; anything
409
+ * else, a missing header, or a value that does not parse as a non-negative
410
+ * number, returns undefined rather than throwing.
411
+ */
412
+ function parseRetryAfterHeader(headers) {
413
+ const raw = typeof headers?.get === "function" ? headers.get("retry-after") : undefined;
414
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
415
+ const seconds = Number(raw);
416
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
417
+ }
418
+
399
419
  export class ApiError extends SiteAuthoringError {
400
- constructor(httpStatus, body) {
420
+ constructor(httpStatus, body, headers) {
401
421
  const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
402
422
  const fields = fieldViolations(body);
403
423
  const capability = capabilityRefusal(body);
@@ -433,6 +453,11 @@ export class ApiError extends SiteAuthoringError {
433
453
  this.fields = fields;
434
454
  this.violationDescriptions = fieldDescriptions(body);
435
455
  this.capability = capability;
456
+ // The `retry-after` trailer (TR00839's deployment throttle, or any other
457
+ // ResourceExhausted refusal that sets it) transcodes to an ordinary
458
+ // response header. Undefined when absent or unparseable — callers must
459
+ // not assume every throttle carries a delay.
460
+ this.retryAfterSeconds = parseRetryAfterHeader(headers);
436
461
  }
437
462
 
438
463
  hasField(field) {
@@ -767,7 +792,7 @@ export class SiteApiClient {
767
792
  responseBody = Object.create(null);
768
793
  }
769
794
  if (remainingBudget(deadline, now) <= 0) throw deadlineError();
770
- throw new ApiError(response.status, responseBody);
795
+ throw new ApiError(response.status, responseBody, response.headers);
771
796
  }
772
797
  throw new SiteAuthoringError("transport.retry_exhausted", "The Taproot API request exhausted its retry budget.");
773
798
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DEPLOYMENT_ENVIRONMENT_PRODUCTION,
3
3
  DEPLOYMENT_ENVIRONMENT_STAGING,
4
+ DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
4
5
  DEPLOYMENT_STATUS_COMPLETED,
5
6
  deploySite,
6
7
  getDeploySelection,
@@ -80,6 +81,16 @@ function usageError(code, message, field) {
80
81
  return new SiteAuthoringError(code, message, { field, exitCode: 2 });
81
82
  }
82
83
 
84
+ /**
85
+ * One line when this request coalesced into an already in-flight or recently
86
+ * anchored identical deployment (TR00839), instead of creating a new one.
87
+ */
88
+ function announceOutcome(created, onProgress) {
89
+ if (created.outcome === DEPLOYMENT_REQUEST_OUTCOME_COALESCED) {
90
+ onProgress(`An identical deployment request is already in progress; reusing deployment ${created.id}.`);
91
+ }
92
+ }
93
+
83
94
  function explicitSelection(invocation) {
84
95
  const stagedPageIds = Array.isArray(invocation.stagedPageIds) ? invocation.stagedPageIds : undefined;
85
96
  const selectedSettingsTypes = Array.isArray(invocation.selectedSettingsTypes)
@@ -268,6 +279,7 @@ export async function deploy(invocation) {
268
279
  ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
269
280
  });
270
281
  onProgress(`Production deployment ${created.id} accepted; waiting for it to complete.`);
282
+ announceOutcome(created, onProgress);
271
283
  const completed = await waitForDeployment(client, {
272
284
  siteId,
273
285
  deploymentId: created.id,
@@ -283,6 +295,7 @@ export async function deploy(invocation) {
283
295
  ...(presentationOnly ? { authoringSurface: SURFACE_DOCS_PRESENTATION } : {}),
284
296
  promotedStagingDeploymentId: stagingDeploymentId,
285
297
  deployment: completed,
298
+ outcome: created.outcome,
286
299
  readiness: reportReadiness(readiness),
287
300
  });
288
301
  }
@@ -357,6 +370,7 @@ export async function deploy(invocation) {
357
370
  ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
358
371
  });
359
372
  onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
373
+ announceOutcome(created, onProgress);
360
374
  const completed = await waitForDeployment(client, {
361
375
  siteId,
362
376
  deploymentId: created.id,
@@ -377,6 +391,7 @@ export async function deploy(invocation) {
377
391
  includeNavigation: false,
378
392
  },
379
393
  deployment: completed,
394
+ outcome: created.outcome,
380
395
  readiness: reportReadiness(readiness),
381
396
  stagingPreview,
382
397
  nextStep: stagingPreview.url === "" ? "staging review" : "deploy --production",
@@ -393,6 +408,7 @@ export async function deploy(invocation) {
393
408
  includeNavigation,
394
409
  },
395
410
  deployment: completed,
411
+ outcome: created.outcome,
396
412
  readiness: reportReadiness(readiness),
397
413
  stagingPreview,
398
414
  nextStep: stagingPreview.redirects?.verified === true ? "deploy --production" : "redirects check",
@@ -1,9 +1,14 @@
1
- import { saveSiteFooterSettings, withRefusalGuidance } from "../api.js";
1
+ import { PRESENTATION_REVISION, saveSiteFooterSettings, withRefusalGuidance } from "../api.js";
2
2
  import { VERB_FOOTER_PUSH } from "../constants.js";
3
3
  import { SiteAuthoringError } from "../errors.js";
4
4
  import { projectFooterSettingsForWorkspace, validateFooterDocument } from "../footer-contract.js";
5
5
  import { computeFooterDraftHash } from "../footer-draft-hash.js";
6
- import { advanceFooterManifest, FOOTER_SETTINGS_FILE, readFooterWorkspaceContext } from "../footer-workspace.js";
6
+ import {
7
+ advanceFooterManifest,
8
+ FOOTER_SETTINGS_FILE,
9
+ readFooterWorkspaceContext,
10
+ readPresentationBaseline,
11
+ } from "../footer-workspace.js";
7
12
  import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
8
13
  import { SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
9
14
  import { ApiError } from "../transport.js";
@@ -105,7 +110,27 @@ export async function footerPush(invocation) {
105
110
  // fails, a stale token prevents the next push from overwriting the saved
106
111
  // remote footer and the completed-write report tells the caller to pull.
107
112
  await writeWorkspaceJson(config.workspaceDir, FOOTER_SETTINGS_FILE, nextDocument);
108
- await advanceFooterManifest(config.workspaceDir, siteId, saved, footerDraftHash);
113
+ // A footer save that moved a scheme colour moved the presentation revision
114
+ // (TR00807); the server reports the new one from its own transaction, and
115
+ // the one it replaced. The baseline advances only when the replaced
116
+ // revision is the one this workspace was pulled at: otherwise the site's
117
+ // appearance moved since the pull, the workspace's appearance files do not
118
+ // reflect it, and a baseline taken from this save would let the next
119
+ // theme push overwrite those edits unseen. A Taproot that predates the
120
+ // fields leaves the recorded baseline alone.
121
+ const baseline = readPresentationBaseline(context.manifest);
122
+ const reported = PRESENTATION_REVISION.test(response.presentationRevision ?? "")
123
+ && PRESENTATION_REVISION.test(response.previousPresentationRevision ?? "");
124
+ const advances = reported && baseline !== undefined && baseline.revision === response.previousPresentationRevision;
125
+ if (reported && baseline !== undefined && !advances) {
126
+ onProgress(
127
+ "The site's presentation moved after this workspace was pulled, so its presentation baseline was left as "
128
+ + "it is; 'taproot-site theme push' will refuse until the workspace is pulled again.",
129
+ );
130
+ }
131
+ await advanceFooterManifest(config.workspaceDir, siteId, saved, footerDraftHash, {
132
+ presentationRevision: advances ? response.presentationRevision : undefined,
133
+ });
109
134
  } catch (error) {
110
135
  if (error instanceof SiteAuthoringError) throw error.withCompletedWrites(["footerSettings"]);
111
136
  throw error;
package/src/verbs/pull.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  getNavigation,
3
3
  getPage,
4
4
  getSettingsGroup,
5
+ getSitePresentation,
5
6
  getSiteRedirectMap,
6
7
  listSitePages,
7
8
  PAGE_STATUS_DELETED,
@@ -17,7 +18,7 @@ import {
17
18
  VERB_PULL,
18
19
  } from "../constants.js";
19
20
  import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
20
- import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
21
+ import { appearanceManifestEntry, footerManifestEntry, presentationManifestEntry } from "../footer-workspace.js";
21
22
  import { describeJsonDifferences, reportableDifferencePaths } from "../json-path-diff.js";
22
23
  import { projectRedirectMapForWorkspace, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
23
24
  import { redirectsManifestEntry } from "../redirects-workspace.js";
@@ -626,28 +627,74 @@ export async function pull(invocation) {
626
627
  // decoding is a wire-contract check and can fail on malformed stored data;
627
628
  // discovering that after page bodies were written would strand an
628
629
  // unbound workspace that the next pull must refuse for safety.
630
+ //
631
+ // The four groups and the presentation revision `theme push` is fenced by
632
+ // (TR00807) come from one read: the presentation snapshot carries the
633
+ // groups' documents exactly as the revision covers them, so the baseline
634
+ // the manifest records is the revision of the documents the workspace
635
+ // holds. Four per-group reads followed by a revision read would pair
636
+ // whatever the groups held with whatever the revision had become by then,
637
+ // and a push fenced by that baseline could overwrite an edit the workspace
638
+ // never saw. The per-group reads remain for a Taproot that predates the
639
+ // snapshot (404) and for a credential that cannot read it (403): both
640
+ // record no baseline, and `theme push` refuses toward a pull that does.
641
+ let presentation;
642
+ onProgress("Reading the presentation snapshot.");
643
+ try {
644
+ presentation = await getSitePresentation(client, siteId);
645
+ } catch (error) {
646
+ if (
647
+ error instanceof ApiError
648
+ && error.refusalKind() === REFUSAL_UNCLASSIFIED
649
+ && error.httpStatus === 404
650
+ ) {
651
+ onProgress(
652
+ "This Taproot does not serve the atomic presentation save yet; the settings groups are read one at a "
653
+ + "time, the manifest records no presentation baseline, and 'theme push' will refuse until it is "
654
+ + "pulled from a Taproot that does.",
655
+ );
656
+ } else if (
657
+ error instanceof ApiError
658
+ && (error.refusalKind() === REFUSAL_UNCLASSIFIED
659
+ || error.refusalKind() === REFUSAL_CAPABILITY_MISSING)
660
+ && error.httpStatus === 403
661
+ ) {
662
+ onProgress(
663
+ "The presentation snapshot is not readable by this credential; the settings groups are read one at a "
664
+ + "time and the manifest records no presentation baseline.",
665
+ );
666
+ } else {
667
+ throw error;
668
+ }
669
+ }
670
+ const presentationRevision = presentation?.revision;
671
+
629
672
  const pulledSettings = [];
630
673
  const skippedSettings = [];
631
674
  const settingsDocuments = [];
632
675
  let pulledFooter;
633
676
  const pulledAppearance = {};
634
677
  for (const group of SETTINGS_GROUPS) {
635
- onProgress(`Reading settings group ${group.settingsType}.`);
636
678
  let response;
637
- try {
638
- response = await getSettingsGroup(client, siteId, group.settingsType);
639
- } catch (error) {
640
- if (
641
- error instanceof ApiError
642
- && (error.refusalKind() === REFUSAL_UNCLASSIFIED
643
- || error.refusalKind() === REFUSAL_CAPABILITY_MISSING)
644
- && UNAVAILABLE_SETTINGS_STATUSES.has(error.httpStatus)
645
- ) {
646
- onProgress(`Settings group ${group.settingsType} is not readable by this credential; skipping it.`);
647
- skippedSettings.push(group.settingsType);
648
- continue;
679
+ if (presentation !== undefined) {
680
+ response = presentation;
681
+ } else {
682
+ onProgress(`Reading settings group ${group.settingsType}.`);
683
+ try {
684
+ response = await getSettingsGroup(client, siteId, group.settingsType);
685
+ } catch (error) {
686
+ if (
687
+ error instanceof ApiError
688
+ && (error.refusalKind() === REFUSAL_UNCLASSIFIED
689
+ || error.refusalKind() === REFUSAL_CAPABILITY_MISSING)
690
+ && UNAVAILABLE_SETTINGS_STATUSES.has(error.httpStatus)
691
+ ) {
692
+ onProgress(`Settings group ${group.settingsType} is not readable by this credential; skipping it.`);
693
+ skippedSettings.push(group.settingsType);
694
+ continue;
695
+ }
696
+ throw error;
649
697
  }
650
- throw error;
651
698
  }
652
699
  const file = `${SETTINGS_DIRECTORY}/${group.file}`;
653
700
  const projected = projectSettingsGroup(group, response);
@@ -1048,6 +1095,7 @@ export async function pull(invocation) {
1048
1095
  ? { appearance: appearanceManifestEntry(pulledAppearance) }
1049
1096
  : {}
1050
1097
  ),
1098
+ ...(presentationRevision === undefined ? {} : { presentation: presentationManifestEntry(presentationRevision) }),
1051
1099
  pages: manifestPages,
1052
1100
  };
1053
1101
  await writeManifest(config.workspaceDir, manifest);
@@ -1,27 +1,36 @@
1
- import { getSettingsGroup, saveSiteFooterSettings, setSetting, withRefusalGuidance } from "../api.js";
1
+ import { getSitePresentation, saveSitePresentation, withRefusalGuidance } from "../api.js";
2
2
  import {
3
+ APPEARANCE_FOOTER_COLOR_FIELDS,
3
4
  APPEARANCE_SCALAR_FIELDS,
4
- applyFooterColors,
5
5
  buildAppearanceScalarOperations,
6
6
  footerColorOverlay,
7
7
  } from "../appearance-contract.js";
8
- import { VERB_THEME_PUSH } from "../constants.js";
8
+ import { REFUSAL_UNCLASSIFIED, VERB_THEME_PUSH } from "../constants.js";
9
9
  import { MISSING_SETTING_MESSAGE, SiteAuthoringError } from "../errors.js";
10
10
  import { projectFooterSettingsForWorkspace } from "../footer-contract.js";
11
11
  import { computeFooterDraftHash } from "../footer-draft-hash.js";
12
12
  import {
13
13
  advanceFooterManifest,
14
+ clearPendingPresentationSave,
15
+ computePresentationChangeSetHash,
14
16
  readAppearanceWorkspaceContext,
17
+ readPendingPresentationSave,
18
+ readPresentationBaseline,
19
+ recordPendingPresentationSave,
15
20
  requireFooterContentPushed,
16
21
  } from "../footer-workspace.js";
22
+ import { describeJsonDifferences } from "../json-path-diff.js";
17
23
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
18
24
  import {
25
+ projectSettingsGroup,
26
+ SETTINGS_GROUPS,
19
27
  SETTINGS_TYPE_BRAND,
20
28
  SETTINGS_TYPE_SITE_HEADER,
21
29
  SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
22
30
  SETTINGS_TYPE_TAPROOT_STYLES,
23
31
  } from "../settings-catalog.js";
24
32
  import { missingThemeFields, validateAndEncodeThemePair } from "../theme-validation.js";
33
+ import { ApiError } from "../transport.js";
25
34
  import {
26
35
  readWorkspaceJson,
27
36
  SETTINGS_DIRECTORY,
@@ -36,6 +45,15 @@ const SETTINGS_FILES = Object.freeze({
36
45
  [SETTINGS_TYPE_SITE_HEADER]: "site-header.json",
37
46
  [SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]: "site-publishing-preferences.json",
38
47
  });
48
+
49
+ /**
50
+ * The two theme documents travel as writes beside the scalars: the same
51
+ * `(group, setting, value)` triples `SetSetting` accepts, which is what the
52
+ * atomic save takes (TR00807). Order is the appearance registry's, themes
53
+ * last, so the audit row reads the way the old sequential order did.
54
+ */
55
+ const THEME_SETTINGS = Object.freeze(["lightTheme", "darkTheme"]);
56
+
39
57
  function isPlainObject(value) {
40
58
  return value !== null && typeof value === "object" && !Array.isArray(value);
41
59
  }
@@ -142,96 +160,354 @@ export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds
142
160
  return { style, brand, header, publishing, themes, scalarOperations, footerColors };
143
161
  }
144
162
 
163
+ /**
164
+ * The change set the atomic save receives: every appearance scalar in
165
+ * registry order, then the two encoded themes, plus the ten footer colours.
166
+ */
167
+ function buildChangeSet(validated) {
168
+ return {
169
+ settings: [
170
+ ...validated.scalarOperations.map((operation) => ({
171
+ settingsType: operation.settingsType,
172
+ setting: operation.setting,
173
+ value: operation.value,
174
+ })),
175
+ ...THEME_SETTINGS.map((setting) => ({
176
+ settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
177
+ setting,
178
+ value: setting === "lightTheme" ? validated.themes.light : validated.themes.dark,
179
+ })),
180
+ ],
181
+ footerColors: validated.footerColors,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * A Taproot that answers the presentation routes with 404 predates the atomic
187
+ * save. The truthful answer is a refusal that says so: this verb no longer
188
+ * performs the sequential writes an older server would accept, because those
189
+ * are exactly the partial-failure shape the atomic save exists to remove.
190
+ */
191
+ function translateServerSupport(error, action) {
192
+ if (
193
+ error instanceof ApiError
194
+ && error.refusalKind() === REFUSAL_UNCLASSIFIED
195
+ && error.httpStatus === 404
196
+ ) {
197
+ return new SiteAuthoringError(
198
+ "theme.server_unsupported",
199
+ `This Taproot does not serve the atomic presentation ${action}. 'theme push' saves the complete change set in `
200
+ + "one transaction and does not fall back to the older sequential writes; use a Taproot that serves "
201
+ + "/v1/sites/{siteId}/presentation, or an older CLI release against this one.",
202
+ { field: "presentation", status: error.status },
203
+ );
204
+ }
205
+ return error;
206
+ }
207
+
208
+ function translateSaveRefusal(error) {
209
+ if (error instanceof ApiError && error.hasField("ExpectedRevision")) {
210
+ return new SiteAuthoringError(
211
+ "theme.concurrent_modification",
212
+ "The site's presentation changed after this workspace was pulled, so the push was refused and nothing was "
213
+ + "written. Keep copies of the edited settings files, run 'taproot-site pull' to refresh the baseline, "
214
+ + "re-apply the edits, and push again; 'theme push --dry-run' shows what differs.",
215
+ { field: "revision", status: error.status },
216
+ );
217
+ }
218
+ if (error instanceof ApiError && error.hasField("ExpectedFooterDraftHash")) {
219
+ return new SiteAuthoringError(
220
+ "theme.footer_concurrent_modification",
221
+ "The footer document changed between this push's read and its save, so the push was refused and nothing was "
222
+ + "written. Run 'taproot-site theme push' again; it re-reads the footer before saving.",
223
+ { field: "expectedFooterDraftHash", status: error.status },
224
+ );
225
+ }
226
+ return translateServerSupport(error, "save");
227
+ }
228
+
229
+ /**
230
+ * The workspace's copy of exactly the fields the save owns, per settings
231
+ * file, and the site's copy projected the way `pull` writes it — so the paths
232
+ * reported are paths in the files an author edits.
233
+ */
234
+ function ownedDocuments(validated, presentation) {
235
+ const projected = {};
236
+ for (const group of SETTINGS_GROUPS) {
237
+ projected[group.settingsType] = projectSettingsGroup(group, presentation);
238
+ }
239
+ const owned = (settingsType, local, remote, names) => ({
240
+ file: `${SETTINGS_DIRECTORY}/${SETTINGS_FILES[settingsType]}`,
241
+ local: Object.fromEntries(names.map((name) => [name, local[name]])),
242
+ remote: Object.fromEntries(names.map((name) => [name, remote[name]])),
243
+ });
244
+ const scalarNames = (settingsType) =>
245
+ APPEARANCE_SCALAR_FIELDS.filter((field) => field.settingsType === settingsType).map((field) => field.name);
246
+ const footerColors = (footer) => {
247
+ const colors = {};
248
+ for (const definition of APPEARANCE_FOOTER_COLOR_FIELDS) {
249
+ const scheme = isPlainObject(footer?.[definition.scheme]) ? footer[definition.scheme] : {};
250
+ colors[definition.scheme] ??= {};
251
+ colors[definition.scheme][definition.name] = scheme[definition.name] ?? "";
252
+ }
253
+ return { footerSettings: colors };
254
+ };
255
+ return [
256
+ owned(
257
+ SETTINGS_TYPE_TAPROOT_STYLES,
258
+ validated.style,
259
+ projected[SETTINGS_TYPE_TAPROOT_STYLES],
260
+ [...THEME_SETTINGS, ...scalarNames(SETTINGS_TYPE_TAPROOT_STYLES)],
261
+ ),
262
+ owned(SETTINGS_TYPE_BRAND, validated.brand, projected[SETTINGS_TYPE_BRAND], scalarNames(SETTINGS_TYPE_BRAND)),
263
+ owned(
264
+ SETTINGS_TYPE_SITE_HEADER,
265
+ validated.header,
266
+ projected[SETTINGS_TYPE_SITE_HEADER],
267
+ scalarNames(SETTINGS_TYPE_SITE_HEADER),
268
+ ),
269
+ {
270
+ file: `${SETTINGS_DIRECTORY}/${SETTINGS_FILES[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]}`,
271
+ local: footerColors(validated.publishing.footerSettings),
272
+ remote: footerColors(projected[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES].footerSettings),
273
+ },
274
+ ];
275
+ }
276
+
277
+ /** The JSON paths at which each settings file differs from the site, remote first. */
278
+ function describeDifferences(validated, presentation) {
279
+ return ownedDocuments(validated, presentation).map((document_) => {
280
+ const differences = describeJsonDifferences(document_.remote, document_.local);
281
+ return {
282
+ file: document_.file,
283
+ paths: differences.paths,
284
+ ...(differences.truncated ? { truncated: true } : {}),
285
+ };
286
+ });
287
+ }
288
+
145
289
  export async function themePush(invocation) {
146
290
  const session = await openSession(invocation);
147
291
  const { client, config, siteId, onProgress } = session;
292
+ const dryRun = invocation.dryRun === true;
148
293
  // One advisory line before this verb does any work, and only when the
149
294
  // exchange said the platform is paused. It changes nothing else: the write
150
295
  // still runs and its refusal still classifies as platform_paused (TR00692).
151
- warnIfExternalWritesPaused(session, VERB_THEME_PUSH);
296
+ if (!dryRun) warnIfExternalWritesPaused(session, VERB_THEME_PUSH);
152
297
  const appearanceContext = await readAppearanceWorkspaceContext(config.workspaceDir, siteId);
153
- const { publishing, themes, scalarOperations, footerColors } = await validateThemeWorkspace(
298
+ const validated = await validateThemeWorkspace(
154
299
  config.workspaceDir,
155
300
  siteId,
156
301
  appearanceContext.knownImageIds,
157
302
  );
158
- // The fresh footer read below replaces the workspace document, so refuse
303
+ const { publishing, themes, scalarOperations } = validated;
304
+ // The save's response replaces the workspace footer document, so refuse
159
305
  // while it still carries footer-content edits that footer push has not
160
306
  // saved. Colour-only differences are theme push's own to write.
161
307
  requireFooterContentPushed(appearanceContext.footer, publishing.footerSettings);
308
+ const baseline = readPresentationBaseline(appearanceContext.manifest);
309
+ if (!dryRun && baseline === undefined) {
310
+ throw new SiteAuthoringError(
311
+ "theme.pull_required",
312
+ "The pull manifest records no presentation baseline, so this push has no revision to be fenced by. Run "
313
+ + "'taproot-site pull' against a Taproot that serves the atomic presentation save, then retry 'theme push'.",
314
+ { field: "presentation.revision" },
315
+ );
316
+ }
162
317
  onProgress(`Validated the complete light/dark theme pair and ${scalarOperations.length} appearance settings.`);
163
318
  for (const warning of themes.warnings) onProgress(`Espalier warning: ${warning}`);
319
+ const warnings = {
320
+ items: themes.warnings,
321
+ count: themes.warningCount,
322
+ ...(themes.warningsTruncated ? { truncated: true } : {}),
323
+ };
164
324
 
165
- const written = [];
325
+ // The fresh read serves both modes: the dry run compares against it, and
326
+ // the push takes the footer document's draft token from it. Nothing before
327
+ // this point, and nothing in a dry run after it, writes to the site.
328
+ let current;
166
329
  try {
167
- await withRefusalGuidance(onProgress, "theme push", async () => {
168
- // Re-read immediately before the concurrency-protected save and carry only
169
- // the ten workspace color values across. Footer prose, links, and images
170
- // always come from this fresh server document, never the pull snapshot.
171
- const currentPublishing = await getSettingsGroup(
172
- client,
173
- siteId,
174
- SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
330
+ current = await withRefusalGuidance(onProgress, "theme push", async () => await getSitePresentation(client, siteId));
331
+ } catch (error) {
332
+ throw translateServerSupport(error, "read");
333
+ }
334
+ const stale = baseline !== undefined && baseline.revision !== current.revision;
335
+ // A save this workspace sent and never heard back from (TR00807). When the
336
+ // change set about to go is the same one, a moved revision is not evidence
337
+ // of a concurrent edit: it is what that save leaves behind when it
338
+ // committed. Replaying it under the baseline it was sent with lets the site
339
+ // answer already-current, or apply it if it never committed; anything else
340
+ // it refuses itself. A pending record for a different change set proves
341
+ // nothing about the site and takes the ordinary path.
342
+ const changeSet = buildChangeSet(validated);
343
+ const changeSetHash = computePresentationChangeSetHash(changeSet);
344
+ const pending = readPendingPresentationSave(appearanceContext.manifest);
345
+ const replayable = pending !== undefined && pending.changeSetHash === changeSetHash;
346
+ const replaying = stale && replayable;
347
+
348
+ if (dryRun) {
349
+ const differences = describeDifferences(validated, current);
350
+ const changed = differences.some((entry) => entry.paths.length > 0 || entry.truncated === true);
351
+ for (const entry of differences) {
352
+ if (entry.paths.length === 0 && entry.truncated !== true) continue;
353
+ onProgress(`${entry.file} differs at: ${entry.paths.join(", ")}${entry.truncated ? ", and more" : ""}.`);
354
+ }
355
+ if (!changed) onProgress("The workspace's presentation matches the site; a push would change nothing.");
356
+ if (baseline === undefined) {
357
+ onProgress("The pull manifest records no presentation baseline; a push would refuse until the workspace is re-pulled.");
358
+ } else if (replaying) {
359
+ onProgress(
360
+ `The site's presentation revision moved since this workspace was pulled (${baseline.revision} → `
361
+ + `${current.revision}), and the manifest records a save of this same change set whose response was `
362
+ + "lost; a push would replay it under its original baseline rather than refuse.",
175
363
  );
176
- const currentFooter = currentPublishing.sitePublishingPreferences?.footerSettings ?? {};
177
- const nextFooter = applyFooterColors(currentFooter, footerColors);
178
- const footerResponse = await saveSiteFooterSettings(
179
- client,
180
- siteId,
181
- nextFooter,
182
- computeFooterDraftHash(currentFooter),
364
+ } else if (stale) {
365
+ onProgress(
366
+ `The site's presentation revision moved since this workspace was pulled (${baseline.revision} → `
367
+ + `${current.revision}); a push would be refused. Keep copies of the edited files, pull, and re-apply.`,
183
368
  );
184
- written.push("footerSettings.light/dark colors");
185
- if (!isPlainObject(footerResponse.footerSettings)) {
186
- throw new SiteAuthoringError(
187
- "api.footer_settings_contract",
188
- "Taproot returned no normalized footer document after saving the color overlay.",
189
- { field: "footerSettings" },
190
- );
191
- }
192
- const savedFooter = footerResponse.footerSettings;
193
- const projectedFooter = projectFooterSettingsForWorkspace(savedFooter);
194
- await writeWorkspaceJson(
195
- config.workspaceDir,
196
- `${SETTINGS_DIRECTORY}/${SETTINGS_FILES[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]}`,
197
- {
198
- entityId: siteId,
199
- settingsType: SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
200
- settings: { ...publishing, footerSettings: projectedFooter },
369
+ }
370
+ onProgress("Dry run: nothing was written.");
371
+ return successResult(VERB_THEME_PUSH, siteId, {
372
+ dryRun: true,
373
+ revision: {
374
+ ...(baseline === undefined ? {} : { baseline: baseline.revision }),
375
+ current: current.revision,
376
+ stale: baseline === undefined ? undefined : stale,
377
+ },
378
+ ...(pending === undefined ? {} : {
379
+ pendingSave: {
380
+ ...(pending.startedAt === undefined ? {} : { startedAt: pending.startedAt }),
381
+ sameChangeSet: replayable,
201
382
  },
383
+ }),
384
+ changed,
385
+ differences,
386
+ warnings,
387
+ });
388
+ }
389
+
390
+ if (stale && !replaying) {
391
+ // Refused here, before any request that could write, with the same
392
+ // guidance the server's fence would give — and with the paths that
393
+ // differ, which the server cannot know.
394
+ if (pending !== undefined) {
395
+ onProgress(
396
+ "The manifest records an earlier save of a different change set whose response was lost; the site may hold "
397
+ + "it. The pull that refreshes the baseline shows what the site holds now.",
202
398
  );
203
- await advanceFooterManifest(config.workspaceDir, siteId, projectedFooter);
204
- onProgress("Saved the light and dark footer scheme colors over the current footer document.");
205
-
206
- for (const operation of scalarOperations) {
207
- await setSetting(client, siteId, operation.settingsType, operation.setting, operation.value);
208
- written.push(`${operation.settingsType}.${operation.setting}`);
209
- onProgress(`Saved ${operation.setting}.`);
210
- }
211
-
212
- // The pair is locally valid and complete before either mutation starts.
213
- // These writes are last so the first one is also the point at which the
214
- // server marks the theme externally managed and the visual editor adopts
215
- // its guarded mode.
216
- await setSetting(client, siteId, SETTINGS_TYPE_TAPROOT_STYLES, "lightTheme", themes.light);
217
- written.push(`${SETTINGS_TYPE_TAPROOT_STYLES}.lightTheme`);
218
- await setSetting(client, siteId, SETTINGS_TYPE_TAPROOT_STYLES, "darkTheme", themes.dark);
219
- written.push(`${SETTINGS_TYPE_TAPROOT_STYLES}.darkTheme`);
220
- onProgress("Saved both complete themes; Brand & Style now treats the color model as externally managed.");
399
+ }
400
+ const differences = describeDifferences(validated, current);
401
+ throw new SiteAuthoringError(
402
+ "theme.concurrent_modification",
403
+ `The site's presentation changed after this workspace was pulled (revision ${baseline.revision} → `
404
+ + `${current.revision}), so nothing was written. Keep copies of the edited settings files, run `
405
+ + "'taproot-site pull' to refresh the baseline, re-apply the edits, and push again; the paths below name "
406
+ + "where the workspace and the site now differ.",
407
+ { field: "revision", alternatives: differences.filter((entry) => entry.paths.length > 0).map((entry) => entry.file) },
408
+ ).withDifferences(differences.flatMap((entry) => entry.paths.map((path) => `${entry.file}:${path}`)));
409
+ }
410
+
411
+ const expectedRevision = replaying ? pending.expectedRevision : baseline.revision;
412
+ if (replaying) {
413
+ onProgress(
414
+ `Replaying the presentation save recorded${pending.startedAt === undefined ? "" : ` at ${pending.startedAt}`} `
415
+ + "whose response was lost, under the baseline it was sent with: a save that committed is answered as "
416
+ + "already current and nothing is applied twice; one that did not commit is applied.",
417
+ );
418
+ }
419
+ // Recorded before the request so a lost response leaves a record the next
420
+ // run can replay; the manifest write that follows an answer replaces it.
421
+ if (pending?.expectedRevision !== expectedRevision || pending.changeSetHash !== changeSetHash) {
422
+ await recordPendingPresentationSave(config.workspaceDir, siteId, {
423
+ expectedRevision,
424
+ changeSetHash,
425
+ startedAt: new Date(session.now()).toISOString(),
221
426
  });
427
+ }
428
+
429
+ // The footer token comes from the read just made: the server overlays the
430
+ // colours onto the document it holds now. On a replay of a committed save
431
+ // the site is already current and the token is not consulted.
432
+ const currentFooter = current.sitePublishingPreferences?.footerSettings ?? {};
433
+ let saved;
434
+ try {
435
+ saved = await withRefusalGuidance(onProgress, "theme push", async () =>
436
+ await saveSitePresentation(client, siteId, {
437
+ expectedRevision,
438
+ expectedFooterDraftHash: computeFooterDraftHash(currentFooter),
439
+ ...changeSet,
440
+ }));
222
441
  } catch (error) {
223
- if (error instanceof SiteAuthoringError && written.length > 0) {
224
- throw error.withCompletedWrites(written);
442
+ if (error instanceof SiteAuthoringError && error.code === "transport.mutation_ambiguous") {
443
+ onProgress(
444
+ "The save ended without an authoritative response and is recorded as pending. Run 'taproot-site theme "
445
+ + "push' again with the same settings files: a save that committed is answered as already current and "
446
+ + "nothing is applied twice; one that did not is applied.",
447
+ );
448
+ throw translateSaveRefusal(error);
225
449
  }
226
- throw error;
450
+ // Only a fence refusal proves nothing committed; it drops the pending
451
+ // record so a later run compares against the site rather than replaying
452
+ // under a baseline the site already judged. Any other error keeps it: the
453
+ // server commits the presentation before it derives favicon renditions,
454
+ // so an error answered after that commit has moved the revision, and the
455
+ // record is what lets the next run of the same change set be answered as
456
+ // already current instead of refused as someone else's edit.
457
+ if (error instanceof ApiError && (error.hasField("ExpectedRevision") || error.hasField("ExpectedFooterDraftHash"))) {
458
+ await clearPendingPresentationSave(config.workspaceDir, siteId);
459
+ } else {
460
+ onProgress(
461
+ "The save was answered with an error after it may have committed; it stays recorded as pending, and running "
462
+ + "'taproot-site theme push' again with the same settings files settles it either way.",
463
+ );
464
+ }
465
+ throw translateSaveRefusal(error);
466
+ }
467
+
468
+ const { presentation, applied } = saved;
469
+ const savedFooter = presentation.sitePublishingPreferences?.footerSettings;
470
+ if (!isPlainObject(savedFooter)) {
471
+ throw new SiteAuthoringError(
472
+ "api.footer_settings_contract",
473
+ "Taproot returned no footer document with the saved presentation.",
474
+ { field: "footerSettings" },
475
+ );
227
476
  }
477
+ // The publishing document is rewritten from the site's copy — footer prose,
478
+ // links and imagery always come from there — and both baselines advance
479
+ // from the same response. Committed remotely before either local write, so
480
+ // a failure here leaves a stale baseline that refuses the next push rather
481
+ // than one that overwrites the save.
482
+ const projectedFooter = projectFooterSettingsForWorkspace(savedFooter);
483
+ await writeWorkspaceJson(
484
+ config.workspaceDir,
485
+ `${SETTINGS_DIRECTORY}/${SETTINGS_FILES[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]}`,
486
+ {
487
+ entityId: siteId,
488
+ settingsType: SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
489
+ settings: { ...publishing, footerSettings: projectedFooter },
490
+ },
491
+ );
492
+ await advanceFooterManifest(config.workspaceDir, siteId, projectedFooter, undefined, {
493
+ presentationRevision: presentation.revision,
494
+ });
495
+ onProgress(
496
+ applied
497
+ ? "Saved the complete presentation in one transaction: footer scheme colors, appearance settings, and both "
498
+ + "themes; Brand & Style now treats the color model as externally managed."
499
+ : "The site already held this presentation; nothing was written and the baseline was refreshed.",
500
+ );
228
501
 
502
+ const written = [
503
+ "footerSettings.light/dark colors",
504
+ ...scalarOperations.map((operation) => `${operation.settingsType}.${operation.setting}`),
505
+ ...THEME_SETTINGS.map((setting) => `${SETTINGS_TYPE_TAPROOT_STYLES}.${setting}`),
506
+ ];
229
507
  return successResult(VERB_THEME_PUSH, siteId, {
508
+ applied,
509
+ revision: presentation.revision,
230
510
  written: boundedList(written, 100),
231
- warnings: {
232
- items: themes.warnings,
233
- count: themes.warningCount,
234
- ...(themes.warningsTruncated ? { truncated: true } : {}),
235
- },
511
+ warnings,
236
512
  });
237
513
  }