@taprootio/site-authoring 0.3.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.
- package/README.md +81 -9
- package/examples/riverbend-wellness/manifest.fixture.json +6 -1
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/package.json +1 -1
- package/src/api.js +175 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +70 -5
- package/src/constants.js +37 -1
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +1 -0
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +122 -5
- package/src/session.js +161 -7
- package/src/transport.js +23 -5
- package/src/verbs/approve.js +7 -2
- package/src/verbs/deploy.js +29 -2
- package/src/verbs/footer-push.js +7 -2
- package/src/verbs/index.js +7 -1
- package/src/verbs/login.js +11 -1
- package/src/verbs/media-upload.js +7 -2
- package/src/verbs/nav-push.js +7 -2
- package/src/verbs/pages-push.js +93 -8
- package/src/verbs/preview-page.js +7 -2
- package/src/verbs/preview-revoke.js +7 -2
- package/src/verbs/pull.js +350 -18
- package/src/verbs/redirects-pull.js +90 -0
- package/src/verbs/redirects-push.js +100 -0
- package/src/verbs/status.js +90 -3
- package/src/verbs/theme-push.js +7 -2
- package/src/verbs/validate.js +74 -0
- package/src/verbs/whoami.js +79 -1
- 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,
|
|
@@ -10,8 +11,11 @@ import {
|
|
|
10
11
|
withRefusalGuidance,
|
|
11
12
|
} from "../api.js";
|
|
12
13
|
import { REFUSAL_CAPABILITY_MISSING, REFUSAL_UNCLASSIFIED, VERB_PULL } from "../constants.js";
|
|
13
|
-
import { SiteAuthoringError } from "../errors.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,
|
|
@@ -128,9 +135,39 @@ function pullConflict(conflicts) {
|
|
|
128
135
|
+ `was changed, and the site's version is preserved at '${first.baselineFile}'. Either run `
|
|
129
136
|
+ `'taproot-site pages push ${selector}' to make the site match '${first.file}', or delete '${first.file}' `
|
|
130
137
|
+ `and pull again to adopt the site's version as this page's source.`
|
|
131
|
-
+ (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)}`,
|
|
132
143
|
{ field: first.file, alternatives: conflicts.map((entry) => entry.file) },
|
|
133
|
-
|
|
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" : ""}.`;
|
|
134
171
|
}
|
|
135
172
|
|
|
136
173
|
/**
|
|
@@ -154,6 +191,63 @@ function holdsRemoteDocument(sourceBytes, remoteHash) {
|
|
|
154
191
|
}
|
|
155
192
|
}
|
|
156
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
|
+
|
|
157
251
|
function bodyStatusFor(summary) {
|
|
158
252
|
if (summary.hasDraft) return PAGE_STATUS_DRAFT;
|
|
159
253
|
if (summary.status !== PAGE_STATUS_UNKNOWN) return summary.status;
|
|
@@ -296,6 +390,9 @@ async function resolveTrackedSources(workspaceDir, pages, registry, onProgress)
|
|
|
296
390
|
* and honoring it is what repairs an existing workspace in one pull instead of
|
|
297
391
|
* by hand. Every other verb keeps refusing an old manifest outright — the site
|
|
298
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`.
|
|
299
396
|
*/
|
|
300
397
|
async function readSourceRegistry(workspaceDir, siteId) {
|
|
301
398
|
if (!await workspaceFileExists(workspaceDir, MANIFEST_FILE_NAME)) return new Map();
|
|
@@ -308,7 +405,64 @@ async function readSourceRegistry(workspaceDir, siteId) {
|
|
|
308
405
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.siteId !== siteId) {
|
|
309
406
|
return new Map();
|
|
310
407
|
}
|
|
311
|
-
|
|
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;
|
|
312
466
|
}
|
|
313
467
|
|
|
314
468
|
function freeFormBody(page) {
|
|
@@ -388,8 +542,8 @@ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
|
|
|
388
542
|
PAGES_DIRECTORY,
|
|
389
543
|
);
|
|
390
544
|
}
|
|
391
|
-
// These
|
|
392
|
-
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]) {
|
|
393
547
|
const artifactBinding = await inspectArtifactSiteBinding(workspaceDir, artifact);
|
|
394
548
|
if (artifactBinding.state === "absent") continue;
|
|
395
549
|
if (artifactBinding.state === "bound" && artifactBinding.siteId === siteId) continue;
|
|
@@ -420,6 +574,31 @@ export async function pull(invocation) {
|
|
|
420
574
|
const tracked = await resolveTrackedSources(config.workspaceDir, freeForm, registry, onProgress);
|
|
421
575
|
const pageFiles = assignPageFiles(freeForm, tracked);
|
|
422
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
|
+
|
|
423
602
|
// Project every settings response before the first workspace write. Theme
|
|
424
603
|
// decoding is a wire-contract check and can fail on malformed stored data;
|
|
425
604
|
// discovering that after page bodies were written would strand an
|
|
@@ -485,7 +664,13 @@ export async function pull(invocation) {
|
|
|
485
664
|
// page's one source. Dropping it from the registry would let the next
|
|
486
665
|
// pull mint the competing document all over again.
|
|
487
666
|
onProgress(`Page '${summary.path}' has no readable free-form body; keeping '${source.file}' as its source.`);
|
|
488
|
-
trackedPlans.set(summary.pageId, {
|
|
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
|
+
});
|
|
489
674
|
continue;
|
|
490
675
|
}
|
|
491
676
|
const baselineFile = internalPageBaselineFile(summary.pageId);
|
|
@@ -513,7 +698,29 @@ export async function pull(invocation) {
|
|
|
513
698
|
const remoteHash = canonicalDocumentHash(body);
|
|
514
699
|
const sourceBytes = await readWorkspaceFile(config.workspaceDir, source.file, WORKSPACE_LIMITS.documentBytes);
|
|
515
700
|
const sourceHash = workspaceContentHash(sourceBytes);
|
|
516
|
-
const
|
|
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;
|
|
517
724
|
// Markdown is one-way, so a Markdown source can never be rewritten from
|
|
518
725
|
// the site's document and is always kept. A ProseMirror source can be
|
|
519
726
|
// refreshed — but only over content the author has not edited since the
|
|
@@ -542,6 +749,8 @@ export async function pull(invocation) {
|
|
|
542
749
|
const conflicted = markdown ? remoteChanged : localChanged && remoteChanged;
|
|
543
750
|
trackedPlans.set(summary.pageId, {
|
|
544
751
|
description,
|
|
752
|
+
title: titleFromRead(page, summary),
|
|
753
|
+
path: pathFromRead(page, summary),
|
|
545
754
|
file: source.file,
|
|
546
755
|
sourceFormat: source.sourceFormat,
|
|
547
756
|
// `sourceHash` records the source as of the last time it *agreed* with
|
|
@@ -553,17 +762,80 @@ export async function pull(invocation) {
|
|
|
553
762
|
// carries its previous hash forward until a push records a new one or
|
|
554
763
|
// a remote change turns the divergence into a conflict.
|
|
555
764
|
baseline: keepLocal
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
},
|
|
558
781
|
...(keepLocal ? { baselineBody: serialized, baselineFile } : { refresh: serialized, baselineFile }),
|
|
559
782
|
});
|
|
560
|
-
if (
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
+
}
|
|
567
839
|
} else if (keepLocal && localChanged) {
|
|
568
840
|
// Two different situations, and saying "kept your edits" for both
|
|
569
841
|
// would be a guess dressed as a fact: with a recorded hash the
|
|
@@ -587,7 +859,35 @@ export async function pull(invocation) {
|
|
|
587
859
|
if (plan.baselineBody === undefined) continue;
|
|
588
860
|
await writeWorkspaceFile(config.workspaceDir, plan.baselineFile, plan.baselineBody);
|
|
589
861
|
}
|
|
590
|
-
if (conflicts.length > 0)
|
|
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
|
+
}
|
|
591
891
|
|
|
592
892
|
// Phase two: everything else, plus the tracked refreshes cleared above.
|
|
593
893
|
const manifestPages = [];
|
|
@@ -605,9 +905,19 @@ export async function pull(invocation) {
|
|
|
605
905
|
workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
|
|
606
906
|
};
|
|
607
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
|
+
}
|
|
608
916
|
const plan = trackedPlans.get(summary.pageId);
|
|
609
917
|
if (plan !== undefined) {
|
|
610
918
|
entry.description = plan.description;
|
|
919
|
+
if (plan.title !== undefined) entry.title = plan.title;
|
|
920
|
+
if (plan.path !== undefined) entry.path = plan.path;
|
|
611
921
|
if (plan.file !== undefined) {
|
|
612
922
|
entry.file = plan.file;
|
|
613
923
|
entry.sourceFormat = plan.sourceFormat;
|
|
@@ -631,6 +941,8 @@ export async function pull(invocation) {
|
|
|
631
941
|
const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
|
|
632
942
|
const body = freeFormBody(page);
|
|
633
943
|
entry.description = typeof page.shortDescription === "string" ? page.shortDescription : "";
|
|
944
|
+
entry.title = titleFromRead(page, summary);
|
|
945
|
+
entry.path = pathFromRead(page, summary);
|
|
634
946
|
if (body === undefined) {
|
|
635
947
|
// The server accepts and stores a body it never validates, so an
|
|
636
948
|
// unreadable one is a real state. It is reported rather than written
|
|
@@ -649,9 +961,11 @@ export async function pull(invocation) {
|
|
|
649
961
|
} else {
|
|
650
962
|
entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
|
|
651
963
|
entry.sourceFormat = pageSourceFormat(entry.file);
|
|
964
|
+
const revision = normalizePageBodyRevision(page.bodyRevision);
|
|
652
965
|
entry.baseline = {
|
|
653
966
|
remoteHash: canonicalDocumentHash(body),
|
|
654
967
|
sourceHash: workspaceContentHash(source),
|
|
968
|
+
...(revision === undefined ? {} : { revision }),
|
|
655
969
|
};
|
|
656
970
|
}
|
|
657
971
|
await writeWorkspaceFile(config.workspaceDir, entry.file, source);
|
|
@@ -667,6 +981,14 @@ export async function pull(invocation) {
|
|
|
667
981
|
const navItems = await getNavigation(client, siteId);
|
|
668
982
|
await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, { siteId, navItems });
|
|
669
983
|
|
|
984
|
+
if (redirectMap !== undefined) {
|
|
985
|
+
await writeWorkspaceJson(
|
|
986
|
+
config.workspaceDir,
|
|
987
|
+
REDIRECTS_FILE_NAME,
|
|
988
|
+
projectRedirectMapForWorkspace(siteId, redirectMap),
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
670
992
|
for (const document of settingsDocuments) {
|
|
671
993
|
await writeWorkspaceJson(config.workspaceDir, document.file, document.value);
|
|
672
994
|
}
|
|
@@ -677,6 +999,7 @@ export async function pull(invocation) {
|
|
|
677
999
|
pulledAt: new Date(now()).toISOString(),
|
|
678
1000
|
pagesTruncated: truncated,
|
|
679
1001
|
navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
|
|
1002
|
+
...(redirectMap === undefined ? {} : { redirects: redirectsManifestEntry(redirectMap) }),
|
|
680
1003
|
settings: pulledSettings,
|
|
681
1004
|
settingsSkipped: skippedSettings,
|
|
682
1005
|
...(pulledFooter === undefined ? {} : { footer: footerManifestEntry(pulledFooter) }),
|
|
@@ -720,6 +1043,15 @@ export async function pull(invocation) {
|
|
|
720
1043
|
...(reported.truncated ? { itemsTruncated: true } : {}),
|
|
721
1044
|
},
|
|
722
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
|
+
}),
|
|
723
1055
|
settings: {
|
|
724
1056
|
pulled: pulledSettings.map((entry) => entry.settingsType),
|
|
725
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
|
+
}
|