@taprootio/site-authoring 0.7.2 → 0.8.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/session.js CHANGED
@@ -22,8 +22,9 @@ import {
22
22
  } from "./credentials.js";
23
23
  import { SiteAuthoringError } from "./errors.js";
24
24
  import { exchangeSiteAuthoringToken, withRefusalGuidance } from "./api.js";
25
- import { SiteApiClient } from "./transport.js";
25
+ import { ApiError, SiteApiClient } from "./transport.js";
26
26
  import { readStoredApiBaseUrl } from "./settings.js";
27
+ import { capabilitiesForSurface, surfaceAllows, surfaceRefusal } from "./surface.js";
27
28
 
28
29
  /**
29
30
  * The two things every verb does at its edges: open a credentialed session, and
@@ -75,6 +76,46 @@ function keyMissingError() {
75
76
  );
76
77
  }
77
78
 
79
+ /**
80
+ * Refuses a verb the recorded surface cannot take, before any credential is
81
+ * used (TR00790). Offline on purpose: the answer is in the configuration `use`
82
+ * wrote, and an agent that runs `pages push` against a Docs site should be
83
+ * told in words, now, rather than after an exchange and a refused request.
84
+ * The exchange's own answer is checked again afterwards, because the
85
+ * recording can be stale.
86
+ */
87
+ function requireRecordedSurface(invocation, config) {
88
+ if (invocation.surface === undefined || config.authoringSurface === undefined) return;
89
+ if (!surfaceAllows(config.authoringSurface, invocation.surface)) {
90
+ throw surfaceRefusal(invocation.verb ?? "This verb", config.authoringSurface, { source: "config" });
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The verb's capability request, narrowed to the recorded surface (TR00790).
96
+ *
97
+ * A verb may declare its own set for a surface (`surfaceCapabilities`) where
98
+ * plain intersection would be wrong; otherwise the declared set is
99
+ * intersected with what the surface offers. Either way a request that started
100
+ * non-empty must not end empty: on the wire an empty list means "everything
101
+ * the surface offers", so sending one would hand a narrow verb a wide
102
+ * credential. That case is a verb-table defect and is refused as one.
103
+ */
104
+ function narrowedCapabilities(invocation, surface) {
105
+ const requested = invocation.capabilities ?? [];
106
+ if (surface === undefined) return requested;
107
+ const narrowed = invocation.surfaceCapabilities?.[surface] ?? capabilitiesForSurface(surface, requested);
108
+ if (requested.length > 0 && narrowed.length === 0) {
109
+ throw new SiteAuthoringError(
110
+ "surface.capabilities_unavailable",
111
+ `'${invocation.verb ?? "This verb"}' declares no capability the ${surface} surface offers, so no credential `
112
+ + "can be minted for it there.",
113
+ { field: "authoringSurface", status: surface, exitCode: 2 },
114
+ );
115
+ }
116
+ return narrowed;
117
+ }
118
+
78
119
  function siteMissingError() {
79
120
  return new SiteAuthoringError(
80
121
  "config.site_missing",
@@ -189,6 +230,7 @@ export async function openSession(invocation = {}) {
189
230
  const apiBaseUrl = await resolveApiBaseUrl(environment);
190
231
  const config = await loadConfig(invocation);
191
232
  if (!config.siteId) throw siteMissingError();
233
+ requireRecordedSurface(invocation, config);
192
234
  const client = invocation.client
193
235
  ?? new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, environmentToken));
194
236
  // No exchange happens on this path, and nothing else on the contract reads
@@ -199,6 +241,7 @@ export async function openSession(invocation = {}) {
199
241
  config,
200
242
  client,
201
243
  siteId: config.siteId,
244
+ surface: config.authoringSurface,
202
245
  platform: UNKNOWN_PLATFORM,
203
246
  release: UNKNOWN_CLI_RELEASE,
204
247
  now,
@@ -214,6 +257,7 @@ export async function openSession(invocation = {}) {
214
257
  return apiBaseUrl;
215
258
  });
216
259
  if (!config.siteId) throw siteMissingError();
260
+ requireRecordedSurface(invocation, config);
217
261
 
218
262
  // The server is the authority on expiry, so an expired sign-in is still sent
219
263
  // — the clock here may simply be wrong. What is worth saying out loud is that
@@ -233,6 +277,7 @@ export async function openSession(invocation = {}) {
233
277
  config,
234
278
  client: invocation.client,
235
279
  siteId: config.siteId,
280
+ surface: config.authoringSurface,
236
281
  platform: UNKNOWN_PLATFORM,
237
282
  release: UNKNOWN_CLI_RELEASE,
238
283
  now,
@@ -247,15 +292,42 @@ export async function openSession(invocation = {}) {
247
292
  // as a field name and nothing else. That matters most for `cli_outdated`,
248
293
  // whose whole remedy is a command the operator has to be handed (TR00703),
249
294
  // but it is true of every classified refusal the exchange can raise.
250
- const exchanged = await withRefusalGuidance(
251
- onProgress,
252
- "sign-in token exchange",
253
- async () =>
254
- await exchangeSiteAuthoringToken(accountClient, {
255
- siteId: config.siteId,
256
- capabilities: invocation.capabilities ?? [],
257
- }),
258
- );
295
+ let exchanged;
296
+ try {
297
+ exchanged = await withRefusalGuidance(
298
+ onProgress,
299
+ "sign-in token exchange",
300
+ async () =>
301
+ await exchangeSiteAuthoringToken(accountClient, {
302
+ siteId: config.siteId,
303
+ // Narrowed to what the recorded surface offers (TR00790): a managed
304
+ // Docs site's exchange refuses Content by name, and a verb that only
305
+ // reads with it — deploy, pull — still runs there without it.
306
+ capabilities: narrowedCapabilities(invocation, config.authoringSurface),
307
+ }),
308
+ );
309
+ } catch (error) {
310
+ // A site the exchange no longer offers answers Not found, and one way a
311
+ // site that was offered stops being offered is a Docs source becoming
312
+ // prebuilt after `use` recorded it (TR00790). The server deliberately says
313
+ // nothing more — the answer is the same for a site that never existed —
314
+ // so the recording is what makes the hint possible, and the hint is what
315
+ // turns "not found" into the command that fixes it.
316
+ if (error instanceof ApiError && error.httpStatus === 404 && config.authoringSurface !== undefined) {
317
+ onProgress(
318
+ `The site recorded in the configuration (${config.authoringSurface}) is no longer available to author. `
319
+ + "If it is a Docs site, its publication mode may have changed; run "
320
+ + `'${CLI_BINARY_NAME} ${VERB_USE} <site>' again to see what it accepts now.`,
321
+ );
322
+ }
323
+ throw error;
324
+ }
325
+ // The live answer outranks the recording. A site whose Docs source moved
326
+ // since `use` ran is refused here, before any request the server would
327
+ // refuse anyway, and the message says to record the new surface.
328
+ if (invocation.surface !== undefined && !surfaceAllows(exchanged.authoringSurface, invocation.surface)) {
329
+ throw surfaceRefusal(invocation.verb ?? "This verb", exchanged.authoringSurface, { source: "exchange" });
330
+ }
259
331
  // Two non-secret facts the store should now carry: the sign-in's slid
260
332
  // deadline, and what this exchange actually produced — the latter is the only
261
333
  // thing that lets `whoami` answer offline with the real grant rather than the
@@ -306,6 +378,7 @@ export async function openSession(invocation = {}) {
306
378
  config,
307
379
  client,
308
380
  siteId: config.siteId,
381
+ surface: exchanged.authoringSurface,
309
382
  exchanged: {
310
383
  keyId: exchanged.keyId,
311
384
  keyPrefix: exchanged.keyPrefix,
package/src/surface.js ADDED
@@ -0,0 +1,152 @@
1
+ import { CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS, CAPABILITY_DESIGN } from "./capabilities.js";
2
+ import {
3
+ AUTHORING_SURFACES,
4
+ CLI_BINARY_NAME,
5
+ SURFACE_DOCS_PRESENTATION,
6
+ SURFACE_NONE,
7
+ SURFACE_STANDARD,
8
+ VERB_USE,
9
+ } from "./constants.js";
10
+ import { SiteAuthoringError } from "./errors.js";
11
+
12
+ /**
13
+ * Which authoring verbs a site accepts (TR00790), as the CLI reads it.
14
+ *
15
+ * Taproot resolves the surface from a site's kind and, for a Docs site, its
16
+ * current publication mode: a STANDARD site takes every verb, a managed Docs
17
+ * site takes only the design and theming surface (its pages, navigation, and
18
+ * redirects come from the artifact the Docs shell renders), and a prebuilt
19
+ * Docs site takes nothing, because it serves its artifact's file tree
20
+ * unchanged. The server re-resolves it on every write; what the CLI holds is a
21
+ * recording, used to refuse a verb before a request that would be refused
22
+ * anyway and to say why in words rather than a field name.
23
+ */
24
+
25
+ const WIRE_SURFACES = Object.freeze({
26
+ SITE_AUTHORING_SURFACE_STANDARD: SURFACE_STANDARD,
27
+ SITE_AUTHORING_SURFACE_DOCS_PRESENTATION: SURFACE_DOCS_PRESENTATION,
28
+ SITE_AUTHORING_SURFACE_NONE: SURFACE_NONE,
29
+ });
30
+
31
+ const WIRE_SITE_KINDS = Object.freeze({
32
+ SITE_TYPE_STANDARD: "standard",
33
+ SITE_TYPE_DOCS: "docs",
34
+ SITE_TYPE_PROFILE: "profile",
35
+ });
36
+
37
+ const WIRE_DOCS_MODES = Object.freeze({
38
+ DOCS_PUBLICATION_MODE_MANAGED: "managed",
39
+ DOCS_PUBLICATION_MODE_PREBUILT: "prebuilt",
40
+ });
41
+
42
+ // Wider surfaces satisfy narrower requirements, so one comparison decides.
43
+ const SURFACE_WIDTH = Object.freeze({
44
+ [SURFACE_NONE]: 0,
45
+ [SURFACE_DOCS_PRESENTATION]: 1,
46
+ [SURFACE_STANDARD]: 2,
47
+ });
48
+
49
+ /**
50
+ * The capabilities each surface's exchange may carry. Content is absent from
51
+ * the Docs surface because the site has no pages for it to write, and an
52
+ * exchange that asks for it anyway is refused by name.
53
+ */
54
+ const SURFACE_CAPABILITIES = Object.freeze({
55
+ [SURFACE_STANDARD]: Object.freeze([CAPABILITY_CONTENT, CAPABILITY_DESIGN, CAPABILITY_DEPLOYMENTS]),
56
+ [SURFACE_DOCS_PRESENTATION]: Object.freeze([CAPABILITY_DESIGN, CAPABILITY_DEPLOYMENTS]),
57
+ [SURFACE_NONE]: Object.freeze([]),
58
+ });
59
+
60
+ /**
61
+ * Reads the wire enum. Anything unrecognized — absent, unspecified, or a value
62
+ * this CLI does not know — is `undefined`: not a surface, so nothing gates on
63
+ * it. The CLI's gate is a courtesy ahead of the server's own refusal, and a
64
+ * courtesy that refused on a value it could not read would refuse for the
65
+ * wrong reason; the server still refuses what the site cannot take.
66
+ */
67
+ export function surfaceFromWire(value) {
68
+ return typeof value === "string" && Object.hasOwn(WIRE_SURFACES, value) ? WIRE_SURFACES[value] : undefined;
69
+ }
70
+
71
+ export function siteKindFromWire(value) {
72
+ return typeof value === "string" && Object.hasOwn(WIRE_SITE_KINDS, value) ? WIRE_SITE_KINDS[value] : "unknown";
73
+ }
74
+
75
+ /** Absent for a STANDARD site, which has no Docs mode at all. */
76
+ export function docsPublicationModeFromWire(value) {
77
+ return typeof value === "string" && Object.hasOwn(WIRE_DOCS_MODES, value) ? WIRE_DOCS_MODES[value] : undefined;
78
+ }
79
+
80
+ export function isKnownSurface(value) {
81
+ return typeof value === "string" && AUTHORING_SURFACES.includes(value);
82
+ }
83
+
84
+ /**
85
+ * Whether a site of `surface` accepts a verb that needs `required`. An
86
+ * unknown surface on either side allows: there is nothing to gate on, and the
87
+ * server decides.
88
+ */
89
+ export function surfaceAllows(surface, required) {
90
+ return SURFACE_WIDTH[surface] === undefined
91
+ || SURFACE_WIDTH[required] === undefined
92
+ || SURFACE_WIDTH[surface] >= SURFACE_WIDTH[required];
93
+ }
94
+
95
+ /** The verb's capability request, narrowed to what its site's surface offers. */
96
+ export function capabilitiesForSurface(surface, requested) {
97
+ const offered = SURFACE_CAPABILITIES[surface];
98
+ if (offered === undefined) return requested;
99
+ return requested.filter((capability) => offered.includes(capability));
100
+ }
101
+
102
+ /** One short phrase per surface, for listings and `whoami`. */
103
+ export function describeSurface(surface) {
104
+ switch (surface) {
105
+ case SURFACE_STANDARD:
106
+ return "every authoring verb";
107
+ case SURFACE_DOCS_PRESENTATION:
108
+ return "design and theming only; pages, navigation, and redirects come from the Docs artifact";
109
+ case SURFACE_NONE:
110
+ return "no authoring verb; a prebuilt Docs site serves its artifact unchanged";
111
+ default:
112
+ return "not reported by Taproot; each verb is decided on the server";
113
+ }
114
+ }
115
+
116
+ /** How a listing labels a site's kind, with the Docs mode when it has one. */
117
+ export function describeSiteKind(site) {
118
+ if (site.siteType === "docs") {
119
+ return site.docsPublicationMode === undefined ? "docs" : `docs, ${site.docsPublicationMode}`;
120
+ }
121
+ return site.siteType;
122
+ }
123
+
124
+ /**
125
+ * The refusal a verb raises when its site's surface is too narrow. Two codes,
126
+ * one per surface, so an agent can branch on which without parsing prose; the
127
+ * message names the verb, the surface, and the remedy when there is one.
128
+ *
129
+ * `source` says where the surface came from: the configuration `use` wrote,
130
+ * or the exchange that just answered. A refusal from a recording says how to
131
+ * refresh it; a refusal from the live answer says the site changed.
132
+ */
133
+ export function surfaceRefusal(verb, surface, { source = "config" } = {}) {
134
+ const remedy = source === "config"
135
+ ? ` This is the surface recorded by '${CLI_BINARY_NAME} ${VERB_USE}'; run it again if the site has changed.`
136
+ : ` Taproot reported this on the exchange just now; run '${CLI_BINARY_NAME} ${VERB_USE}' again to record it.`;
137
+ if (surface === SURFACE_DOCS_PRESENTATION) {
138
+ return new SiteAuthoringError(
139
+ "surface.presentation_only",
140
+ `'${verb}' does not apply to a managed Docs site: its pages, navigation, and redirects come from the Docs `
141
+ + "artifact, and the CLI authors only its design — theme, brand, header, and footer settings, the images "
142
+ + `they reference, and their deployment.${remedy}`,
143
+ { field: "authoringSurface", status: surface, exitCode: 2 },
144
+ );
145
+ }
146
+ return new SiteAuthoringError(
147
+ "surface.none",
148
+ `'${verb}' does not apply to this site: a prebuilt Docs site serves its artifact's files unchanged, so no `
149
+ + `authoring verb applies to it.${remedy}`,
150
+ { field: "authoringSurface", status: SURFACE_NONE, exitCode: 2 },
151
+ );
152
+ }
@@ -10,7 +10,7 @@ import {
10
10
  waitForDeployment,
11
11
  withRefusalGuidance,
12
12
  } from "../api.js";
13
- import { DEPLOY_TARGET_PRODUCTION, DEPLOY_TARGET_STAGING, VERB_DEPLOY } from "../constants.js";
13
+ import { DEPLOY_TARGET_PRODUCTION, DEPLOY_TARGET_STAGING, SURFACE_DOCS_PRESENTATION, VERB_DEPLOY } from "../constants.js";
14
14
  import { SiteAuthoringError } from "../errors.js";
15
15
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
16
16
  import { checkStagingRedirects } from "../staging-check.js";
@@ -167,7 +167,12 @@ export async function deploy(invocation) {
167
167
  );
168
168
  }
169
169
  const session = await openSession(invocation);
170
- const { client, config, siteId, now, onProgress } = session;
170
+ const { client, config, siteId, surface, now, onProgress } = session;
171
+ // A managed Docs site deploys its settings and nothing else (TR00790): the
172
+ // Docs shell takes pages, navigation, and redirects from the artifact, so
173
+ // the staging redirect inspection that follows a standard deployment has
174
+ // nothing to inspect and the handoff it mints is refused there.
175
+ const presentationOnly = surface === SURFACE_DOCS_PRESENTATION;
171
176
  // One advisory line before this verb does any work, and only when the
172
177
  // exchange said the platform is paused. It changes nothing else: the write
173
178
  // still runs and its refusal still classifies as platform_paused (TR00692).
@@ -203,26 +208,49 @@ export async function deploy(invocation) {
203
208
  now,
204
209
  });
205
210
  await recordDeployment(config, manifest, "production", completed);
206
- onProgress(REDIRECT_PROPAGATION_NOTE);
211
+ if (!presentationOnly) onProgress(REDIRECT_PROPAGATION_NOTE);
207
212
  return successResult(VERB_DEPLOY, siteId, {
208
213
  target,
209
214
  environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
215
+ ...(presentationOnly ? { authoringSurface: SURFACE_DOCS_PRESENTATION } : {}),
210
216
  promotedStagingDeploymentId: stagingDeploymentId,
211
217
  deployment: completed,
212
218
  readiness: reportReadiness(readiness),
213
219
  });
214
220
  }
215
221
 
222
+ // An explicit page or navigation selection on a managed Docs site is
223
+ // refused before the review is read: the caller asked for something the
224
+ // site cannot take. The review's own defaults are a different matter,
225
+ // handled below.
226
+ if (
227
+ presentationOnly
228
+ && ((selection.stagedPageIds?.length ?? 0) > 0 || selection.includeNavigation === true)
229
+ ) {
230
+ throw usageError(
231
+ "deploy.presentation_only",
232
+ "A managed Docs site deploys settings only: its pages and navigation come from the Docs artifact, so a "
233
+ + "candidate naming pages or navigation cannot be staged from here.",
234
+ "Candidate",
235
+ );
236
+ }
216
237
  onProgress("Reading the changed release set shown on the Deployments page.");
217
238
  const defaults = await getDeploySelection(client, siteId);
218
- const stagedPageIds = selection.stagedPageIds ?? defaults.stagedPageIds;
239
+ // The review is site-type-blind, and a Docs site's settings-only
240
+ // production manifest makes any draft navigation row read as a change
241
+ // forever. The Deployments page zeroes pages and navigation for a Docs
242
+ // site rather than refusing on them, and this does the same (TR00790).
243
+ const stagedPageIds = presentationOnly ? [] : selection.stagedPageIds ?? defaults.stagedPageIds;
219
244
  const selectedSettingsTypes = selection.selectedSettingsTypes ?? defaults.selectedSettingsTypes;
220
- const includeNavigation = selection.includeNavigation ?? defaults.includeNavigation;
245
+ const includeNavigation = presentationOnly ? false : selection.includeNavigation ?? defaults.includeNavigation;
221
246
  if (stagedPageIds.length === 0 && selectedSettingsTypes.length === 0 && !includeNavigation) {
222
247
  throw new SiteAuthoringError(
223
248
  "deploy.empty_selection",
224
- "A staging deployment needs at least one approved page, settings group, or navigation change. "
225
- + "Run 'taproot-site approve' or change staged settings/navigation first.",
249
+ presentationOnly
250
+ ? "A staging deployment on a managed Docs site needs at least one settings change; it stages settings "
251
+ + "only. Change a theme, brand, header, or footer setting first."
252
+ : "A staging deployment needs at least one approved page, settings group, or navigation change. "
253
+ + "Run 'taproot-site approve' or change staged settings/navigation first.",
226
254
  { field: "Candidate" },
227
255
  );
228
256
  }
@@ -269,6 +297,22 @@ export async function deploy(invocation) {
269
297
  now,
270
298
  });
271
299
  await recordDeployment(config, manifest, "staging", completed);
300
+ if (presentationOnly) {
301
+ onProgress("Review the staged presentation on the site's staging host, then promote it.");
302
+ return successResult(VERB_DEPLOY, siteId, {
303
+ target,
304
+ environment: DEPLOYMENT_ENVIRONMENT_STAGING,
305
+ authoringSurface: SURFACE_DOCS_PRESENTATION,
306
+ selection: {
307
+ stagedPageCount: 0,
308
+ selectedSettingsTypes,
309
+ includeNavigation: false,
310
+ },
311
+ deployment: completed,
312
+ readiness: reportReadiness(readiness),
313
+ nextStep: "deploy --production",
314
+ });
315
+ }
272
316
  const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
273
317
  onProgress(REDIRECT_PROPAGATION_NOTE);
274
318
  return successResult(VERB_DEPLOY, siteId, {
package/src/verbs/pull.js CHANGED
@@ -10,7 +10,12 @@ import {
10
10
  TEMPLATE_TYPE_FREE_FORM,
11
11
  withRefusalGuidance,
12
12
  } from "../api.js";
13
- import { REFUSAL_CAPABILITY_MISSING, REFUSAL_UNCLASSIFIED, VERB_PULL } from "../constants.js";
13
+ import {
14
+ REFUSAL_CAPABILITY_MISSING,
15
+ REFUSAL_UNCLASSIFIED,
16
+ SURFACE_DOCS_PRESENTATION,
17
+ VERB_PULL,
18
+ } from "../constants.js";
14
19
  import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
15
20
  import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
16
21
  import { describeJsonDifferences, reportableDifferencePaths } from "../json-path-diff.js";
@@ -560,16 +565,31 @@ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
560
565
  }
561
566
 
562
567
  export async function pull(invocation) {
563
- const { client, config, siteId, now, onProgress } = await openSession(invocation);
568
+ const { client, config, siteId, surface, now, onProgress } = await openSession(invocation);
564
569
  await ensureWorkspaceRoot(config);
565
570
 
566
571
  await requireWorkspaceBelongsToSite(config.workspaceDir, siteId);
567
572
 
568
573
  const registry = await readSourceRegistry(config.workspaceDir, siteId);
569
574
 
575
+ // A managed Docs site has no pages, navigation, or redirects of its own to
576
+ // snapshot (TR00790): the Docs shell takes those from the artifact, and the
577
+ // reads would be refused. What it has is the four settings documents, which
578
+ // are the whole of what this workspace holds for it.
579
+ const presentationOnly = surface === SURFACE_DOCS_PRESENTATION;
580
+
570
581
  return await withRefusalGuidance(onProgress, "pull", async () => {
571
- onProgress("Listing the site's pages.");
572
- const { pages, truncated } = await listSitePages(client, siteId, { onProgress });
582
+ let pages = [];
583
+ let truncated = false;
584
+ if (presentationOnly) {
585
+ onProgress(
586
+ "This is a managed Docs site: pulling its settings only; pages, navigation, and redirects are not "
587
+ + "authored here.",
588
+ );
589
+ } else {
590
+ onProgress("Listing the site's pages.");
591
+ ({ pages, truncated } = await listSitePages(client, siteId, { onProgress }));
592
+ }
573
593
  const live = pages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
574
594
  const freeForm = live.filter((summary) => summary.templateType === TEMPLATE_TYPE_FREE_FORM);
575
595
  const tracked = await resolveTrackedSources(config.workspaceDir, freeForm, registry, onProgress);
@@ -581,22 +601,24 @@ export async function pull(invocation) {
581
601
  // predates the map answers the route with 404; that pull then records no
582
602
  // redirect baseline, and `redirects push` asks for a pull against a site
583
603
  // that has one rather than guessing.
584
- onProgress("Reading the redirect map.");
585
604
  let redirectMap;
586
- try {
587
- redirectMap = await getSiteRedirectMap(client, siteId);
588
- } catch (error) {
589
- if (
590
- error instanceof ApiError
591
- && error.refusalKind() === REFUSAL_UNCLASSIFIED
592
- && error.httpStatus === 404
593
- ) {
594
- onProgress(
595
- `This Taproot does not serve a redirect map yet; ${REDIRECTS_FILE_NAME} is not written and the manifest `
596
- + "records no redirect baseline.",
597
- );
598
- } else {
599
- throw error;
605
+ if (!presentationOnly) {
606
+ onProgress("Reading the redirect map.");
607
+ try {
608
+ redirectMap = await getSiteRedirectMap(client, siteId);
609
+ } catch (error) {
610
+ if (
611
+ error instanceof ApiError
612
+ && error.refusalKind() === REFUSAL_UNCLASSIFIED
613
+ && error.httpStatus === 404
614
+ ) {
615
+ onProgress(
616
+ `This Taproot does not serve a redirect map yet; ${REDIRECTS_FILE_NAME} is not written and the manifest `
617
+ + "records no redirect baseline.",
618
+ );
619
+ } else {
620
+ throw error;
621
+ }
600
622
  }
601
623
  }
602
624
 
@@ -988,9 +1010,12 @@ export async function pull(invocation) {
988
1010
  manifestPages.push(entry);
989
1011
  }
990
1012
 
991
- onProgress("Reading the navigation tree.");
992
- const navItems = await getNavigation(client, siteId);
993
- await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, { siteId, navItems });
1013
+ let navItems;
1014
+ if (!presentationOnly) {
1015
+ onProgress("Reading the navigation tree.");
1016
+ navItems = await getNavigation(client, siteId);
1017
+ await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, { siteId, navItems });
1018
+ }
994
1019
 
995
1020
  if (redirectMap !== undefined) {
996
1021
  await writeWorkspaceJson(
@@ -1008,8 +1033,12 @@ export async function pull(invocation) {
1008
1033
  manifestVersion: MANIFEST_VERSION,
1009
1034
  siteId,
1010
1035
  pulledAt: new Date(now()).toISOString(),
1036
+ // Recorded so an offline reader of this workspace — validate, a later
1037
+ // push — knows the absence of pages and navigation is the site's shape,
1038
+ // not a partial pull.
1039
+ ...(presentationOnly ? { authoringSurface: SURFACE_DOCS_PRESENTATION } : {}),
1011
1040
  pagesTruncated: truncated,
1012
- navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
1041
+ ...(navItems === undefined ? {} : { navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length } }),
1013
1042
  ...(redirectMap === undefined ? {} : { redirects: redirectsManifestEntry(redirectMap) }),
1014
1043
  settings: pulledSettings,
1015
1044
  settingsSkipped: skippedSettings,
@@ -1046,6 +1075,7 @@ export async function pull(invocation) {
1046
1075
  );
1047
1076
  return successResult(VERB_PULL, siteId, {
1048
1077
  manifestFile: MANIFEST_FILE_NAME,
1078
+ ...(presentationOnly ? { authoringSurface: SURFACE_DOCS_PRESENTATION } : {}),
1049
1079
  pages: {
1050
1080
  total: manifestPages.length,
1051
1081
  bodies,
@@ -1059,7 +1089,7 @@ export async function pull(invocation) {
1059
1089
  items: reported.items,
1060
1090
  ...(reported.truncated ? { itemsTruncated: true } : {}),
1061
1091
  },
1062
- navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
1092
+ ...(navItems === undefined ? {} : { navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length } }),
1063
1093
  // Absent, not zeroed, when the site served no map: an agent reading
1064
1094
  // `entries: 0` would take the site for one with no redirects.
1065
1095
  ...(redirectMap === undefined ? {} : {
@@ -1,19 +1,20 @@
1
1
  import { listAuthorableSites, withRefusalGuidance } from "../api.js";
2
- import { CLI_BINARY_NAME, VERB_SITES, VERB_USE } from "../constants.js";
2
+ import { CLI_BINARY_NAME, SURFACE_STANDARD, VERB_SITES, VERB_USE } from "../constants.js";
3
3
  import { sanitizeDiagnostic } from "../errors.js";
4
4
  import { boundedList, openAccountSession, successResult } from "../session.js";
5
+ import { describeSiteKind, describeSurface } from "../surface.js";
5
6
 
6
7
  /**
7
- * `taproot-site sites` — the sites this sign-in may author.
8
+ * `taproot-site sites` — this account's sites and what each accepts.
8
9
  *
9
10
  * The answer that used to require reading a site id out of the browser's URL
10
11
  * bar and pasting it into a hand-written configuration file (TR00645). It runs
11
12
  * on the sign-in credential, which is the only thing that can enumerate an
12
13
  * account's sites, and it is one of exactly two things that credential can do.
13
14
  *
14
- * The server has already filtered the list to sites an exchange would accept,
15
- * so everything printed here is something `use` can select and the next command
16
- * can author.
15
+ * Every standard and Docs site is listed with its authoring surface (TR00790).
16
+ * A Docs site that accepts no verb is shown rather than hidden: an operator who
17
+ * cannot see a site they own cannot learn why the CLI refuses it.
17
18
  */
18
19
 
19
20
  /**
@@ -31,16 +32,20 @@ export async function sites(invocation) {
31
32
  const reported = boundedList(authorable, MAXIMUM_SITES);
32
33
 
33
34
  if (authorable.length === 0) {
34
- onProgress("This account has no sites that can be authored from the CLI.");
35
+ onProgress("This account has no standard or Docs sites.");
35
36
  } else {
36
37
  for (const site of reported.items) {
37
38
  // Sanitized on the way to the terminal, raw in the JSON result. Site
38
39
  // names are authored by people and can carry terminal controls or bidi
39
40
  // overrides; a listing is exactly where that would be used to make one
40
- // site read as another the operator then selects.
41
+ // site read as another the operator then selects. The domain gets the
42
+ // same treatment; the kind and surface are this CLI's own words.
41
43
  const name = sanitizeDiagnostic(site.name, site.siteId);
42
44
  const domain = site.primaryDomain ? ` ${sanitizeDiagnostic(site.primaryDomain, "")}` : "";
43
- onProgress(`${site.siteId} ${name}${domain}`);
45
+ onProgress(`${site.siteId} ${name} [${describeSiteKind(site)}]${domain}`);
46
+ if (site.authoringSurface !== SURFACE_STANDARD) {
47
+ onProgress(` ${site.authoringSurface ?? "surface unknown"}: ${describeSurface(site.authoringSurface)}.`);
48
+ }
44
49
  }
45
50
  onProgress(`Select one with '${CLI_BINARY_NAME} ${VERB_USE} <name or id>'.`);
46
51
  }