@taprootio/site-authoring 0.6.2 → 0.7.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 +103 -10
- package/package.json +2 -2
- package/src/api.js +281 -61
- package/src/cli.js +73 -26
- package/src/constants.js +2 -1
- package/src/content/components.js +7 -33
- package/src/content/free-form-section-registry.json +4 -2
- package/src/content/inline-facts.js +6 -0
- package/src/content/tiptap-prosemirror.ts +22 -16
- package/src/content/vocabulary.js +6 -2
- package/src/errors.js +27 -0
- package/src/fixture-contract.js +4 -4
- package/src/fixture-init.js +231 -0
- package/src/output.js +12 -2
- package/src/reference-help.js +139 -89
- package/src/settings-catalog.js +1 -0
- package/src/staging-check.js +145 -0
- package/src/theme-validation.js +31 -12
- package/src/transport.js +12 -3
- package/src/verbs/deploy.js +35 -101
- package/src/verbs/index.js +4 -1
- package/src/verbs/pull.js +22 -5
- package/src/verbs/redirects-check.js +18 -0
- package/src/verbs/status.js +34 -17
- package/src/verbs/theme-push.js +44 -9
- package/src/verbs/validate.js +51 -12
package/src/transport.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
ROLLOUT_REFUSAL_FIELD,
|
|
23
23
|
} from "./constants.js";
|
|
24
24
|
import { validateApiBaseUrl } from "./config.js";
|
|
25
|
-
import { hasControlCharacter, SiteAuthoringError } from "./errors.js";
|
|
25
|
+
import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
26
26
|
|
|
27
27
|
// Every method the authoring verbs need. `pages push` updates with PATCH, page
|
|
28
28
|
// deletion is DELETE, and `nav push` replaces the whole tree with PUT.
|
|
@@ -401,15 +401,24 @@ export class ApiError extends SiteAuthoringError {
|
|
|
401
401
|
const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
|
|
402
402
|
const fields = fieldViolations(body);
|
|
403
403
|
const capability = capabilityRefusal(body);
|
|
404
|
+
// Only the known upgrade refusal may supply a human message. Other server
|
|
405
|
+
// errors can contain internal diagnostics and retain their generic output.
|
|
406
|
+
const upgradeMessage = fields.includes(CLI_UPGRADE_REFUSAL_FIELD)
|
|
407
|
+
&& typeof body?.message === "string"
|
|
408
|
+
&& body.message.length > 0
|
|
409
|
+
&& body.message.length <= LIMITS.diagnosticScalars
|
|
410
|
+
&& !hasControlCharacter(body.message)
|
|
411
|
+
? sanitizeDiagnostic(body.message, "")
|
|
412
|
+
: undefined;
|
|
404
413
|
super(
|
|
405
414
|
"api.request_rejected",
|
|
406
|
-
fields.length > 0
|
|
415
|
+
upgradeMessage || (fields.length > 0
|
|
407
416
|
? `Taproot rejected the request field '${fields[0]}'.`
|
|
408
417
|
: capability !== undefined
|
|
409
418
|
? `Taproot refused the request: this credential does not carry a capability granting '${
|
|
410
419
|
capability.permission
|
|
411
420
|
}'.`
|
|
412
|
-
: `Taproot rejected the request with HTTP ${httpStatus}
|
|
421
|
+
: `Taproot rejected the request with HTTP ${httpStatus}.`),
|
|
413
422
|
{
|
|
414
423
|
// `fields` stays exactly what the wire named. The capability field is
|
|
415
424
|
// the CLI's own label for a refusal that carries no field violation at
|
package/src/verbs/deploy.js
CHANGED
|
@@ -3,22 +3,17 @@ import {
|
|
|
3
3
|
DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
4
4
|
DEPLOYMENT_STATUS_COMPLETED,
|
|
5
5
|
deploySite,
|
|
6
|
+
getDeploySelection,
|
|
6
7
|
getPublishingReadiness,
|
|
7
|
-
getStagingPreviewStatus,
|
|
8
8
|
listDeployments,
|
|
9
|
-
|
|
10
|
-
PAGE_STATUS_APPROVED,
|
|
9
|
+
mintStagingPreviewHandoff,
|
|
11
10
|
waitForDeployment,
|
|
12
11
|
withRefusalGuidance,
|
|
13
12
|
} from "../api.js";
|
|
14
|
-
import {
|
|
15
|
-
DEPLOY_TARGET_PRODUCTION,
|
|
16
|
-
DEPLOY_TARGET_STAGING,
|
|
17
|
-
VERB_DEPLOY,
|
|
18
|
-
} from "../constants.js";
|
|
13
|
+
import { DEPLOY_TARGET_PRODUCTION, DEPLOY_TARGET_STAGING, VERB_DEPLOY } from "../constants.js";
|
|
19
14
|
import { SiteAuthoringError } from "../errors.js";
|
|
20
15
|
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
21
|
-
import {
|
|
16
|
+
import { checkStagingRedirects } from "../staging-check.js";
|
|
22
17
|
import { readManifest, writeManifest } from "../workspace.js";
|
|
23
18
|
|
|
24
19
|
/**
|
|
@@ -46,7 +41,7 @@ const REDIRECT_PROPAGATION_NOTE =
|
|
|
46
41
|
* completion.
|
|
47
42
|
*
|
|
48
43
|
* Staging publishes a *candidate*: approved pages, navigation, and the
|
|
49
|
-
* candidate-selectable settings groups. The server refuses an empty candidate,
|
|
44
|
+
* changed candidate-selectable settings groups. The server refuses an empty candidate,
|
|
50
45
|
* so the CLI refuses it first and says what to do about it.
|
|
51
46
|
*
|
|
52
47
|
* Production is a promotion and only a promotion. `DeploySite` rejects a
|
|
@@ -68,7 +63,6 @@ const REDIRECT_PROPAGATION_NOTE =
|
|
|
68
63
|
*/
|
|
69
64
|
|
|
70
65
|
const MAXIMUM_REPORTED_BLOCKERS = 50;
|
|
71
|
-
const STAGING_ROUTE_PROBE_MILLISECONDS = 5_000;
|
|
72
66
|
// Staged page ids ride in the readiness query string, so a very large candidate
|
|
73
67
|
// is checked at site level instead of pushing an unbounded URL at the API.
|
|
74
68
|
const MAXIMUM_READINESS_PAGE_IDS = 100;
|
|
@@ -91,13 +85,6 @@ function explicitSelection(invocation) {
|
|
|
91
85
|
return { stagedPageIds, selectedSettingsTypes, includeNavigation, present };
|
|
92
86
|
}
|
|
93
87
|
|
|
94
|
-
function manifestSettingsTypes(manifest) {
|
|
95
|
-
const pulled = Array.isArray(manifest?.settings)
|
|
96
|
-
? manifest.settings.map((entry) => entry?.settingsType).filter((value) => typeof value === "string")
|
|
97
|
-
: [];
|
|
98
|
-
return CANDIDATE_SELECTABLE_SETTINGS_TYPES.filter((type) => pulled.includes(type));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
88
|
async function recordDeployment(config, manifest, key, deployment) {
|
|
102
89
|
if (manifest === undefined) return;
|
|
103
90
|
manifest.deployments = { ...(manifest.deployments ?? {}) };
|
|
@@ -139,78 +126,35 @@ function reportReadiness(readiness) {
|
|
|
139
126
|
selectedPageCount: readiness.selectedPageCount,
|
|
140
127
|
blockedPageCount: readiness.blockedPageCount,
|
|
141
128
|
hasCandidateChanges: readiness.hasCandidateChanges,
|
|
129
|
+
hasSuccessfulStagingDeployment: readiness.hasSuccessfulStagingDeployment,
|
|
142
130
|
blockers: blockers.items,
|
|
143
131
|
...(blockers.truncated ? { blockersTruncated: true } : {}),
|
|
144
132
|
};
|
|
145
133
|
}
|
|
146
134
|
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function inspectStagingPreview(client, siteId, onProgress) {
|
|
152
|
-
let status;
|
|
135
|
+
async function inspectStagingPreview(client, siteId, onProgress, now) {
|
|
136
|
+
let checks;
|
|
153
137
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
if (!status.ready) {
|
|
162
|
-
const warning = "The staging deployment completed, but Taproot does not report a ready staging URL.";
|
|
163
|
-
onProgress(`Warning: ${warning}`);
|
|
164
|
-
return { url: "", ...stagingWarning(warning) };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const stagingUrl = status.stagingUrl;
|
|
168
|
-
onProgress(`Staging URL: ${stagingUrl}`);
|
|
169
|
-
let response;
|
|
170
|
-
try {
|
|
171
|
-
const timeout = client.timeoutSignal(STAGING_ROUTE_PROBE_MILLISECONDS);
|
|
172
|
-
const signal = client.signal ? AbortSignal.any([client.signal, timeout]) : timeout;
|
|
173
|
-
response = await client.fetch(stagingUrl, {
|
|
174
|
-
method: "GET",
|
|
175
|
-
redirect: "manual",
|
|
176
|
-
signal,
|
|
177
|
-
headers: {
|
|
178
|
-
accept: "text/html",
|
|
179
|
-
"user-agent": "taproot-site/staging-route-check",
|
|
180
|
-
},
|
|
181
|
-
});
|
|
138
|
+
checks = await checkStagingRedirects(client, siteId, onProgress, now);
|
|
139
|
+
if (!checks.redirects.verified) {
|
|
140
|
+
onProgress(
|
|
141
|
+
"Warning: staging redirects are not all verified. Edge propagation may still be pending; run 'taproot-site redirects check' again.",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
182
144
|
} catch {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
145
|
+
onProgress(
|
|
146
|
+
"Warning: the deployment completed, but authenticated staging redirect checks could not finish. Run 'taproot-site redirects check' again.",
|
|
147
|
+
);
|
|
148
|
+
checks = { routeCheck: "unresolved", redirects: { verified: false, covered: false } };
|
|
186
149
|
}
|
|
187
|
-
|
|
188
|
-
const location = response.headers.get("location");
|
|
189
|
-
await response.body?.cancel().catch(() => {});
|
|
190
|
-
let handoff;
|
|
191
150
|
try {
|
|
192
|
-
|
|
151
|
+
// Mint after checks so the final single-use URL has never been consumed.
|
|
152
|
+
const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
|
|
153
|
+
return { ...checks, ...handoff };
|
|
193
154
|
} catch {
|
|
194
|
-
handoff
|
|
155
|
+
onProgress("Warning: the deployment completed, but a staging handoff could not be minted.");
|
|
156
|
+
return { ...checks, url: "", warning: "Staging handoff unavailable; run redirects check to retry." };
|
|
195
157
|
}
|
|
196
|
-
const stagingHost = new URL(stagingUrl).hostname;
|
|
197
|
-
const resolvesToSite = response.status >= 300
|
|
198
|
-
&& response.status < 400
|
|
199
|
-
&& handoff?.protocol === "https:"
|
|
200
|
-
&& handoff.username === ""
|
|
201
|
-
&& handoff.password === ""
|
|
202
|
-
&& handoff.hash === ""
|
|
203
|
-
&& handoff.pathname === "/api/v1/staging-preview/handoff"
|
|
204
|
-
&& handoff.searchParams.getAll("siteId").length === 1
|
|
205
|
-
&& handoff.searchParams.get("siteId") === siteId
|
|
206
|
-
&& handoff.searchParams.getAll("host").length === 1
|
|
207
|
-
&& handoff.searchParams.get("host") === stagingHost;
|
|
208
|
-
if (resolvesToSite) return { url: stagingUrl, routeCheck: "resolved" };
|
|
209
|
-
|
|
210
|
-
const warning = `The staging deployment completed at ${stagingUrl}, but the host returned HTTP ${response.status}`
|
|
211
|
-
+ " instead of this site's staging gate. The deployment artifacts are complete, but the URL is not viewable.";
|
|
212
|
-
onProgress(`Warning: ${warning}`);
|
|
213
|
-
return { url: stagingUrl, ...stagingWarning(warning) };
|
|
214
158
|
}
|
|
215
159
|
|
|
216
160
|
export async function deploy(invocation) {
|
|
@@ -248,6 +192,7 @@ export async function deploy(invocation) {
|
|
|
248
192
|
siteId,
|
|
249
193
|
environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
|
|
250
194
|
stagingDeploymentId,
|
|
195
|
+
...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
|
|
251
196
|
});
|
|
252
197
|
onProgress(`Production deployment ${created.id} accepted; waiting for it to complete.`);
|
|
253
198
|
const completed = await waitForDeployment(client, {
|
|
@@ -268,28 +213,16 @@ export async function deploy(invocation) {
|
|
|
268
213
|
});
|
|
269
214
|
}
|
|
270
215
|
|
|
271
|
-
onProgress("
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// follows reports success.
|
|
277
|
-
throw new SiteAuthoringError(
|
|
278
|
-
"deploy.live_list_truncated",
|
|
279
|
-
"The site has more pages than this CLI can enumerate, so the staging candidate would omit approved "
|
|
280
|
-
+ "pages without saying so. Nothing was deployed.",
|
|
281
|
-
{ field: "stagedPageIds" },
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
const stagedPageIds = selection.stagedPageIds
|
|
285
|
-
?? pages.filter((summary) => summary.status === PAGE_STATUS_APPROVED).map((summary) => summary.pageId);
|
|
286
|
-
const selectedSettingsTypes = selection.selectedSettingsTypes ?? manifestSettingsTypes(manifest);
|
|
287
|
-
const includeNavigation = selection.includeNavigation ?? manifest?.navigation !== undefined;
|
|
216
|
+
onProgress("Reading the changed release set shown on the Deployments page.");
|
|
217
|
+
const defaults = await getDeploySelection(client, siteId);
|
|
218
|
+
const stagedPageIds = selection.stagedPageIds ?? defaults.stagedPageIds;
|
|
219
|
+
const selectedSettingsTypes = selection.selectedSettingsTypes ?? defaults.selectedSettingsTypes;
|
|
220
|
+
const includeNavigation = selection.includeNavigation ?? defaults.includeNavigation;
|
|
288
221
|
if (stagedPageIds.length === 0 && selectedSettingsTypes.length === 0 && !includeNavigation) {
|
|
289
222
|
throw new SiteAuthoringError(
|
|
290
223
|
"deploy.empty_selection",
|
|
291
224
|
"A staging deployment needs at least one approved page, settings group, or navigation change. "
|
|
292
|
-
+ "Run 'taproot-site approve'
|
|
225
|
+
+ "Run 'taproot-site approve' or change staged settings/navigation first.",
|
|
293
226
|
{ field: "Candidate" },
|
|
294
227
|
);
|
|
295
228
|
}
|
|
@@ -301,8 +234,8 @@ export async function deploy(invocation) {
|
|
|
301
234
|
};
|
|
302
235
|
onProgress(
|
|
303
236
|
`Checking publishing readiness for ${stagedPageIds.length} page(s), `
|
|
304
|
-
|
|
305
|
-
|
|
237
|
+
+ `${selectedSettingsTypes.length} settings group(s), `
|
|
238
|
+
+ `navigation ${includeNavigation ? "included" : "excluded"}.`,
|
|
306
239
|
);
|
|
307
240
|
const readiness = await getPublishingReadiness(client, siteId, {
|
|
308
241
|
...candidate,
|
|
@@ -325,6 +258,7 @@ export async function deploy(invocation) {
|
|
|
325
258
|
siteId,
|
|
326
259
|
environment: DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
327
260
|
...candidate,
|
|
261
|
+
...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
|
|
328
262
|
});
|
|
329
263
|
onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
|
|
330
264
|
const completed = await waitForDeployment(client, {
|
|
@@ -335,7 +269,7 @@ export async function deploy(invocation) {
|
|
|
335
269
|
now,
|
|
336
270
|
});
|
|
337
271
|
await recordDeployment(config, manifest, "staging", completed);
|
|
338
|
-
const stagingPreview = await inspectStagingPreview(client, siteId, onProgress);
|
|
272
|
+
const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
|
|
339
273
|
onProgress(REDIRECT_PROPAGATION_NOTE);
|
|
340
274
|
return successResult(VERB_DEPLOY, siteId, {
|
|
341
275
|
target,
|
|
@@ -348,7 +282,7 @@ export async function deploy(invocation) {
|
|
|
348
282
|
deployment: completed,
|
|
349
283
|
readiness: reportReadiness(readiness),
|
|
350
284
|
stagingPreview,
|
|
351
|
-
nextStep: "deploy --production",
|
|
285
|
+
nextStep: stagingPreview.redirects?.verified === true ? "deploy --production" : "redirects check",
|
|
352
286
|
});
|
|
353
287
|
});
|
|
354
288
|
}
|
package/src/verbs/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
VERB_PREVIEW_PAGE,
|
|
12
12
|
VERB_PREVIEW_REVOKE,
|
|
13
13
|
VERB_PULL,
|
|
14
|
+
VERB_REDIRECTS_CHECK,
|
|
14
15
|
VERB_REDIRECTS_PULL,
|
|
15
16
|
VERB_REDIRECTS_PUSH,
|
|
16
17
|
VERB_SITES,
|
|
@@ -29,9 +30,10 @@ import { logout } from "./logout.js";
|
|
|
29
30
|
import { mediaUpload } from "./media-upload.js";
|
|
30
31
|
import { navPush } from "./nav-push.js";
|
|
31
32
|
import { pagesPush } from "./pages-push.js";
|
|
32
|
-
import { pull } from "./pull.js";
|
|
33
33
|
import { previewPage } from "./preview-page.js";
|
|
34
34
|
import { previewRevoke } from "./preview-revoke.js";
|
|
35
|
+
import { pull } from "./pull.js";
|
|
36
|
+
import { redirectsCheck } from "./redirects-check.js";
|
|
35
37
|
import { redirectsPull } from "./redirects-pull.js";
|
|
36
38
|
import { redirectsPush } from "./redirects-push.js";
|
|
37
39
|
import { sites } from "./sites.js";
|
|
@@ -114,6 +116,7 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
114
116
|
[VERB_PULL]: pull,
|
|
115
117
|
[VERB_PAGES_PUSH]: pagesPush,
|
|
116
118
|
[VERB_NAV_PUSH]: navPush,
|
|
119
|
+
[VERB_REDIRECTS_CHECK]: redirectsCheck,
|
|
117
120
|
[VERB_REDIRECTS_PULL]: redirectsPull,
|
|
118
121
|
[VERB_REDIRECTS_PUSH]: redirectsPush,
|
|
119
122
|
[VERB_THEME_PUSH]: themePush,
|
package/src/verbs/pull.js
CHANGED
|
@@ -46,19 +46,19 @@ import {
|
|
|
46
46
|
PAGE_WORKSPACE_MODE_EDITABLE,
|
|
47
47
|
PAGE_WORKSPACE_MODE_METADATA_ONLY,
|
|
48
48
|
PAGE_WORKSPACE_MODE_READ_ONLY,
|
|
49
|
+
PAGES_DIRECTORY,
|
|
49
50
|
pageSourceFormat,
|
|
50
51
|
pageSourceRegistry,
|
|
51
|
-
PAGES_DIRECTORY,
|
|
52
52
|
readObservedPageRecord,
|
|
53
53
|
readWorkspaceFile,
|
|
54
54
|
readWorkspaceJson,
|
|
55
55
|
SETTINGS_DIRECTORY,
|
|
56
56
|
SYSTEM_PAGE_NOT_FOUND_PATH,
|
|
57
57
|
walkWorkspaceFiles,
|
|
58
|
+
WORKSPACE_LIMITS,
|
|
58
59
|
workspaceContentHash,
|
|
59
60
|
workspaceFileExists,
|
|
60
61
|
workspaceFileNameForPage,
|
|
61
|
-
WORKSPACE_LIMITS,
|
|
62
62
|
writeManifest,
|
|
63
63
|
writeWorkspaceFile,
|
|
64
64
|
writeWorkspaceJson,
|
|
@@ -171,7 +171,8 @@ function describeDifferenceSummary(differences) {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
|
-
* Whether a workspace file still holds exactly the site's document
|
|
174
|
+
* Whether a workspace file still holds exactly the site's document, or
|
|
175
|
+
* `undefined` when parsing failed and no comparison could be completed.
|
|
175
176
|
*
|
|
176
177
|
* Compared the way `remoteHash` is computed — canonically — and deliberately
|
|
177
178
|
* not byte for byte. `FreeFormData.body` is a `google.protobuf.Struct`, so two
|
|
@@ -187,7 +188,7 @@ function holdsRemoteDocument(sourceBytes, remoteHash) {
|
|
|
187
188
|
try {
|
|
188
189
|
return canonicalDocumentHash(JSON.parse(sourceBytes.toString("utf8"))) === remoteHash;
|
|
189
190
|
} catch {
|
|
190
|
-
return
|
|
191
|
+
return undefined;
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
|
|
@@ -652,6 +653,7 @@ export async function pull(invocation) {
|
|
|
652
653
|
const trackedPlans = new Map();
|
|
653
654
|
const conflicts = [];
|
|
654
655
|
let bufferedBytes = 0;
|
|
656
|
+
let revisionsRecordedWithoutBodyComparison = 0;
|
|
655
657
|
for (const summary of freeForm) {
|
|
656
658
|
const source = tracked.get(summary.pageId);
|
|
657
659
|
if (source === undefined) continue;
|
|
@@ -742,9 +744,18 @@ export async function pull(invocation) {
|
|
|
742
744
|
// one at all. There is nothing to protect and nothing to compare, so an
|
|
743
745
|
// unrecorded hash simply establishes itself.
|
|
744
746
|
const localKnown = source.baseline?.sourceHash !== undefined;
|
|
747
|
+
const sourceMatchesRemote = !localKnown && !markdown ? holdsRemoteDocument(sourceBytes, remoteHash) : undefined;
|
|
745
748
|
const localChanged = localKnown
|
|
746
749
|
? source.baseline.sourceHash !== sourceHash
|
|
747
|
-
: !markdown &&
|
|
750
|
+
: !markdown && sourceMatchesRemote !== true;
|
|
751
|
+
// A first revision bypasses the old remote hash. Only the unbaselined
|
|
752
|
+
// ProseMirror path above can compare this source with the site's body;
|
|
753
|
+
// a failed parse completes no comparison, and sourceHash merely checks
|
|
754
|
+
// local edits against earlier local bytes.
|
|
755
|
+
// Report the adoption only after the whole pull writes its manifest.
|
|
756
|
+
if (revision !== undefined && source.baseline?.revision === undefined && sourceMatchesRemote === undefined) {
|
|
757
|
+
revisionsRecordedWithoutBodyComparison += 1;
|
|
758
|
+
}
|
|
748
759
|
const keepLocal = markdown || localChanged;
|
|
749
760
|
const conflicted = markdown ? remoteChanged : localChanged && remoteChanged;
|
|
750
761
|
trackedPlans.set(summary.pageId, {
|
|
@@ -1012,6 +1023,11 @@ export async function pull(invocation) {
|
|
|
1012
1023
|
};
|
|
1013
1024
|
await writeManifest(config.workspaceDir, manifest);
|
|
1014
1025
|
onProgress(`Wrote ${MANIFEST_FILE_NAME} describing ${manifestPages.length} pages.`);
|
|
1026
|
+
if (revisionsRecordedWithoutBodyComparison > 0) {
|
|
1027
|
+
onProgress(
|
|
1028
|
+
`revision recorded for ${revisionsRecordedWithoutBodyComparison} pages that had none; bodies were not compared`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1015
1031
|
if (truncated) {
|
|
1016
1032
|
onProgress("The page list was bounded before the site was fully enumerated; the manifest is partial.");
|
|
1017
1033
|
}
|
|
@@ -1037,6 +1053,7 @@ export async function pull(invocation) {
|
|
|
1037
1053
|
// receiving a freshly written one. A caller that expected an authored
|
|
1038
1054
|
// Markdown page to survive the pull can assert on it.
|
|
1039
1055
|
tracked: tracked.size,
|
|
1056
|
+
revisionsRecordedWithoutBodyComparison,
|
|
1040
1057
|
readOnly: manifestPages.filter((entry) => entry.workspaceMode === PAGE_WORKSPACE_MODE_READ_ONLY).length,
|
|
1041
1058
|
truncated,
|
|
1042
1059
|
items: reported.items,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mintStagingPreviewHandoff, withRefusalGuidance } from "../api.js";
|
|
2
|
+
import { VERB_REDIRECTS_CHECK } from "../constants.js";
|
|
3
|
+
import { openSession, successResult } from "../session.js";
|
|
4
|
+
import { checkStagingRedirects } from "../staging-check.js";
|
|
5
|
+
|
|
6
|
+
export async function redirectsCheck(invocation) {
|
|
7
|
+
const { client, siteId, onProgress, now } = await openSession(invocation);
|
|
8
|
+
return await withRefusalGuidance(onProgress, "staging redirects", async () => {
|
|
9
|
+
const checks = await checkStagingRedirects(client, siteId, onProgress, now);
|
|
10
|
+
if (!checks.redirects.verified) {
|
|
11
|
+
onProgress(
|
|
12
|
+
"Warning: staging redirects are not verified. Check the reported mismatches and retry after edge propagation.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
|
|
16
|
+
return successResult(VERB_REDIRECTS_CHECK, siteId, { stagingPreview: { ...checks, ...handoff } });
|
|
17
|
+
});
|
|
18
|
+
}
|
package/src/verbs/status.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
+
getDeploySelection,
|
|
2
3
|
getPublishingReadiness,
|
|
4
|
+
getSiteBrokenReferences,
|
|
3
5
|
IMAGE_PROCESSING_STATE_COMPLETE,
|
|
4
6
|
IMAGE_PROCESSING_STATE_FAILED,
|
|
5
7
|
listDeployments,
|
|
6
8
|
listSiteImages,
|
|
7
9
|
withRefusalGuidance,
|
|
8
10
|
} from "../api.js";
|
|
11
|
+
import { isBehindLatest } from "../cli-release.js";
|
|
9
12
|
import {
|
|
10
13
|
CLI_UPGRADE_COMMAND,
|
|
11
14
|
CLI_VERSION,
|
|
@@ -16,26 +19,17 @@ import {
|
|
|
16
19
|
REFUSAL_PLATFORM_PAUSED,
|
|
17
20
|
VERB_STATUS,
|
|
18
21
|
} from "../constants.js";
|
|
19
|
-
import { isBehindLatest } from "../cli-release.js";
|
|
20
22
|
import { boundedList, openSession, successResult } from "../session.js";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
* `status` — the platform authoring switch,
|
|
24
|
-
* publishing readiness, and
|
|
25
|
-
*
|
|
26
|
-
* Broken references are deliberately absent. `GetSiteBrokenReferences` is
|
|
27
|
-
* session-only under TR00602's read list, and TR00604 adds no server surface,
|
|
28
|
-
* so there is no key-authorized way to read them. The verb says that out loud —
|
|
29
|
-
* on the human channel and as a field in the JSON result — rather than
|
|
30
|
-
* reporting a clean site and implying a coverage it does not have.
|
|
25
|
+
* `status` — the platform authoring switch, CLI release, deployments,
|
|
26
|
+
* publishing readiness, image processing, and broken-reference pages.
|
|
31
27
|
*/
|
|
32
|
-
|
|
33
28
|
const MAXIMUM_DEPLOYMENTS = 20;
|
|
34
29
|
const MAXIMUM_FAILED_IMAGES = 50;
|
|
35
30
|
const MAXIMUM_BLOCKERS = 50;
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
"GetSiteBrokenReferences is session-only on the shipped contract, so a key-authorized CLI cannot read it.";
|
|
31
|
+
const MAXIMUM_BROKEN_REFERENCE_PAGES = 50;
|
|
32
|
+
const MAXIMUM_MISSING_TARGETS = 50;
|
|
39
33
|
|
|
40
34
|
const PLATFORM_UNKNOWN_NOTE =
|
|
41
35
|
"Taproot never reported the platform authoring switch on this run: either no sign-in token exchange happened "
|
|
@@ -109,14 +103,18 @@ function reportPlatform(platform, onProgress) {
|
|
|
109
103
|
export async function status(invocation) {
|
|
110
104
|
const { client, siteId, platform, release, onProgress } = await openSession(invocation);
|
|
111
105
|
// Said first, and before any wire read: an operator running `status` because
|
|
112
|
-
// writes are being refused should not have to wait on
|
|
106
|
+
// writes are being refused should not have to wait on network calls to
|
|
113
107
|
// learn that the platform is paused — and this answer needs none of them.
|
|
114
108
|
const platformReport = reportPlatform(platform, onProgress);
|
|
115
109
|
const cliReleaseReport = reportCliRelease(release, onProgress);
|
|
116
110
|
|
|
117
111
|
return await withRefusalGuidance(onProgress, "status check", async () => {
|
|
118
112
|
onProgress("Reading publishing readiness.");
|
|
119
|
-
const
|
|
113
|
+
const selection = await getDeploySelection(client, siteId);
|
|
114
|
+
const readiness = await getPublishingReadiness(client, siteId, {
|
|
115
|
+
...selection,
|
|
116
|
+
stagedPageIds: selection.stagedPageIds.length > 100 ? undefined : selection.stagedPageIds,
|
|
117
|
+
});
|
|
120
118
|
onProgress("Reading the deployment log.");
|
|
121
119
|
const {
|
|
122
120
|
deployments,
|
|
@@ -124,6 +122,20 @@ export async function status(invocation) {
|
|
|
124
122
|
} = await listDeployments(client, siteId, { pageSize: MAXIMUM_DEPLOYMENTS });
|
|
125
123
|
onProgress("Reading image processing state.");
|
|
126
124
|
const { images, summary, truncated } = await listSiteImages(client, siteId);
|
|
125
|
+
onProgress("Reading broken references.");
|
|
126
|
+
const brokenReferencePages = await getSiteBrokenReferences(client, siteId);
|
|
127
|
+
const reportedBrokenPages = boundedList(brokenReferencePages, MAXIMUM_BROKEN_REFERENCE_PAGES);
|
|
128
|
+
const brokenPages = reportedBrokenPages.items.map((page) => {
|
|
129
|
+
const images = boundedList(page.missingImageIds, MAXIMUM_MISSING_TARGETS);
|
|
130
|
+
const paths = boundedList(page.missingPagePaths, MAXIMUM_MISSING_TARGETS);
|
|
131
|
+
return {
|
|
132
|
+
...page,
|
|
133
|
+
missingImageIds: images.items,
|
|
134
|
+
missingPagePaths: paths.items,
|
|
135
|
+
...(images.truncated ? { missingImageIdsTruncated: true } : {}),
|
|
136
|
+
...(paths.truncated ? { missingPagePathsTruncated: true } : {}),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
127
139
|
|
|
128
140
|
const byState = {};
|
|
129
141
|
for (const image of images) {
|
|
@@ -142,7 +154,7 @@ export async function status(invocation) {
|
|
|
142
154
|
const reportedDeployments = boundedList(deployments, MAXIMUM_DEPLOYMENTS);
|
|
143
155
|
const blockers = boundedList(readiness.blockers, MAXIMUM_BLOCKERS);
|
|
144
156
|
|
|
145
|
-
onProgress(`Broken references
|
|
157
|
+
onProgress(`Broken references: ${brokenReferencePages.length} page(s) with missing targets.`);
|
|
146
158
|
|
|
147
159
|
return successResult(VERB_STATUS, siteId, {
|
|
148
160
|
platform: platformReport,
|
|
@@ -175,7 +187,12 @@ export async function status(invocation) {
|
|
|
175
187
|
...(failedImages.truncated ? { failedItemsTruncated: true } : {}),
|
|
176
188
|
...(truncated ? { listTruncated: true } : {}),
|
|
177
189
|
},
|
|
178
|
-
brokenReferences: {
|
|
190
|
+
brokenReferences: {
|
|
191
|
+
covered: true,
|
|
192
|
+
totalPages: brokenReferencePages.length,
|
|
193
|
+
pages: brokenPages,
|
|
194
|
+
...(reportedBrokenPages.truncated ? { pagesTruncated: true } : {}),
|
|
195
|
+
},
|
|
179
196
|
});
|
|
180
197
|
});
|
|
181
198
|
}
|
package/src/verbs/theme-push.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { getSettingsGroup, saveSiteFooterSettings, setSetting, withRefusalGuidance } from "../api.js";
|
|
2
2
|
import {
|
|
3
|
+
APPEARANCE_SCALAR_FIELDS,
|
|
3
4
|
applyFooterColors,
|
|
4
5
|
buildAppearanceScalarOperations,
|
|
5
6
|
footerColorOverlay,
|
|
6
7
|
} from "../appearance-contract.js";
|
|
7
8
|
import { VERB_THEME_PUSH } from "../constants.js";
|
|
8
9
|
import { SiteAuthoringError } from "../errors.js";
|
|
9
|
-
import { computeFooterDraftHash } from "../footer-draft-hash.js";
|
|
10
10
|
import { projectFooterSettingsForWorkspace } from "../footer-contract.js";
|
|
11
|
+
import { computeFooterDraftHash } from "../footer-draft-hash.js";
|
|
11
12
|
import {
|
|
12
13
|
advanceFooterManifest,
|
|
13
14
|
readAppearanceWorkspaceContext,
|
|
@@ -20,9 +21,7 @@ import {
|
|
|
20
21
|
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
21
22
|
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
22
23
|
} from "../settings-catalog.js";
|
|
23
|
-
import {
|
|
24
|
-
validateAndEncodeThemePair,
|
|
25
|
-
} from "../theme-validation.js";
|
|
24
|
+
import { missingThemeFields, validateAndEncodeThemePair } from "../theme-validation.js";
|
|
26
25
|
import {
|
|
27
26
|
readWorkspaceJson,
|
|
28
27
|
SETTINGS_DIRECTORY,
|
|
@@ -94,13 +93,49 @@ export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds
|
|
|
94
93
|
expectedEntityIds?.get(SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES),
|
|
95
94
|
),
|
|
96
95
|
]);
|
|
96
|
+
const documents = {
|
|
97
|
+
[SETTINGS_TYPE_TAPROOT_STYLES]: style,
|
|
98
|
+
[SETTINGS_TYPE_BRAND]: brand,
|
|
99
|
+
[SETTINGS_TYPE_SITE_HEADER]: header,
|
|
100
|
+
};
|
|
101
|
+
// Preflight the complete required-key inventory before any value validator
|
|
102
|
+
// can stop at a malformed value in an earlier group. Optional footer color
|
|
103
|
+
// leaves retain their existing empty-color fallback.
|
|
104
|
+
const missing = [];
|
|
105
|
+
for (const scheme of ["light", "dark"]) {
|
|
106
|
+
const name = `${scheme}Theme`;
|
|
107
|
+
if (!Object.hasOwn(style, name)) missing.push(name);
|
|
108
|
+
else missing.push(...missingThemeFields(style[name], scheme));
|
|
109
|
+
}
|
|
110
|
+
for (const definition of APPEARANCE_SCALAR_FIELDS) {
|
|
111
|
+
if (!Object.hasOwn(documents[definition.settingsType], definition.name)) {
|
|
112
|
+
missing.push(definition.path);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!Object.hasOwn(publishing, "footerSettings")) {
|
|
116
|
+
missing.push("site-publishing-preferences.footerSettings");
|
|
117
|
+
} else if (isPlainObject(publishing.footerSettings)) {
|
|
118
|
+
for (const scheme of ["light", "dark"]) {
|
|
119
|
+
if (!Object.hasOwn(publishing.footerSettings, scheme)) missing.push(`footerSettings.${scheme}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
throw new SiteAuthoringError(
|
|
124
|
+
"theme.settings_missing",
|
|
125
|
+
"Required settings keys are missing. Run 'taproot-site pull' to update this workspace's contract.",
|
|
126
|
+
{
|
|
127
|
+
field: missing[0],
|
|
128
|
+
details: missing.map((field) => ({
|
|
129
|
+
code: "theme.setting_missing",
|
|
130
|
+
field,
|
|
131
|
+
message: `${field} is missing (added in a later contract; run taproot-site pull)`,
|
|
132
|
+
})),
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
}
|
|
97
136
|
const themes = validateAndEncodeThemePair(style.lightTheme, style.darkTheme);
|
|
98
137
|
const scalarOperations = buildAppearanceScalarOperations(
|
|
99
|
-
|
|
100
|
-
[SETTINGS_TYPE_TAPROOT_STYLES]: style,
|
|
101
|
-
[SETTINGS_TYPE_BRAND]: brand,
|
|
102
|
-
[SETTINGS_TYPE_SITE_HEADER]: header,
|
|
103
|
-
},
|
|
138
|
+
documents,
|
|
104
139
|
knownImageIds,
|
|
105
140
|
);
|
|
106
141
|
const footerColors = footerColorOverlay(publishing.footerSettings);
|