@taprootio/site-authoring 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +106 -13
  2. package/examples/riverbend-wellness/manifest.fixture.json +79 -0
  3. package/examples/riverbend-wellness/nav.json +26 -0
  4. package/examples/riverbend-wellness/pages/index.md +93 -0
  5. package/examples/riverbend-wellness/pages/visit.md +33 -0
  6. package/examples/riverbend-wellness/redirects.json +33 -0
  7. package/examples/riverbend-wellness/settings/brand.json +7 -0
  8. package/examples/riverbend-wellness/settings/site-header.json +21 -0
  9. package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
  10. package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
  11. package/package.json +2 -1
  12. package/src/api.js +203 -3
  13. package/src/cli-release.js +152 -0
  14. package/src/cli.js +120 -11
  15. package/src/constants.js +59 -2
  16. package/src/contact-url.js +63 -0
  17. package/src/content/components.js +26 -4
  18. package/src/content/free-form-section-registry.json +3 -2
  19. package/src/content/inline-facts.js +5 -1
  20. package/src/content/tiptap-prosemirror.ts +26 -12
  21. package/src/credentials.js +39 -4
  22. package/src/errors.js +25 -0
  23. package/src/fixture-contract.js +64 -0
  24. package/src/footer-contract.js +28 -30
  25. package/src/json-path-diff.js +167 -0
  26. package/src/output.js +9 -0
  27. package/src/presentation-reference.js +5 -3
  28. package/src/redirects-contract.js +444 -0
  29. package/src/redirects-workspace.js +72 -0
  30. package/src/reference-help.js +262 -10
  31. package/src/session.js +161 -7
  32. package/src/transport.js +111 -6
  33. package/src/verbs/approve.js +7 -2
  34. package/src/verbs/deploy.js +29 -2
  35. package/src/verbs/footer-push.js +7 -2
  36. package/src/verbs/index.js +7 -1
  37. package/src/verbs/login.js +11 -1
  38. package/src/verbs/media-upload.js +7 -2
  39. package/src/verbs/nav-push.js +17 -11
  40. package/src/verbs/pages-push.js +93 -8
  41. package/src/verbs/preview-page.js +7 -2
  42. package/src/verbs/preview-revoke.js +7 -2
  43. package/src/verbs/pull.js +357 -22
  44. package/src/verbs/redirects-pull.js +90 -0
  45. package/src/verbs/redirects-push.js +100 -0
  46. package/src/verbs/status.js +90 -3
  47. package/src/verbs/theme-push.js +7 -2
  48. package/src/verbs/validate.js +93 -18
  49. package/src/verbs/whoami.js +79 -1
  50. package/src/workspace.js +153 -15
package/src/verbs/pull.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  getNavigation,
3
3
  getPage,
4
4
  getSettingsGroup,
5
+ getSiteRedirectMap,
5
6
  listSitePages,
6
7
  PAGE_STATUS_DELETED,
7
8
  PAGE_STATUS_DRAFT,
@@ -9,9 +10,12 @@ import {
9
10
  TEMPLATE_TYPE_FREE_FORM,
10
11
  withRefusalGuidance,
11
12
  } from "../api.js";
12
- import { REFUSAL_UNCLASSIFIED, VERB_PULL } from "../constants.js";
13
- import { SiteAuthoringError } from "../errors.js";
13
+ import { REFUSAL_CAPABILITY_MISSING, REFUSAL_UNCLASSIFIED, VERB_PULL } from "../constants.js";
14
+ import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
14
15
  import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
16
+ import { describeJsonDifferences, reportableDifferencePaths } from "../json-path-diff.js";
17
+ import { projectRedirectMapForWorkspace, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
18
+ import { redirectsManifestEntry } from "../redirects-workspace.js";
15
19
  import { boundedList, openSession, successResult } from "../session.js";
16
20
  import {
17
21
  projectSettingsGroup,
@@ -29,10 +33,12 @@ import {
29
33
  inspectArtifactSiteBinding,
30
34
  inspectManifestSiteBinding,
31
35
  internalPageBaselineFile,
36
+ internalPageObservedRevisionFile,
32
37
  MANIFEST_FILE_NAME,
33
38
  MANIFEST_VERSION,
34
39
  MEDIA_MANIFEST_FILE_NAME,
35
40
  NAVIGATION_FILE_NAME,
41
+ normalizePageBodyRevision,
36
42
  normalizePagePath,
37
43
  PAGE_READ_ONLY_REASON_SYSTEM_404,
38
44
  PAGE_SOURCE_EXTENSIONS,
@@ -43,6 +49,7 @@ import {
43
49
  pageSourceFormat,
44
50
  pageSourceRegistry,
45
51
  PAGES_DIRECTORY,
52
+ readObservedPageRecord,
46
53
  readWorkspaceFile,
47
54
  readWorkspaceJson,
48
55
  SETTINGS_DIRECTORY,
@@ -94,8 +101,10 @@ import {
94
101
 
95
102
  const MAXIMUM_REPORTED = 200;
96
103
  // A settings group the credential's envelope does not reach is a narrower
97
- // snapshot, not a failed pull. Anything the transport classified as a refusal
98
- // is a different matter and always propagates.
104
+ // snapshot, not a failed pull. The server names that shortfall as a
105
+ // capability denial (TR00691), and an older server answers a bare 403; both
106
+ // mean the same thing here. Any other classified refusal — a rejected
107
+ // credential, the rollout switch, a plan ceiling — always propagates.
99
108
  const UNAVAILABLE_SETTINGS_STATUSES = new Set([403, 404]);
100
109
  const APPEARANCE_SETTINGS_TYPES = Object.freeze([
101
110
  SETTINGS_TYPE_TAPROOT_STYLES,
@@ -126,9 +135,39 @@ function pullConflict(conflicts) {
126
135
  + `was changed, and the site's version is preserved at '${first.baselineFile}'. Either run `
127
136
  + `'taproot-site pages push ${selector}' to make the site match '${first.file}', or delete '${first.file}' `
128
137
  + `and pull again to adopt the site's version as this page's source.`
129
- + (remaining > 0 ? ` ${remaining} other tracked page(s) are in the same state.` : ""),
138
+ + (remaining > 0 ? ` ${remaining} other tracked page(s) are in the same state.` : "")
139
+ // Last, so the diagnostic bounds itself: the message is cut at
140
+ // LIMITS.diagnosticScalars, and twenty long paths must never cut off the
141
+ // recovery the refusal exists to state.
142
+ + ` ${describeDifferenceSummary(first.differences)}`,
130
143
  { field: first.file, alternatives: conflicts.map((entry) => entry.file) },
131
- );
144
+ // Deliberately not `?? []`: an empty list is the claim "compared, and the
145
+ // body is identical", which is what an agent reads as "look at the title,
146
+ // path, or description instead". A comparison that could not be made at all
147
+ // must leave the field absent rather than make that claim.
148
+ ).withDifferences(reportableDifferencePaths(first.differences));
149
+ }
150
+
151
+ /**
152
+ * One sentence naming what actually moved, for the conflict that reports it.
153
+ *
154
+ * "This page changed on the site" is true and useless: on the SHY production
155
+ * home page the whole change was an image's `src` and `urls`, re-projected on
156
+ * every read, and nothing in the refusal said so. The paths do not decide
157
+ * anything — the revision already did — but they are what tells an operator
158
+ * whether the site gained real content or merely a new signed URL.
159
+ */
160
+ function describeDifferenceSummary(differences) {
161
+ if (differences === undefined) {
162
+ return "This workspace kept no copy of the site's previous version, so there is nothing to compare it against.";
163
+ }
164
+ if (differences.paths.length === 0) {
165
+ return differences.truncated
166
+ ? "The two documents were too large to compare completely; no difference was found in the part compared."
167
+ : "The site's document is identical to the copy this workspace last reconciled with, so the change is in the "
168
+ + "page's title, path, or description rather than its body.";
169
+ }
170
+ return `Differs at: ${differences.paths.join(", ")}${differences.truncated ? ", and more" : ""}.`;
132
171
  }
133
172
 
134
173
  /**
@@ -152,6 +191,63 @@ function holdsRemoteDocument(sourceBytes, remoteHash) {
152
191
  }
153
192
  }
154
193
 
194
+ /** A page document, or `undefined` when the bytes are not one. */
195
+ function parsePageDocument(bytes) {
196
+ try {
197
+ const parsed = JSON.parse(bytes.toString("utf8"));
198
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
199
+ } catch {
200
+ return undefined;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * The document this workspace last reconciled with, if it still holds one.
206
+ *
207
+ * Two places can hold it. Internal state holds it for a page whose own source
208
+ * could not be rewritten from the site's document — every Markdown-tracked
209
+ * page, and any ProseMirror source carrying unpushed edits. For a ProseMirror
210
+ * source pull last refreshed, the source file *is* what pull wrote, so it is
211
+ * the previous version; but only while it has not been edited since, because
212
+ * an edited file is a mixture of local and remote work and diffing it would
213
+ * attribute the author's own changes to the site.
214
+ *
215
+ * Every failure is "nothing to compare against", the way
216
+ * `readObservedPageRevision` treats damaged internal state. This read exists to
217
+ * make a message more legible and decides nothing; pull writes the preserved
218
+ * body pretty-printed and under no per-file cap of its own, so a body large
219
+ * enough to trip `documentBytes` on the way back in is reachable — and a throw
220
+ * there would replace the `pages.pull_conflict` the caller is raising (skipping
221
+ * the observed-revision write that keeps the recovery push reachable), or fail
222
+ * an ordinary non-conflicting refresh outright. The conflict is still raised,
223
+ * and reports that this workspace kept no copy of the previous version.
224
+ */
225
+ async function readPreviousRemoteDocument({ workspaceDir, baselineFile, sourceBytes, markdown, localChanged }) {
226
+ try {
227
+ if (await workspaceFileExists(workspaceDir, baselineFile)) {
228
+ const bytes = await readWorkspaceFile(workspaceDir, baselineFile, WORKSPACE_LIMITS.documentBytes);
229
+ const preserved = parsePageDocument(bytes);
230
+ if (preserved !== undefined) return preserved;
231
+ }
232
+ } catch {
233
+ return undefined;
234
+ }
235
+ if (markdown || localChanged) return undefined;
236
+ return parsePageDocument(sourceBytes);
237
+ }
238
+
239
+ /** The JSON paths at which the site's document moved, when that is knowable. */
240
+ async function describeRemoteMovement({ workspaceDir, baselineFile, sourceBytes, markdown, localChanged, body }) {
241
+ const previous = await readPreviousRemoteDocument({
242
+ workspaceDir,
243
+ baselineFile,
244
+ sourceBytes,
245
+ markdown,
246
+ localChanged,
247
+ });
248
+ return previous === undefined ? undefined : describeJsonDifferences(previous, body);
249
+ }
250
+
155
251
  function bodyStatusFor(summary) {
156
252
  if (summary.hasDraft) return PAGE_STATUS_DRAFT;
157
253
  if (summary.status !== PAGE_STATUS_UNKNOWN) return summary.status;
@@ -294,6 +390,9 @@ async function resolveTrackedSources(workspaceDir, pages, registry, onProgress)
294
390
  * and honoring it is what repairs an existing workspace in one pull instead of
295
391
  * by hand. Every other verb keeps refusing an old manifest outright — the site
296
392
  * binding is checked before this runs, so a foreign manifest never reaches it.
393
+ *
394
+ * What it does *not* honor is a comparison recorded under the superseded
395
+ * scheme; see `withoutSupersededRemoteHashes`.
297
396
  */
298
397
  async function readSourceRegistry(workspaceDir, siteId) {
299
398
  if (!await workspaceFileExists(workspaceDir, MANIFEST_FILE_NAME)) return new Map();
@@ -306,7 +405,64 @@ async function readSourceRegistry(workspaceDir, siteId) {
306
405
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.siteId !== siteId) {
307
406
  return new Map();
308
407
  }
309
- return pageSourceRegistry(parsed);
408
+ const registry = pageSourceRegistry(parsed);
409
+ return parsed.manifestVersion === MANIFEST_VERSION ? registry : withoutSupersededRemoteHashes(registry);
410
+ }
411
+
412
+ /**
413
+ * Forgets `baseline.remoteHash` on every entry of a registry read out of a
414
+ * manifest that is not this version's.
415
+ *
416
+ * The hash answers "did the site's projected body change", which is the
417
+ * question version 6 stopped asking: the projection moves on its own, and the
418
+ * SHY home page's clean deploy is what proved it. On the first pull after the
419
+ * upgrade there is no recorded revision yet, so the comparison would fall back
420
+ * to that hash — and for a Markdown page, which is always kept and therefore
421
+ * always conflicts on a remote change, a projection that had moved in the
422
+ * meantime would refuse the pull *and* write no manifest. The documented
423
+ * recovery, `pages push`, reads the manifest strictly and refuses a superseded
424
+ * `manifestVersion` telling the operator to pull again, so the two verbs would
425
+ * send each other in a circle with no way out but deleting the source.
426
+ *
427
+ * Dropping the hash makes the migration pull establish the revision baseline
428
+ * instead, which is what every later pull compares. `sourceHash` is kept: it
429
+ * answers a question that has not changed — whether the author has edited a
430
+ * pulled ProseMirror source since — and losing it would let this same pull
431
+ * refresh the file over unpushed work.
432
+ *
433
+ * This discards state; it does not migrate any. An older manifest is still
434
+ * fully replaced by the pull that reads it.
435
+ */
436
+ function withoutSupersededRemoteHashes(registry) {
437
+ for (const record of registry.values()) {
438
+ const baseline = record.baseline;
439
+ if (baseline?.remoteHash === undefined) continue;
440
+ const kept = {
441
+ ...(baseline.sourceHash === undefined ? {} : { sourceHash: baseline.sourceHash }),
442
+ ...(baseline.revision === undefined ? {} : { revision: baseline.revision }),
443
+ };
444
+ record.baseline = Object.keys(kept).length === 0 ? undefined : kept;
445
+ }
446
+ return registry;
447
+ }
448
+
449
+ /**
450
+ * The page read is the same response that supplies the body and the
451
+ * revision, so its title and path are the ones that revision describes. The
452
+ * listing's are one round trip older: a rename that landed in between would
453
+ * otherwise be recorded beside the newer revision, and the next push would
454
+ * send the old title or path against a revision that matches — reverting the
455
+ * rename with no conflict to report (TR00643).
456
+ */
457
+ function titleFromRead(page, summary) {
458
+ // Stripped the way the listing's title is (`roundTrippedText` in api.js):
459
+ // control and bidi characters are terminal and diff safety, not fidelity.
460
+ return typeof page.title === "string" ? sanitizeDiagnostic(page.title, "") : summary.title;
461
+ }
462
+
463
+ function pathFromRead(page, summary) {
464
+ const normalized = typeof page.path === "string" ? normalizePagePath(page.path) : undefined;
465
+ return normalized === undefined ? summary.path : normalized;
310
466
  }
311
467
 
312
468
  function freeFormBody(page) {
@@ -386,8 +542,8 @@ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
386
542
  PAGES_DIRECTORY,
387
543
  );
388
544
  }
389
- // These two do carry a site, so they can clear themselves.
390
- for (const artifact of [NAVIGATION_FILE_NAME, MEDIA_MANIFEST_FILE_NAME]) {
545
+ // These three do carry a site, so they can clear themselves.
546
+ for (const artifact of [NAVIGATION_FILE_NAME, MEDIA_MANIFEST_FILE_NAME, REDIRECTS_FILE_NAME]) {
391
547
  const artifactBinding = await inspectArtifactSiteBinding(workspaceDir, artifact);
392
548
  if (artifactBinding.state === "absent") continue;
393
549
  if (artifactBinding.state === "bound" && artifactBinding.siteId === siteId) continue;
@@ -418,6 +574,31 @@ export async function pull(invocation) {
418
574
  const tracked = await resolveTrackedSources(config.workspaceDir, freeForm, registry, onProgress);
419
575
  const pageFiles = assignPageFiles(freeForm, tracked);
420
576
 
577
+ // The redirect map, with the revision a later push is fenced by (TR00702).
578
+ // Read here, before the first workspace write, for the same reason the
579
+ // settings groups below are: a failure must strand nothing. A Taproot that
580
+ // predates the map answers the route with 404; that pull then records no
581
+ // redirect baseline, and `redirects push` asks for a pull against a site
582
+ // that has one rather than guessing.
583
+ onProgress("Reading the redirect map.");
584
+ let redirectMap;
585
+ try {
586
+ redirectMap = await getSiteRedirectMap(client, siteId);
587
+ } catch (error) {
588
+ if (
589
+ error instanceof ApiError
590
+ && error.refusalKind() === REFUSAL_UNCLASSIFIED
591
+ && error.httpStatus === 404
592
+ ) {
593
+ onProgress(
594
+ `This Taproot does not serve a redirect map yet; ${REDIRECTS_FILE_NAME} is not written and the manifest `
595
+ + "records no redirect baseline.",
596
+ );
597
+ } else {
598
+ throw error;
599
+ }
600
+ }
601
+
421
602
  // Project every settings response before the first workspace write. Theme
422
603
  // decoding is a wire-contract check and can fail on malformed stored data;
423
604
  // discovering that after page bodies were written would strand an
@@ -435,7 +616,8 @@ export async function pull(invocation) {
435
616
  } catch (error) {
436
617
  if (
437
618
  error instanceof ApiError
438
- && error.refusalKind() === REFUSAL_UNCLASSIFIED
619
+ && (error.refusalKind() === REFUSAL_UNCLASSIFIED
620
+ || error.refusalKind() === REFUSAL_CAPABILITY_MISSING)
439
621
  && UNAVAILABLE_SETTINGS_STATUSES.has(error.httpStatus)
440
622
  ) {
441
623
  onProgress(`Settings group ${group.settingsType} is not readable by this credential; skipping it.`);
@@ -482,7 +664,13 @@ export async function pull(invocation) {
482
664
  // page's one source. Dropping it from the registry would let the next
483
665
  // pull mint the competing document all over again.
484
666
  onProgress(`Page '${summary.path}' has no readable free-form body; keeping '${source.file}' as its source.`);
485
- trackedPlans.set(summary.pageId, { description, file: source.file, sourceFormat: source.sourceFormat });
667
+ trackedPlans.set(summary.pageId, {
668
+ description,
669
+ title: titleFromRead(page, summary),
670
+ path: pathFromRead(page, summary),
671
+ file: source.file,
672
+ sourceFormat: source.sourceFormat,
673
+ });
486
674
  continue;
487
675
  }
488
676
  const baselineFile = internalPageBaselineFile(summary.pageId);
@@ -510,7 +698,29 @@ export async function pull(invocation) {
510
698
  const remoteHash = canonicalDocumentHash(body);
511
699
  const sourceBytes = await readWorkspaceFile(config.workspaceDir, source.file, WORKSPACE_LIMITS.documentBytes);
512
700
  const sourceHash = workspaceContentHash(sourceBytes);
513
- const remoteChanged = source.baseline?.remoteHash !== undefined && source.baseline.remoteHash !== remoteHash;
701
+ const revision = normalizePageBodyRevision(page.bodyRevision);
702
+ // The revision is the site's own statement about its stored state, so it
703
+ // moves for a real edit to the body, title, path, or description and for
704
+ // nothing else. The body hash cannot make that distinction: every read
705
+ // re-projects image delivery and re-serializes component data, so a
706
+ // signer rotation or a changed image reads as an edit — which is exactly
707
+ // what refused the SHY home page's pull after a clean deploy. So the
708
+ // hash is consulted only where there is no revision to compare: a
709
+ // Taproot that predates the revision contract, where it is still strictly
710
+ // better than assuming nothing moved.
711
+ //
712
+ // A site that *now* reports a revision establishes the revision baseline
713
+ // instead of comparing a superseded hash, even when the manifest recorded
714
+ // one. That manifest is this version's — `withoutSupersededRemoteHashes`
715
+ // never touched it — but its hash was recorded against a Taproot that had
716
+ // no revision to give, so it is the same superseded projected-body hash,
717
+ // and honoring it would refuse forever every Markdown page whose image
718
+ // projection moved across the API deploy. This costs one pull's detection
719
+ // at the upgrade boundary, the same trade `withoutSupersededRemoteHashes`
720
+ // accepts.
721
+ const remoteChanged = revision !== undefined
722
+ ? source.baseline?.revision !== undefined && source.baseline.revision !== revision
723
+ : source.baseline?.remoteHash !== undefined && source.baseline.remoteHash !== remoteHash;
514
724
  // Markdown is one-way, so a Markdown source can never be rewritten from
515
725
  // the site's document and is always kept. A ProseMirror source can be
516
726
  // refreshed — but only over content the author has not edited since the
@@ -539,6 +749,8 @@ export async function pull(invocation) {
539
749
  const conflicted = markdown ? remoteChanged : localChanged && remoteChanged;
540
750
  trackedPlans.set(summary.pageId, {
541
751
  description,
752
+ title: titleFromRead(page, summary),
753
+ path: pathFromRead(page, summary),
542
754
  file: source.file,
543
755
  sourceFormat: source.sourceFormat,
544
756
  // `sourceHash` records the source as of the last time it *agreed* with
@@ -550,17 +762,80 @@ export async function pull(invocation) {
550
762
  // carries its previous hash forward until a push records a new one or
551
763
  // a remote change turns the divergence into a conflict.
552
764
  baseline: keepLocal
553
- ? { remoteHash, sourceHash: localChanged ? source.baseline?.sourceHash : sourceHash }
554
- : { remoteHash, sourceHash: workspaceContentHash(serialized) },
765
+ // The revision records what this workspace has reconciled with, and
766
+ // a conflicted page reconciles with nothing: this manifest is never
767
+ // written, because the refusal below is raised before it is. That is
768
+ // what keeps the same conflict coming back until somebody resolves
769
+ // it, rather than the site's new version being adopted by a pull
770
+ // that refused to adopt it.
771
+ ? {
772
+ remoteHash,
773
+ sourceHash: localChanged ? source.baseline?.sourceHash : sourceHash,
774
+ ...(revision === undefined ? {} : { revision }),
775
+ }
776
+ : {
777
+ remoteHash,
778
+ sourceHash: workspaceContentHash(serialized),
779
+ ...(revision === undefined ? {} : { revision }),
780
+ },
555
781
  ...(keepLocal ? { baselineBody: serialized, baselineFile } : { refresh: serialized, baselineFile }),
556
782
  });
557
- if (conflicted) {
558
- conflicts.push({
559
- pagePath: normalizePagePath(summary.path) ?? summary.path,
560
- file: source.file,
561
- baselineFile,
562
- localChanged,
563
- });
783
+ if (remoteChanged) {
784
+ // A refusal for this same version already worked this out, against a
785
+ // baseline it then overwrote with the very version being compared.
786
+ // Recomputing now would compare that version with itself, find nothing,
787
+ // and report "the change is in the page's title, path, or description"
788
+ // for what was a body edit. So a record naming exactly this version is
789
+ // authoritative about what the operator was last shown — including its
790
+ // having been unable to tell — and a page that has moved *again* names
791
+ // a different one and is compared afresh, against the version that
792
+ // refusal preserved.
793
+ //
794
+ // "This version" is the revision where the site reports one, and the
795
+ // canonical body hash where it does not. Against a Taproot that
796
+ // predates the revision contract — which the published CLI meets
797
+ // whenever it runs ahead of the deployed API — the hash is the whole of
798
+ // what the fallback comparison above is made against, so it is equally
799
+ // the thing a record has to name.
800
+ const observed = await readObservedPageRecord(config.workspaceDir, summary.pageId);
801
+ const alreadyShown = observed !== undefined
802
+ && (revision === undefined
803
+ ? observed.revision === undefined && observed.remoteHash === remoteHash
804
+ : observed.revision === revision);
805
+ // Computed here, while both documents are in hand and before the
806
+ // preserved baseline below is overwritten with the new one — after
807
+ // that write the previous version is gone and nothing could say what
808
+ // moved.
809
+ const differences = alreadyShown
810
+ ? observed.differences
811
+ : await describeRemoteMovement({
812
+ workspaceDir: config.workspaceDir,
813
+ baselineFile,
814
+ sourceBytes,
815
+ markdown,
816
+ localChanged,
817
+ body,
818
+ });
819
+ // Sanitized as one message: the page path is server data and the paths
820
+ // are drawn from an untrusted document, and the same strings reach
821
+ // `error.differences` de-fanged. A progress line is the one place they
822
+ // would otherwise reach a terminal raw.
823
+ onProgress(
824
+ sanitizeDiagnostic(`Page '${summary.path}' moved on the site. ${describeDifferenceSummary(differences)}`),
825
+ );
826
+ if (conflicted) {
827
+ conflicts.push({
828
+ pageId: summary.pageId,
829
+ pagePath: normalizePagePath(summary.path) ?? summary.path,
830
+ file: source.file,
831
+ baselineFile,
832
+ localChanged,
833
+ differences,
834
+ observedRevision: revision,
835
+ observedRemoteHash: remoteHash,
836
+ observedRevisionFile: internalPageObservedRevisionFile(summary.pageId),
837
+ });
838
+ }
564
839
  } else if (keepLocal && localChanged) {
565
840
  // Two different situations, and saying "kept your edits" for both
566
841
  // would be a guess dressed as a fact: with a recorded hash the
@@ -584,7 +859,35 @@ export async function pull(invocation) {
584
859
  if (plan.baselineBody === undefined) continue;
585
860
  await writeWorkspaceFile(config.workspaceDir, plan.baselineFile, plan.baselineBody);
586
861
  }
587
- if (conflicts.length > 0) throw pullConflict(conflicts);
862
+ if (conflicts.length > 0) {
863
+ // Recorded beside the preserved body, and only for a page this pull is
864
+ // about to refuse. It says "the operator has been shown this version",
865
+ // which is what keeps the refusal's own recovery — push the local source
866
+ // to make the site match it — reachable without weakening the push
867
+ // refusal for a page that moved while nobody was looking.
868
+ //
869
+ // The differences ride along because the write above has just replaced
870
+ // the only copy they could be computed from. Without them the second
871
+ // refusal for an unchanged version would carry no `differences` at all
872
+ // and claim the change was not in the body.
873
+ //
874
+ // Written for every conflict, not only for a site that reports a
875
+ // revision. A site that does not is compared by body hash, and skipping
876
+ // the record there left the second refusal recomputing against the copy
877
+ // the first one overwrote — finding no difference, and reporting a body
878
+ // edit as a metadata change every time.
879
+ for (const conflict of conflicts) {
880
+ if (conflict.observedRevisionFile === undefined) continue;
881
+ await writeWorkspaceJson(config.workspaceDir, conflict.observedRevisionFile, {
882
+ pageId: conflict.pageId,
883
+ ...(conflict.observedRevision === undefined
884
+ ? { remoteHash: conflict.observedRemoteHash }
885
+ : { revision: conflict.observedRevision }),
886
+ ...(conflict.differences === undefined ? {} : { differences: conflict.differences }),
887
+ });
888
+ }
889
+ throw pullConflict(conflicts);
890
+ }
588
891
 
589
892
  // Phase two: everything else, plus the tracked refreshes cleared above.
590
893
  const manifestPages = [];
@@ -602,9 +905,19 @@ export async function pull(invocation) {
602
905
  workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
603
906
  };
604
907
  if (summary.templateType === TEMPLATE_TYPE_FREE_FORM) {
908
+ // This pull is reconciling the page, so the manifest below carries the
909
+ // newer and stronger claim and the "what the operator was shown" record
910
+ // is spent. Leaving it would let a push override a remote change this
911
+ // workspace has since caught up with.
912
+ const observedRevisionFile = internalPageObservedRevisionFile(summary.pageId);
913
+ if (observedRevisionFile !== undefined) {
914
+ await deleteWorkspaceFile(config.workspaceDir, observedRevisionFile);
915
+ }
605
916
  const plan = trackedPlans.get(summary.pageId);
606
917
  if (plan !== undefined) {
607
918
  entry.description = plan.description;
919
+ if (plan.title !== undefined) entry.title = plan.title;
920
+ if (plan.path !== undefined) entry.path = plan.path;
608
921
  if (plan.file !== undefined) {
609
922
  entry.file = plan.file;
610
923
  entry.sourceFormat = plan.sourceFormat;
@@ -628,6 +941,8 @@ export async function pull(invocation) {
628
941
  const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
629
942
  const body = freeFormBody(page);
630
943
  entry.description = typeof page.shortDescription === "string" ? page.shortDescription : "";
944
+ entry.title = titleFromRead(page, summary);
945
+ entry.path = pathFromRead(page, summary);
631
946
  if (body === undefined) {
632
947
  // The server accepts and stores a body it never validates, so an
633
948
  // unreadable one is a real state. It is reported rather than written
@@ -646,9 +961,11 @@ export async function pull(invocation) {
646
961
  } else {
647
962
  entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
648
963
  entry.sourceFormat = pageSourceFormat(entry.file);
964
+ const revision = normalizePageBodyRevision(page.bodyRevision);
649
965
  entry.baseline = {
650
966
  remoteHash: canonicalDocumentHash(body),
651
967
  sourceHash: workspaceContentHash(source),
968
+ ...(revision === undefined ? {} : { revision }),
652
969
  };
653
970
  }
654
971
  await writeWorkspaceFile(config.workspaceDir, entry.file, source);
@@ -664,6 +981,14 @@ export async function pull(invocation) {
664
981
  const navItems = await getNavigation(client, siteId);
665
982
  await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, { siteId, navItems });
666
983
 
984
+ if (redirectMap !== undefined) {
985
+ await writeWorkspaceJson(
986
+ config.workspaceDir,
987
+ REDIRECTS_FILE_NAME,
988
+ projectRedirectMapForWorkspace(siteId, redirectMap),
989
+ );
990
+ }
991
+
667
992
  for (const document of settingsDocuments) {
668
993
  await writeWorkspaceJson(config.workspaceDir, document.file, document.value);
669
994
  }
@@ -674,6 +999,7 @@ export async function pull(invocation) {
674
999
  pulledAt: new Date(now()).toISOString(),
675
1000
  pagesTruncated: truncated,
676
1001
  navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
1002
+ ...(redirectMap === undefined ? {} : { redirects: redirectsManifestEntry(redirectMap) }),
677
1003
  settings: pulledSettings,
678
1004
  settingsSkipped: skippedSettings,
679
1005
  ...(pulledFooter === undefined ? {} : { footer: footerManifestEntry(pulledFooter) }),
@@ -717,6 +1043,15 @@ export async function pull(invocation) {
717
1043
  ...(reported.truncated ? { itemsTruncated: true } : {}),
718
1044
  },
719
1045
  navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
1046
+ // Absent, not zeroed, when the site served no map: an agent reading
1047
+ // `entries: 0` would take the site for one with no redirects.
1048
+ ...(redirectMap === undefined ? {} : {
1049
+ redirects: {
1050
+ file: REDIRECTS_FILE_NAME,
1051
+ revision: redirectMap.revision,
1052
+ entries: redirectMap.entries.length,
1053
+ },
1054
+ }),
720
1055
  settings: {
721
1056
  pulled: pulledSettings.map((entry) => entry.settingsType),
722
1057
  skipped: skippedSettings,
@@ -0,0 +1,90 @@
1
+ import { getSiteRedirectMap, withRefusalGuidance } from "../api.js";
2
+ import { REFUSAL_UNCLASSIFIED, VERB_REDIRECTS_PULL } from "../constants.js";
3
+ import { SiteAuthoringError } from "../errors.js";
4
+ import {
5
+ projectRedirectMapForWorkspace,
6
+ REDIRECT_KIND_GONE,
7
+ REDIRECT_ORIGIN_AUTHORED,
8
+ REDIRECTS_FILE_NAME,
9
+ } from "../redirects-contract.js";
10
+ import { redirectsManifestEntry } from "../redirects-workspace.js";
11
+ import { boundedList, openSession, successResult } from "../session.js";
12
+ import { ApiError } from "../transport.js";
13
+ import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
14
+
15
+ const MAXIMUM_REPORTED = 200;
16
+
17
+ /**
18
+ * `redirects pull` — snapshot the site's whole redirect map.
19
+ *
20
+ * It writes `redirects.json` and records the map's revision in the pull
21
+ * manifest, because a whole-map push has to be fenced against the state it was
22
+ * built from: the map holds entries nobody typed — one per page or tag rename —
23
+ * and a stale replace would delete them.
24
+ *
25
+ * It is a verb of its own as well as part of `pull` so an agent can re-read
26
+ * just the map after a rename, or after a conflicting push, without re-pulling
27
+ * every page.
28
+ */
29
+ export async function redirectsPull(invocation) {
30
+ const { client, config, siteId, onProgress } = await openSession(invocation);
31
+ // The manifest is required rather than optional: without one there is no
32
+ // pulled workspace for this file to belong to, and the same refusal `pages
33
+ // push` gives is the right one here.
34
+ const manifest = await readManifest(config.workspaceDir, siteId);
35
+
36
+ const map = await withRefusalGuidance(
37
+ onProgress,
38
+ "redirects pull",
39
+ async () => await readRedirectMapOrRefuse(client, siteId),
40
+ );
41
+
42
+ const document_ = projectRedirectMapForWorkspace(siteId, map);
43
+ await writeWorkspaceJson(config.workspaceDir, REDIRECTS_FILE_NAME, document_);
44
+ manifest.redirects = redirectsManifestEntry(map);
45
+ await writeManifest(config.workspaceDir, manifest);
46
+ onProgress(
47
+ `Wrote ${REDIRECTS_FILE_NAME} with ${map.entries.length} entr${map.entries.length === 1 ? "y" : "ies"} `
48
+ + "and recorded the map revision.",
49
+ );
50
+
51
+ const authored = map.entries.filter((entry) => entry.origin === REDIRECT_ORIGIN_AUTHORED).length;
52
+ const reported = boundedList(document_.entries, MAXIMUM_REPORTED);
53
+ return successResult(VERB_REDIRECTS_PULL, siteId, {
54
+ redirectsFile: REDIRECTS_FILE_NAME,
55
+ revision: map.revision,
56
+ redirects: {
57
+ total: map.entries.length,
58
+ // Split so an agent can tell what it declared from what a rename
59
+ // recorded, which is the difference between "mine to edit" and "the
60
+ // site's own history, delete it only on purpose".
61
+ authored,
62
+ pathHistory: map.entries.length - authored,
63
+ gone: map.entries.filter((entry) => entry.kind === REDIRECT_KIND_GONE).length,
64
+ items: reported.items,
65
+ ...(reported.truncated ? { itemsTruncated: true } : {}),
66
+ },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * A Taproot that predates the redirect map answers the route with a bare 404.
72
+ * Named here rather than surfaced as an unclassified status, because the
73
+ * refusal `redirects push` gives for a missing baseline sends the operator to
74
+ * this verb (TR00702).
75
+ */
76
+ async function readRedirectMapOrRefuse(client, siteId) {
77
+ try {
78
+ return await getSiteRedirectMap(client, siteId);
79
+ } catch (error) {
80
+ if (error instanceof ApiError && error.refusalKind() === REFUSAL_UNCLASSIFIED && error.httpStatus === 404) {
81
+ throw new SiteAuthoringError(
82
+ "redirects.not_served",
83
+ "This Taproot does not serve a redirect map yet. Wait for the deploy that adds it, then run "
84
+ + "'taproot-site redirects pull' again.",
85
+ { status: error.status },
86
+ );
87
+ }
88
+ throw error;
89
+ }
90
+ }