@taprootio/site-authoring 0.6.3 → 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/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
@@ -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
- listSitePages,
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 { CANDIDATE_SELECTABLE_SETTINGS_TYPES } from "../settings-catalog.js";
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 stagingWarning(message) {
148
- return { routeCheck: "unresolved", warning: message };
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
- status = await getStagingPreviewStatus(client, siteId);
155
- } catch {
156
- const warning = "The staging deployment completed, but Taproot could not report its staging URL."
157
- + " Check the site's staging-host configuration before treating the deployment as viewable.";
158
- onProgress(`Warning: ${warning}`);
159
- return { url: "", ...stagingWarning(warning) };
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
- const warning = `The staging deployment completed at ${stagingUrl}, but the host could not be reached.`;
184
- onProgress(`Warning: ${warning}`);
185
- return { url: stagingUrl, ...stagingWarning(warning) };
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
- handoff = location ? new URL(location, stagingUrl) : undefined;
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 = undefined;
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("Listing approved pages for the staging candidate.");
272
- const { pages, truncated } = await listSitePages(client, siteId, { onProgress });
273
- if (truncated) {
274
- // The candidate is built from this list, so a partial one silently
275
- // publishes a subset of what was approved — and the deployment that
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' (and 'taproot-site pull' to pick up navigation and settings) first.",
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
- + `${selectedSettingsTypes.length} settings group(s), `
305
- + `navigation ${includeNavigation ? "included" : "excluded"}.`,
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
  }
@@ -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 false;
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 && !holdsRemoteDocument(sourceBytes, remoteHash);
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
+ }
@@ -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, the CLI release, deployments,
24
- * publishing readiness, and image processing.
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 BROKEN_REFERENCES_NOTE =
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 three network calls to
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 readiness = await getPublishingReadiness(client, siteId);
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 are not reported: ${BROKEN_REFERENCES_NOTE}`);
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: { covered: false, reason: BROKEN_REFERENCES_NOTE },
190
+ brokenReferences: {
191
+ covered: true,
192
+ totalPages: brokenReferencePages.length,
193
+ pages: brokenPages,
194
+ ...(reportedBrokenPages.truncated ? { pagesTruncated: true } : {}),
195
+ },
179
196
  });
180
197
  });
181
198
  }
@@ -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);