@taprootio/site-authoring 0.7.2 → 0.8.1
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 +36 -13
- package/package.json +2 -2
- package/src/api.js +15 -4
- package/src/cli.js +68 -5
- package/src/config.js +15 -0
- package/src/constants.js +10 -1
- package/src/content/free-form-section-registry.json +44 -1
- package/src/content/tiptap-prosemirror.ts +38 -0
- package/src/content/validate-document.js +17 -0
- package/src/content/vocabulary.js +2 -0
- package/src/fixture-init.js +5 -0
- package/src/reference-help.js +12 -1
- package/src/session.js +83 -10
- package/src/surface.js +152 -0
- package/src/verbs/deploy.js +51 -7
- package/src/verbs/pull.js +54 -24
- package/src/verbs/sites.js +13 -8
- package/src/verbs/status.js +43 -9
- package/src/verbs/use.js +22 -3
- package/src/verbs/whoami.js +9 -1
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
}
|
package/src/verbs/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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 {
|
|
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
|
-
|
|
572
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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 ? {} : {
|
package/src/verbs/sites.js
CHANGED
|
@@ -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` —
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
}
|