@taprootio/site-authoring 0.1.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/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- package/src/workspace.js +1085 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLI_BINARY_NAME,
|
|
3
|
+
CLI_NAME,
|
|
4
|
+
CLI_VERSION,
|
|
5
|
+
DEFAULT_LOGIN_KEY_NAME,
|
|
6
|
+
DEPLOY_TARGET_PRODUCTION,
|
|
7
|
+
DEPLOY_TARGET_STAGING,
|
|
8
|
+
LIMITS,
|
|
9
|
+
LOGIN_KEY_NAME_MAXIMUM,
|
|
10
|
+
PUBLISH_KEY_ENVIRONMENT_VARIABLE,
|
|
11
|
+
RESULT_SCHEMA_VERSION,
|
|
12
|
+
VERB_APPROVE,
|
|
13
|
+
VERB_DEPLOY,
|
|
14
|
+
VERB_ENV,
|
|
15
|
+
VERB_FOOTER_PUSH,
|
|
16
|
+
VERB_HELP,
|
|
17
|
+
VERB_LOGIN,
|
|
18
|
+
VERB_LOGOUT,
|
|
19
|
+
VERB_MEDIA_UPLOAD,
|
|
20
|
+
VERB_NAV_PUSH,
|
|
21
|
+
VERB_PAGES_PUSH,
|
|
22
|
+
VERB_PREVIEW_PAGE,
|
|
23
|
+
VERB_PREVIEW_REVOKE,
|
|
24
|
+
VERB_PULL,
|
|
25
|
+
VERB_SITES,
|
|
26
|
+
VERB_STATUS,
|
|
27
|
+
VERB_THEME_PUSH,
|
|
28
|
+
VERB_USE,
|
|
29
|
+
VERB_VALIDATE,
|
|
30
|
+
VERB_WHOAMI,
|
|
31
|
+
} from "./constants.js";
|
|
32
|
+
import {
|
|
33
|
+
asSiteAuthoringError,
|
|
34
|
+
hasAsciiControl,
|
|
35
|
+
isCanonicalUuid,
|
|
36
|
+
normalizePreviewRecovery,
|
|
37
|
+
SiteAuthoringError,
|
|
38
|
+
} from "./errors.js";
|
|
39
|
+
import { failureResult, humanFailure, serializeResult, writeGithubActionsOutput } from "./output.js";
|
|
40
|
+
import {
|
|
41
|
+
formatReferenceResult,
|
|
42
|
+
getAppearanceReference,
|
|
43
|
+
getComponentReference,
|
|
44
|
+
getFooterReference,
|
|
45
|
+
getPageTypeReference,
|
|
46
|
+
getThemeReference,
|
|
47
|
+
getWorkflowReference,
|
|
48
|
+
listComponentTypeReferences,
|
|
49
|
+
listPageTypeReferences,
|
|
50
|
+
PAGE_TYPES,
|
|
51
|
+
REFERENCE_TOPICS,
|
|
52
|
+
REFERENCE_VERSION,
|
|
53
|
+
} from "./reference-help.js";
|
|
54
|
+
import {
|
|
55
|
+
CAPABILITY_CONTENT,
|
|
56
|
+
CAPABILITY_DEPLOYMENTS,
|
|
57
|
+
CAPABILITY_DESIGN,
|
|
58
|
+
} from "./capabilities.js";
|
|
59
|
+
import { VERB_HANDLERS } from "./verbs/index.js";
|
|
60
|
+
|
|
61
|
+
// One entry per verb, in help order. `tokens` is the exact leading positional
|
|
62
|
+
// sequence; matching prefers the longest sequence, so a two-token family can
|
|
63
|
+
// never be shadowed by a one-token verb.
|
|
64
|
+
const VERBS = Object.freeze([
|
|
65
|
+
{
|
|
66
|
+
name: VERB_HELP,
|
|
67
|
+
tokens: ["help"],
|
|
68
|
+
summary: "Show offline authoring reference help for agents.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: VERB_VALIDATE,
|
|
72
|
+
tokens: ["validate"],
|
|
73
|
+
summary: "Validate a complete authoring fixture without credentials or mutation.",
|
|
74
|
+
positionals: "fixturePath",
|
|
75
|
+
offline: true,
|
|
76
|
+
note: "Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
|
|
77
|
+
+ "This proves local structure and semantics only. It does not prove authorization, live site ownership, concurrency, "
|
|
78
|
+
+ "persisted round trips, or rendering; run a real pull and authorized preview before deployment.",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: VERB_LOGIN,
|
|
82
|
+
tokens: ["login"],
|
|
83
|
+
summary: "Authorize this CLI against a Taproot account through a browser approval.",
|
|
84
|
+
credentialFree: true,
|
|
85
|
+
configFree: true,
|
|
86
|
+
keyName: true,
|
|
87
|
+
note: "Starts a device-authorization exchange, prints the approval URL and an eight-character code, and waits for "
|
|
88
|
+
+ "an owner to enter that code and approve in a browser they are already signed in to. Typing the code — rather "
|
|
89
|
+
+ "than following a prefilled link — is what proves the approver can see this terminal. "
|
|
90
|
+
+ "It needs no site and no taproot-site.json: what it authorizes is the account, so it works in any directory "
|
|
91
|
+
+ "immediately after install. Choose a site afterwards with 'sites' and 'use'. "
|
|
92
|
+
+ "The approval URL is composed from the reviewed API origin and is never taken from a response. "
|
|
93
|
+
+ "The issued sign-in is written to credentials.json under $XDG_CONFIG_HOME/taproot-site/ (falling back to "
|
|
94
|
+
+ "~/.config/taproot-site/), directory 0700 and file 0600, one per API origin, replacing any sign-in already "
|
|
95
|
+
+ "stored for that origin. The secret itself is never displayed, logged, or placed in the JSON result: the "
|
|
96
|
+
+ "result names the credential by id and display prefix only. "
|
|
97
|
+
+ "The sign-in authorizes nothing on any site — it lists the account's sites and exchanges itself for "
|
|
98
|
+
+ "short-lived site credentials. It expires after 24 hours without a successful exchange, and any "
|
|
99
|
+
+ "expiry the approver chose bounds that: using the CLI keeps it alive, but never past their date. "
|
|
100
|
+
+ `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} always takes precedence over the stored sign-in and skips the exchange `
|
|
101
|
+
+ "entirely, so existing automation is unaffected by logging in. "
|
|
102
|
+
+ "--quiet is rejected for this verb: the approval URL and code reach the operator only as progress, before "
|
|
103
|
+
+ "any JSON result exists, so silencing them would make the approval impossible to complete. "
|
|
104
|
+
+ "A denied, expired, or timed-out approval stores nothing. If Taproot reports the authorization was already "
|
|
105
|
+
+ "claimed, a credential was issued that this command never received: revoke it under Account -> Settings -> "
|
|
106
|
+
+ "API keys.",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: VERB_LOGOUT,
|
|
110
|
+
tokens: ["logout"],
|
|
111
|
+
summary: "Discard the stored Taproot sign-in.",
|
|
112
|
+
credentialFree: true,
|
|
113
|
+
configFree: true,
|
|
114
|
+
note: "Removes the stored sign-in for this API origin and reports whether one was there. This is a local discard "
|
|
115
|
+
+ "only: it does not revoke anything, and the credential stays valid until an owner revokes it under "
|
|
116
|
+
+ "Account -> Settings -> API keys. Logging out with nothing stored is a success.",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: VERB_SITES,
|
|
120
|
+
tokens: ["sites"],
|
|
121
|
+
summary: "List the sites this sign-in may author.",
|
|
122
|
+
configFree: true,
|
|
123
|
+
note: "Runs on the stored sign-in rather than a site credential, and is one of exactly two things that credential "
|
|
124
|
+
+ "can do. The list is already filtered to sites an exchange would accept, so anything shown here is a site "
|
|
125
|
+
+ "'use' can select and the next command can author.",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: VERB_USE,
|
|
129
|
+
tokens: ["use"],
|
|
130
|
+
summary: "Choose the site the next command writes to.",
|
|
131
|
+
configFree: true,
|
|
132
|
+
positionals: "siteSelector",
|
|
133
|
+
note: "Accepts a site id, an exact name, or an unambiguous case-insensitive name, and records the choice as "
|
|
134
|
+
+ "siteId in taproot-site.json — creating that file in the current directory when there is not one yet. "
|
|
135
|
+
+ "Two sites sharing a name is refused rather than guessed: pass the site id instead.",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: VERB_WHOAMI,
|
|
139
|
+
tokens: ["whoami"],
|
|
140
|
+
summary: "Report the Taproot, account, site, and sign-in expiry in effect.",
|
|
141
|
+
credentialFree: true,
|
|
142
|
+
configFree: true,
|
|
143
|
+
offline: true,
|
|
144
|
+
// Offline but not self-contained: it reads both the store and the
|
|
145
|
+
// configuration, so it accepts --config and must not claim otherwise.
|
|
146
|
+
readsLocalState: true,
|
|
147
|
+
note: "Answers entirely from local state — the stored sign-in and the configuration — so it still works when the "
|
|
148
|
+
+ `network or the credential does not. Reports whether ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} is set, but never `
|
|
149
|
+
+ "its value. The sign-in secret is never printed; the credential is named by id and display prefix, which are "
|
|
150
|
+
+ "what an owner needs to revoke it.",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: VERB_ENV,
|
|
154
|
+
tokens: ["env"],
|
|
155
|
+
summary: "Show or switch which Taproot the CLI talks to.",
|
|
156
|
+
credentialFree: true,
|
|
157
|
+
configFree: true,
|
|
158
|
+
offline: true,
|
|
159
|
+
// Offline, but it reads the stored endpoint and the credential store to
|
|
160
|
+
// report whether you are signed in where you just switched to.
|
|
161
|
+
readsLocalState: true,
|
|
162
|
+
positionals: "environmentSelector",
|
|
163
|
+
note: "With no argument it reports the current Taproot and whether this machine is signed in to it; with "
|
|
164
|
+
+ "'production' or 'local' it switches. The choice is remembered per machine, beside the credential, because "
|
|
165
|
+
+ "it has to be known before any taproot-site.json exists — 'login', 'sites', and 'use' all run before the "
|
|
166
|
+
+ "file 'use' writes. Sign-ins are stored per origin, so switching away and back finds the one that was "
|
|
167
|
+
+ "already there. An explicit loopback URL ending in '/api' is accepted for development; nothing else is.",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: VERB_PULL,
|
|
171
|
+
// Pages and the four settings groups, and every one of those gates on
|
|
172
|
+
// site.theme.manage — so a read-only snapshot still needs Design.
|
|
173
|
+
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
|
|
174
|
+
tokens: ["pull"],
|
|
175
|
+
summary: "Snapshot pages, navigation, and settings into the local workspace.",
|
|
176
|
+
note: "Every page path keeps exactly one authoritative source. A page whose manifest entry names a source that is "
|
|
177
|
+
+ "still on disk keeps that file, so pull never writes a '.pm.json' beside a tracked '.md'. "
|
|
178
|
+
+ "For a page tracked as Markdown the site's own document is kept as internal state under "
|
|
179
|
+
+ "'.taproot-site-state/' instead; it is never a page source and is never pushed. "
|
|
180
|
+
+ "To change a page's source format, remove the tracked source and author the other format beside it — two "
|
|
181
|
+
+ "editable sources for one path is a refusal, not a guess. "
|
|
182
|
+
+ "Markdown is deliberately one-way, so a page edited on the site since the last pull cannot be rewritten as "
|
|
183
|
+
+ "Markdown: pull refuses with pages.pull_conflict before changing anything under 'pages/', preserves the "
|
|
184
|
+
+ "site's version under '.taproot-site-state/', and leaves you to either push the local source or delete it "
|
|
185
|
+
+ "and pull again. A locally edited '.pm.json' is kept rather than overwritten for the same reason.",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: VERB_PAGES_PUSH,
|
|
189
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
190
|
+
tokens: ["pages", "push"],
|
|
191
|
+
summary: "Create and update pages from the local workspace.",
|
|
192
|
+
positionals: "pagePaths",
|
|
193
|
+
allowRawHtml: true,
|
|
194
|
+
note: "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
|
|
195
|
+
+ "The homepage is recorded with an empty path, so address it as '/'. "
|
|
196
|
+
+ "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
|
|
197
|
+
+ "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
|
|
198
|
+
+ "resolution, system-page rules, path uniqueness against the live site, media ownership, and two workspace "
|
|
199
|
+
+ "files claiming one path all still fail closed. What a selection does not do is convert or validate the "
|
|
200
|
+
+ "documents of pages it is not sending: an unrelated page left on an obsolete contract is reported by the "
|
|
201
|
+
+ "whole-workspace push, not used to block this one. The result states the selection and how many sources were "
|
|
202
|
+
+ "discovered and validated. "
|
|
203
|
+
+ "The system 404 projection written by pull is read-only: an unchanged whole-workspace push verifies and skips it, "
|
|
204
|
+
+ "while a changed, missing, or replacement source is refused before any page mutation, and naming it in a "
|
|
205
|
+
+ "selection is refused outright. "
|
|
206
|
+
+ "See 'taproot-site help page free-form' for the stable manifest and error contract. "
|
|
207
|
+
+ "--allow-raw-html permits rawHtml nodes, which render verbatim and"
|
|
208
|
+
+ " unsanitized on the published site. Off by default on purpose; leave"
|
|
209
|
+
+ " it off unless the content is trusted hand-written markup.",
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: VERB_NAV_PUSH,
|
|
213
|
+
capabilities: [CAPABILITY_DESIGN],
|
|
214
|
+
tokens: ["nav", "push"],
|
|
215
|
+
summary: "Replace the whole navigation tree from the local workspace.",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: VERB_THEME_PUSH,
|
|
219
|
+
capabilities: [CAPABILITY_DESIGN],
|
|
220
|
+
tokens: ["theme", "push"],
|
|
221
|
+
summary: "Validate and push the workspace's complete theme and appearance settings.",
|
|
222
|
+
note: "Run pull first. Theme JSON stays decoded in the workspace; this command validates the complete light/dark"
|
|
223
|
+
+ " pair and encodes it only at the API boundary. Image settings reference site-owned image IDs from media upload.",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: VERB_FOOTER_PUSH,
|
|
227
|
+
capabilities: [CAPABILITY_DESIGN],
|
|
228
|
+
tokens: ["footer", "push"],
|
|
229
|
+
summary: "Validate and replace the workspace's complete footer document.",
|
|
230
|
+
note: "Run pull first. The command validates the whole closed footer locally, uses the pulled draft hash,"
|
|
231
|
+
+ " and refuses a concurrent remote edit with re-pull guidance.",
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: VERB_MEDIA_UPLOAD,
|
|
235
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
236
|
+
tokens: ["media", "upload"],
|
|
237
|
+
summary: "Upload media and wait for processing to finish.",
|
|
238
|
+
positionals: "paths",
|
|
239
|
+
note: "Positional arguments name the files or directories to upload;"
|
|
240
|
+
+ " with none, the workspace's media/ directory is walked.",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: VERB_APPROVE,
|
|
244
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
245
|
+
tokens: ["approve"],
|
|
246
|
+
summary: "Publish drafts. This stages the site; it does not deploy it.",
|
|
247
|
+
positionals: "pagePaths",
|
|
248
|
+
note: "Positional arguments narrow the selection to those page paths;"
|
|
249
|
+
+ " with none, every draft the workspace manifest tracks is staged."
|
|
250
|
+
+ " The homepage is recorded with an empty path, so address it as '/'.",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: VERB_DEPLOY,
|
|
254
|
+
capabilities: [CAPABILITY_DEPLOYMENTS],
|
|
255
|
+
tokens: ["deploy"],
|
|
256
|
+
summary: "Deploy to staging, or promote staging to production.",
|
|
257
|
+
target: true,
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: VERB_PREVIEW_PAGE,
|
|
261
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
262
|
+
tokens: ["preview", "page"],
|
|
263
|
+
summary: "Render one persisted draft and mint a short-lived staging handoff.",
|
|
264
|
+
positionals: "pageSelector",
|
|
265
|
+
json: true,
|
|
266
|
+
note: "Select the persisted draft by page path (as recorded by pull) or canonical lowercase UUID. "
|
|
267
|
+
+ "The homepage is recorded with an empty path, so address it as '/'. "
|
|
268
|
+
+ "The handoff URL in the result is single-use and expires two minutes after it is minted: opening it "
|
|
269
|
+
+ "consumes it, and a reused, shared, or bookmarked preview URL answers Not found. Run preview page again "
|
|
270
|
+
+ "for another. "
|
|
271
|
+
+ "--json is accepted"
|
|
272
|
+
+ " explicitly for agent invocations, though every operational success and failure is already JSON.",
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: VERB_PREVIEW_REVOKE,
|
|
276
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
277
|
+
tokens: ["preview", "revoke"],
|
|
278
|
+
summary: "Revoke an active authoring preview and schedule its artifacts for cleanup.",
|
|
279
|
+
positionals: "previewIds",
|
|
280
|
+
json: true,
|
|
281
|
+
note: "Supply the canonical lowercase page UUID followed by the snapshot UUID returned by preview page.",
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: VERB_STATUS,
|
|
285
|
+
// Readiness and the deployment log are Deployments; the image list is
|
|
286
|
+
// Content.
|
|
287
|
+
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
288
|
+
tokens: ["status"],
|
|
289
|
+
summary: "Report deployments, readiness, and image processing.",
|
|
290
|
+
note: "Broken references are not included: that read remains"
|
|
291
|
+
+ " session-only on the server, so the result reports it as uncovered"
|
|
292
|
+
+ " rather than pretending an empty list means a clean site.",
|
|
293
|
+
},
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
const COMMON_OPTIONS = `Options:
|
|
297
|
+
--config <path> Place before a config-reading verb; bypass parent discovery.
|
|
298
|
+
--quiet Suppress human progress. The JSON result is unchanged.
|
|
299
|
+
--help Show this help.
|
|
300
|
+
--version Show the package version.`;
|
|
301
|
+
|
|
302
|
+
const HELP = `Usage: ${CLI_BINARY_NAME} [--config <path>] <verb> [options]
|
|
303
|
+
|
|
304
|
+
Authoring verbs drive one Taproot site through the authoring surface. Start with
|
|
305
|
+
login, then sites and use to choose which site those verbs write to. Commands
|
|
306
|
+
talk to production unless 'env local' says otherwise; that choice is remembered
|
|
307
|
+
per machine, not per project.
|
|
308
|
+
|
|
309
|
+
The site credential is taken from ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} when it is
|
|
310
|
+
set; otherwise it is minted for each run by exchanging the account sign-in that
|
|
311
|
+
login stores outside the repository. There is no flag for either. The sign-in
|
|
312
|
+
authorizes nothing on any site: it lists sites and buys short-lived site
|
|
313
|
+
credentials, and it expires 24 hours after it is issued.
|
|
314
|
+
|
|
315
|
+
Verbs write one JSON result to stdout and human progress to stderr. validate,
|
|
316
|
+
help, whoami, and env are offline and read-only. login, logout, sites, use,
|
|
317
|
+
whoami, and env need no configuration and no site. The offline help family is
|
|
318
|
+
human-readable by default; add --json for stable reference data. Exit codes: 0
|
|
319
|
+
success, 1 failure, 2 usage fault.
|
|
320
|
+
|
|
321
|
+
Verbs:
|
|
322
|
+
${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(14)} ${verb.summary}`).join("\n")}
|
|
323
|
+
|
|
324
|
+
Configuration:
|
|
325
|
+
Site verbs read taproot-site.json, found by walking up from the current
|
|
326
|
+
directory through a bounded number of parents. Exactly one must be found, or
|
|
327
|
+
pass --config <path> before the verb. It is a closed JSON object:
|
|
328
|
+
configVersion must be 1
|
|
329
|
+
siteId optional; the canonical lowercase site UUID that 'use' writes
|
|
330
|
+
workspaceDir a relative POSIX path beneath the configuration directory
|
|
331
|
+
that pull writes into; every existing segment must be a real
|
|
332
|
+
directory
|
|
333
|
+
Unknown or duplicate fields are refused. apiBaseUrl is not a field: which
|
|
334
|
+
Taproot to talk to is machine state, set with 'env' and limited to
|
|
335
|
+
app.taproot.io (the default), app.taproot.test, or an explicit loopback URL
|
|
336
|
+
ending in /api. Sign-ins are stored per origin.
|
|
337
|
+
|
|
338
|
+
${COMMON_OPTIONS}
|
|
339
|
+
`;
|
|
340
|
+
|
|
341
|
+
function verbHelp(verb) {
|
|
342
|
+
const targetUsage = verb.target ? " (--staging | --production)" : "";
|
|
343
|
+
const positionalUsage = verb.positionals === "fixturePath"
|
|
344
|
+
? " <fixture-directory>"
|
|
345
|
+
: verb.positionals === "pageSelector"
|
|
346
|
+
? " <page-path-or-id>"
|
|
347
|
+
: verb.positionals === "previewIds"
|
|
348
|
+
? " <page-id> <snapshot-id>"
|
|
349
|
+
: verb.positionals === "environmentSelector"
|
|
350
|
+
? " [production | local | <url>]"
|
|
351
|
+
: verb.positionals
|
|
352
|
+
? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
|
|
353
|
+
: "";
|
|
354
|
+
const targetOption = verb.target
|
|
355
|
+
? `\n --staging Deploy the staged site to staging.
|
|
356
|
+
--production Promote the completed staging deployment to production.`
|
|
357
|
+
: "";
|
|
358
|
+
const rawHtmlOption = verb.allowRawHtml
|
|
359
|
+
? "\n --allow-raw-html Permit rawHtml nodes. They render verbatim and unsanitized."
|
|
360
|
+
: "";
|
|
361
|
+
const jsonOption = verb.json
|
|
362
|
+
? "\n --json Emit the stable JSON contract (operational output is always JSON)."
|
|
363
|
+
: "";
|
|
364
|
+
const nameOption = verb.keyName
|
|
365
|
+
? `\n --name <text> Name recorded on the issued key (default "${DEFAULT_LOGIN_KEY_NAME}",\n`
|
|
366
|
+
+ ` 1-${LOGIN_KEY_NAME_MAXIMUM} characters). The approval screen shows it.`
|
|
367
|
+
: "";
|
|
368
|
+
const note = verb.note ? `\n\n${verb.note}` : "";
|
|
369
|
+
// `offline` means "makes no network request", and until TR00645 it also
|
|
370
|
+
// implied "reads no configuration or credential". whoami is the first verb
|
|
371
|
+
// where those diverge — offline, but it reads both — so `readsLocalState` is
|
|
372
|
+
// the opt-out rather than a second positive flag every existing offline verb
|
|
373
|
+
// would have had to gain.
|
|
374
|
+
const selfContained = verb.offline && !verb.readsLocalState;
|
|
375
|
+
const prefix = selfContained ? CLI_BINARY_NAME : `${CLI_BINARY_NAME} [--config <path>]`;
|
|
376
|
+
const boundary = selfContained
|
|
377
|
+
? "This offline verb reads no configuration or credential and performs no network request or write."
|
|
378
|
+
: verb.offline
|
|
379
|
+
? "This verb answers entirely from local state — the stored sign-in and the configuration — and makes no "
|
|
380
|
+
+ "network request and no write."
|
|
381
|
+
: verb.credentialFree
|
|
382
|
+
? `This verb reads the configuration but requires no existing credential. `
|
|
383
|
+
+ `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} always takes precedence over the stored credential, so setting it leaves `
|
|
384
|
+
+ `existing automation unaffected by login and logout.`
|
|
385
|
+
: `The site-scoped credential is taken from ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} when it is set, and otherwise `
|
|
386
|
+
+ `from the credential '${CLI_BINARY_NAME} ${VERB_LOGIN}' stores outside the repository.`;
|
|
387
|
+
const options = selfContained
|
|
388
|
+
? `Options:
|
|
389
|
+
--quiet Suppress human progress. The JSON result is unchanged.
|
|
390
|
+
--help Show this help.
|
|
391
|
+
--version Show the package version.`
|
|
392
|
+
: verb.name === VERB_LOGIN
|
|
393
|
+
// Login's option block must not advertise --quiet: the verb rejects it
|
|
394
|
+
// (the approval URL exists only as progress), and an Options list that
|
|
395
|
+
// contradicts the note two paragraphs below it is worse than either alone.
|
|
396
|
+
? `Options:
|
|
397
|
+
--config <path> Place before the verb; bypass parent discovery.
|
|
398
|
+
--help Show this help.
|
|
399
|
+
--version Show the package version.`
|
|
400
|
+
: COMMON_OPTIONS;
|
|
401
|
+
return `Usage: ${prefix} ${verb.tokens.join(" ")}${positionalUsage}${targetUsage} [options]
|
|
402
|
+
|
|
403
|
+
${verb.summary}
|
|
404
|
+
${boundary}
|
|
405
|
+
|
|
406
|
+
${options}${targetOption}${rawHtmlOption}${jsonOption}${nameOption}${note}
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function matchVerb(arguments_) {
|
|
411
|
+
let matched;
|
|
412
|
+
for (const verb of VERBS) {
|
|
413
|
+
if (
|
|
414
|
+
verb.tokens.every((token, index) => arguments_[index] === token)
|
|
415
|
+
&& (matched === undefined || verb.tokens.length > matched.tokens.length)
|
|
416
|
+
) {
|
|
417
|
+
matched = verb;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return matched;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function usageError(code, message, options = {}) {
|
|
424
|
+
return new SiteAuthoringError(code, message, { ...options, exitCode: 2 });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function parseReferenceArguments(arguments_) {
|
|
428
|
+
if (arguments_.length === 1 && arguments_[0] === "--version") return { mode: "version" };
|
|
429
|
+
if (arguments_.length === 1 && arguments_[0] === "--help") {
|
|
430
|
+
return { mode: "reference", topic: "topics", json: false };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let json = false;
|
|
434
|
+
const terms = [];
|
|
435
|
+
for (const argument of arguments_) {
|
|
436
|
+
if (argument === "--json") {
|
|
437
|
+
if (json) throw usageError("help.usage", "--json may be supplied only once.");
|
|
438
|
+
json = true;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (argument.startsWith("-")) {
|
|
442
|
+
throw usageError("help.usage", "Reference help accepts only --json after its topic.");
|
|
443
|
+
}
|
|
444
|
+
terms.push(argument);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (terms.length === 0) return { mode: "reference", topic: "topics", json };
|
|
448
|
+
const [topic, subject, ...extra] = terms;
|
|
449
|
+
const topicNames = REFERENCE_TOPICS.map((entry) => entry.name);
|
|
450
|
+
if (!topicNames.includes(topic)) {
|
|
451
|
+
throw usageError(
|
|
452
|
+
"help.topic_unknown",
|
|
453
|
+
`Unknown reference topic. Expected one of: ${topicNames.join(", ")}.`,
|
|
454
|
+
{ alternatives: topicNames },
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
if (
|
|
458
|
+
(
|
|
459
|
+
topic === "pages"
|
|
460
|
+
|| topic === "components"
|
|
461
|
+
|| topic === "nav"
|
|
462
|
+
|| topic === "media"
|
|
463
|
+
|| topic === "preview"
|
|
464
|
+
|| topic === "theme"
|
|
465
|
+
|| topic === "appearance"
|
|
466
|
+
|| topic === "footer"
|
|
467
|
+
)
|
|
468
|
+
&& (subject !== undefined || extra.length > 0)) {
|
|
469
|
+
throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
|
|
470
|
+
}
|
|
471
|
+
if ((topic === "page" || topic === "component") && (subject === undefined || extra.length > 0)) {
|
|
472
|
+
throw usageError("help.usage", `The '${topic}' topic requires exactly one name.`);
|
|
473
|
+
}
|
|
474
|
+
return { mode: "reference", topic, subject, json };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function referenceResult(parsed) {
|
|
478
|
+
const result = {
|
|
479
|
+
schemaVersion: RESULT_SCHEMA_VERSION,
|
|
480
|
+
ok: true,
|
|
481
|
+
cli: { name: CLI_NAME, version: CLI_VERSION },
|
|
482
|
+
verb: VERB_HELP,
|
|
483
|
+
referenceVersion: REFERENCE_VERSION,
|
|
484
|
+
};
|
|
485
|
+
switch (parsed.topic) {
|
|
486
|
+
case "topics":
|
|
487
|
+
return { ...result, topic: "topics", topics: REFERENCE_TOPICS };
|
|
488
|
+
case "pages":
|
|
489
|
+
return { ...result, topic: "page-types", pageTypes: listPageTypeReferences() };
|
|
490
|
+
case "components":
|
|
491
|
+
return { ...result, topic: "component-types", components: listComponentTypeReferences() };
|
|
492
|
+
case "page": {
|
|
493
|
+
const page = getPageTypeReference(parsed.subject);
|
|
494
|
+
if (!page) {
|
|
495
|
+
throw usageError(
|
|
496
|
+
"help.page_type_unknown",
|
|
497
|
+
`Unknown page type. Expected one of: ${PAGE_TYPES.join(", ")}.`,
|
|
498
|
+
{ alternatives: PAGE_TYPES },
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return { ...result, topic: "page", page };
|
|
502
|
+
}
|
|
503
|
+
case "component": {
|
|
504
|
+
const component = getComponentReference(parsed.subject);
|
|
505
|
+
if (!component) {
|
|
506
|
+
const alternatives = listComponentTypeReferences().map((entry) => entry.type);
|
|
507
|
+
throw usageError(
|
|
508
|
+
"help.component_type_unknown",
|
|
509
|
+
`Unknown component type. Expected one of: ${alternatives.join(", ")}.`,
|
|
510
|
+
{ alternatives },
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
return { ...result, topic: "component", component };
|
|
514
|
+
}
|
|
515
|
+
case "nav":
|
|
516
|
+
case "media":
|
|
517
|
+
case "preview":
|
|
518
|
+
return { ...result, topic: "workflow", referenceKind: parsed.topic, reference: getWorkflowReference(parsed.topic) };
|
|
519
|
+
case "theme":
|
|
520
|
+
return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
|
|
521
|
+
case "appearance":
|
|
522
|
+
return { ...result, topic: "presentation", referenceKind: "appearance", reference: getAppearanceReference() };
|
|
523
|
+
case "footer":
|
|
524
|
+
return { ...result, topic: "presentation", referenceKind: "footer", reference: getFooterReference() };
|
|
525
|
+
default:
|
|
526
|
+
throw usageError("help.usage", "The reference-help invocation is incomplete.");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function parseConfigOption(arguments_, index) {
|
|
531
|
+
const candidate = arguments_[index + 1];
|
|
532
|
+
if (
|
|
533
|
+
typeof candidate !== "string"
|
|
534
|
+
|| candidate.length === 0
|
|
535
|
+
|| Buffer.byteLength(candidate, "utf8") > LIMITS.configPathBytes
|
|
536
|
+
|| hasAsciiControl(candidate)
|
|
537
|
+
|| candidate.startsWith("--")
|
|
538
|
+
) {
|
|
539
|
+
throw usageError("cli.config_option", "--config requires exactly one path before the operational verb.", {
|
|
540
|
+
field: "configPath",
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return candidate;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The command-line shape of `--name`, exactly as `parseConfigOption` handles
|
|
548
|
+
* `--config`: presence, bounds, and printability here; the semantic rule
|
|
549
|
+
* (trimmed, 1-100 characters) belongs to the verb, which a programmatic caller
|
|
550
|
+
* reaches without passing through this parser at all.
|
|
551
|
+
*/
|
|
552
|
+
function parseNameOption(arguments_, index) {
|
|
553
|
+
const candidate = arguments_[index + 1];
|
|
554
|
+
if (
|
|
555
|
+
typeof candidate !== "string"
|
|
556
|
+
|| candidate.length === 0
|
|
557
|
+
|| Buffer.byteLength(candidate, "utf8") > LIMITS.configPathBytes
|
|
558
|
+
|| hasAsciiControl(candidate)
|
|
559
|
+
|| candidate.startsWith("--")
|
|
560
|
+
) {
|
|
561
|
+
throw usageError("cli.name_option", "--name requires exactly one printable key name.", {
|
|
562
|
+
field: "keyName",
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return candidate;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function parseArguments(arguments_) {
|
|
569
|
+
if (!Array.isArray(arguments_) || arguments_.some((argument) => typeof argument !== "string")) {
|
|
570
|
+
throw usageError("cli.usage", "The command line must be a list of strings.");
|
|
571
|
+
}
|
|
572
|
+
if (arguments_.length === 1 && arguments_[0] === "--help") return { mode: "help" };
|
|
573
|
+
if (arguments_.length === 1 && arguments_[0] === "--version") return { mode: "version" };
|
|
574
|
+
|
|
575
|
+
let configPath;
|
|
576
|
+
let commandArguments = arguments_;
|
|
577
|
+
if (arguments_[0] === "--config") {
|
|
578
|
+
configPath = parseConfigOption(arguments_, 0);
|
|
579
|
+
commandArguments = arguments_.slice(2);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const verb = matchVerb(commandArguments);
|
|
583
|
+
if (!verb) {
|
|
584
|
+
throw usageError(
|
|
585
|
+
"cli.usage",
|
|
586
|
+
`Expected one of: ${VERBS.map((candidate) => candidate.tokens.join(" ")).join(", ")}.`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
const rest = commandArguments.slice(verb.tokens.length);
|
|
590
|
+
if (verb.name === VERB_HELP) {
|
|
591
|
+
if (configPath !== undefined) {
|
|
592
|
+
throw usageError("cli.config_option", "--config applies only to operational verbs.", {
|
|
593
|
+
field: "configPath",
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return parseReferenceArguments(rest);
|
|
597
|
+
}
|
|
598
|
+
// `--config` names the configuration a verb reads, so it is refused only by
|
|
599
|
+
// the verbs that read none. login and logout are credential-free but still
|
|
600
|
+
// read a configuration when one is discoverable — it is what names the API
|
|
601
|
+
// origin the sign-in belongs to — so they accept it. whoami is offline and
|
|
602
|
+
// reads both the store and the configuration, so it accepts it too: the test
|
|
603
|
+
// is "reads nothing local", not "makes no request".
|
|
604
|
+
if (verb.offline && !verb.readsLocalState && configPath !== undefined) {
|
|
605
|
+
throw usageError("cli.config_option", "--config applies only to verbs that read the site configuration.", {
|
|
606
|
+
field: "configPath",
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
if (rest.length === 1 && rest[0] === "--help") return { mode: "verb_help", verb };
|
|
610
|
+
if (rest.length === 1 && rest[0] === "--version") return { mode: "version" };
|
|
611
|
+
|
|
612
|
+
let quiet = false;
|
|
613
|
+
let deployTarget;
|
|
614
|
+
let allowRawHtml = false;
|
|
615
|
+
let json = false;
|
|
616
|
+
let keyName;
|
|
617
|
+
const positionals = [];
|
|
618
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
619
|
+
const argument = rest[index];
|
|
620
|
+
if (argument === "--quiet") {
|
|
621
|
+
if (quiet) throw usageError("cli.duplicate_option", "--quiet may be supplied only once.");
|
|
622
|
+
quiet = true;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (argument === "--config") {
|
|
626
|
+
throw usageError("cli.config_option", "--config must appear before the operational verb.", {
|
|
627
|
+
field: "configPath",
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
if (verb.target && (argument === "--staging" || argument === "--production")) {
|
|
631
|
+
const candidate = argument === "--staging" ? DEPLOY_TARGET_STAGING : DEPLOY_TARGET_PRODUCTION;
|
|
632
|
+
if (deployTarget === candidate) {
|
|
633
|
+
throw usageError("cli.duplicate_option", `${argument} may be supplied only once.`);
|
|
634
|
+
}
|
|
635
|
+
if (deployTarget !== undefined) {
|
|
636
|
+
throw usageError(
|
|
637
|
+
"cli.deploy_target",
|
|
638
|
+
"deploy accepts exactly one of --staging or --production.",
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
deployTarget = candidate;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (verb.allowRawHtml && argument === "--allow-raw-html") {
|
|
645
|
+
if (allowRawHtml) {
|
|
646
|
+
throw usageError("cli.duplicate_option", "--allow-raw-html may be supplied only once.");
|
|
647
|
+
}
|
|
648
|
+
allowRawHtml = true;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (verb.json && argument === "--json") {
|
|
652
|
+
if (json) throw usageError("cli.duplicate_option", "--json may be supplied only once.");
|
|
653
|
+
json = true;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (verb.keyName && argument === "--name") {
|
|
657
|
+
if (keyName !== undefined) throw usageError("cli.duplicate_option", "--name may be supplied only once.");
|
|
658
|
+
keyName = parseNameOption(rest, index);
|
|
659
|
+
index += 1;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (argument.startsWith("-")) {
|
|
663
|
+
throw usageError("cli.unknown_option", "The command contains an unknown option.");
|
|
664
|
+
}
|
|
665
|
+
if (verb.positionals) {
|
|
666
|
+
if (
|
|
667
|
+
argument.length === 0
|
|
668
|
+
|| Buffer.byteLength(argument, "utf8") > LIMITS.configPathBytes
|
|
669
|
+
|| hasAsciiControl(argument)
|
|
670
|
+
) {
|
|
671
|
+
throw usageError("cli.unexpected_argument", "A positional argument is empty, oversized, or unprintable.");
|
|
672
|
+
}
|
|
673
|
+
if (positionals.length >= 100) {
|
|
674
|
+
throw usageError("cli.unexpected_argument", "Too many positional arguments; supply at most 100.");
|
|
675
|
+
}
|
|
676
|
+
positionals.push(argument);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
throw usageError("cli.unexpected_argument", "The command contains an unexpected argument.");
|
|
680
|
+
}
|
|
681
|
+
if (verb.target && deployTarget === undefined) {
|
|
682
|
+
throw usageError("cli.deploy_target", "deploy requires exactly one of --staging or --production.");
|
|
683
|
+
}
|
|
684
|
+
if (
|
|
685
|
+
verb.positionals === "fixturePath"
|
|
686
|
+
&& positionals.length !== 1
|
|
687
|
+
) {
|
|
688
|
+
throw usageError(
|
|
689
|
+
"validate.fixture_path_invalid",
|
|
690
|
+
"validate requires exactly one fixture directory.",
|
|
691
|
+
{ field: "fixturePath" },
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (
|
|
695
|
+
verb.positionals === "pageSelector"
|
|
696
|
+
&& (
|
|
697
|
+
positionals.length !== 1
|
|
698
|
+
|| (!isCanonicalUuid(positionals[0]) && isCanonicalUuid(positionals[0].toLowerCase()))
|
|
699
|
+
)
|
|
700
|
+
) {
|
|
701
|
+
throw usageError(
|
|
702
|
+
"preview.page_selector_invalid",
|
|
703
|
+
"preview page requires exactly one page path or canonical lowercase page UUID.",
|
|
704
|
+
{ field: "pageSelector" },
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
if (
|
|
708
|
+
verb.positionals === "previewIds"
|
|
709
|
+
&& (
|
|
710
|
+
positionals.length !== 2
|
|
711
|
+
|| !isCanonicalUuid(positionals[0])
|
|
712
|
+
|| !isCanonicalUuid(positionals[1])
|
|
713
|
+
)
|
|
714
|
+
) {
|
|
715
|
+
const field = positionals.length === 2 && !isCanonicalUuid(positionals[0])
|
|
716
|
+
? "pageId"
|
|
717
|
+
: "snapshotId";
|
|
718
|
+
throw usageError(
|
|
719
|
+
"preview.identity_invalid",
|
|
720
|
+
"preview revoke requires one canonical lowercase page UUID and one canonical lowercase snapshot UUID.",
|
|
721
|
+
{ field },
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
if (verb.positionals == "siteSelector" && positionals.length !== 1) {
|
|
725
|
+
throw usageError(
|
|
726
|
+
"use.selector_missing",
|
|
727
|
+
"use requires exactly one site name or canonical lowercase site UUID.",
|
|
728
|
+
{ field: "selector" },
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
if (verb.positionals === "environmentSelector" && positionals.length > 1) {
|
|
732
|
+
throw usageError(
|
|
733
|
+
"env.unexpected_argument",
|
|
734
|
+
"env takes at most one environment: 'production', 'local', or an explicit loopback URL.",
|
|
735
|
+
{ field: "environmentSelector" },
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
if (quiet && verb.name === VERB_LOGIN) {
|
|
739
|
+
// The approval URL and user code reach the operator only as progress
|
|
740
|
+
// lines, and the JSON result is serialized only after polling ends — so a
|
|
741
|
+
// silenced login is one nobody can ever approve. Refusing up front beats
|
|
742
|
+
// a guaranteed timeout fifteen minutes later.
|
|
743
|
+
throw usageError(
|
|
744
|
+
"cli.quiet_option",
|
|
745
|
+
"--quiet cannot be used with login: the approval URL and code are printed as progress, "
|
|
746
|
+
+ "and the JSON result exists only after the approval completes.",
|
|
747
|
+
{ field: "quiet" },
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
mode: "run",
|
|
752
|
+
verb: verb.name,
|
|
753
|
+
// The smallest capability set this verb's requests need. It reaches the
|
|
754
|
+
// exchange so a content push never holds deploy, and a pull never holds
|
|
755
|
+
// delete (TR00645).
|
|
756
|
+
capabilities: verb.capabilities,
|
|
757
|
+
configPath,
|
|
758
|
+
quiet,
|
|
759
|
+
deployTarget,
|
|
760
|
+
allowRawHtml,
|
|
761
|
+
keyName,
|
|
762
|
+
positionals: verb.positionals
|
|
763
|
+
? {
|
|
764
|
+
key: verb.positionals,
|
|
765
|
+
values: positionals,
|
|
766
|
+
scalar: verb.positionals === "pageSelector" || verb.positionals === "fixturePath"
|
|
767
|
+
|| verb.positionals === "siteSelector" || verb.positionals === "environmentSelector",
|
|
768
|
+
}
|
|
769
|
+
: undefined,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export async function runCli({
|
|
774
|
+
arguments_ = process.argv.slice(2),
|
|
775
|
+
environment = process.env,
|
|
776
|
+
cwd = process.cwd(),
|
|
777
|
+
stdout = process.stdout,
|
|
778
|
+
stderr = process.stderr,
|
|
779
|
+
handlers = VERB_HANDLERS,
|
|
780
|
+
fetch,
|
|
781
|
+
signal,
|
|
782
|
+
} = {}) {
|
|
783
|
+
let parsed;
|
|
784
|
+
let completedPreviewRecovery;
|
|
785
|
+
const isReferenceInvocation = Array.isArray(arguments_)
|
|
786
|
+
&& (
|
|
787
|
+
arguments_[0] === "help"
|
|
788
|
+
|| (arguments_[0] === "--config" && arguments_[2] === "help")
|
|
789
|
+
);
|
|
790
|
+
// `validate` promises not to write even when parsing later rejects its
|
|
791
|
+
// invocation. Detect its only legal command positions without consulting
|
|
792
|
+
// config or environment, so a malformed offline command cannot fall through
|
|
793
|
+
// to the generic GitHub Actions output writer.
|
|
794
|
+
const isValidationInvocation = Array.isArray(arguments_)
|
|
795
|
+
&& (
|
|
796
|
+
arguments_[0] === VERB_VALIDATE
|
|
797
|
+
|| (arguments_[0] === "--config" && arguments_[2] === VERB_VALIDATE)
|
|
798
|
+
);
|
|
799
|
+
try {
|
|
800
|
+
parsed = parseArguments(arguments_);
|
|
801
|
+
if (parsed.mode === "help") {
|
|
802
|
+
stdout.write(HELP);
|
|
803
|
+
return 0;
|
|
804
|
+
}
|
|
805
|
+
if (parsed.mode === "verb_help") {
|
|
806
|
+
stdout.write(verbHelp(parsed.verb));
|
|
807
|
+
return 0;
|
|
808
|
+
}
|
|
809
|
+
if (parsed.mode === "version") {
|
|
810
|
+
stdout.write(`${CLI_VERSION}\n`);
|
|
811
|
+
return 0;
|
|
812
|
+
}
|
|
813
|
+
if (parsed.mode === "reference") {
|
|
814
|
+
const result = referenceResult(parsed);
|
|
815
|
+
stdout.write(parsed.json ? `${serializeResult(result)}\n` : formatReferenceResult(result));
|
|
816
|
+
return 0;
|
|
817
|
+
}
|
|
818
|
+
// Own properties only: a verb name must never resolve through the
|
|
819
|
+
// prototype chain to something like `constructor`.
|
|
820
|
+
const handler = Object.hasOwn(handlers, parsed.verb) ? handlers[parsed.verb] : undefined;
|
|
821
|
+
if (typeof handler !== "function") {
|
|
822
|
+
throw usageError("cli.unsupported_verb", `No handler is registered for '${parsed.verb}'.`);
|
|
823
|
+
}
|
|
824
|
+
const result = await handler(Object.freeze({
|
|
825
|
+
verb: parsed.verb,
|
|
826
|
+
cwd,
|
|
827
|
+
environment,
|
|
828
|
+
configPath: parsed.configPath,
|
|
829
|
+
deployTarget: parsed.deployTarget,
|
|
830
|
+
quiet: parsed.quiet,
|
|
831
|
+
allowRawHtml: parsed.allowRawHtml,
|
|
832
|
+
keyName: parsed.keyName,
|
|
833
|
+
capabilities: parsed.capabilities,
|
|
834
|
+
// Positionals land under the verb's own seam name (paths for media
|
|
835
|
+
// upload, pagePaths for approve) and only when some were given, so a
|
|
836
|
+
// bare invocation keeps each verb's documented default behavior.
|
|
837
|
+
...(parsed.positionals && parsed.positionals.values.length > 0
|
|
838
|
+
? {
|
|
839
|
+
[parsed.positionals.key]: parsed.positionals.scalar
|
|
840
|
+
? parsed.positionals.values[0]
|
|
841
|
+
: parsed.positionals.values,
|
|
842
|
+
}
|
|
843
|
+
: {}),
|
|
844
|
+
onProgress: parsed.quiet ? () => {} : (message) => stderr.write(`${message}\n`),
|
|
845
|
+
fetch,
|
|
846
|
+
signal,
|
|
847
|
+
}));
|
|
848
|
+
if (parsed.verb === VERB_PREVIEW_PAGE && result?.ok === true) {
|
|
849
|
+
completedPreviewRecovery = normalizePreviewRecovery(result);
|
|
850
|
+
}
|
|
851
|
+
const json = serializeResult(result);
|
|
852
|
+
if (!isValidationInvocation && environment.GITHUB_OUTPUT) {
|
|
853
|
+
await writeGithubActionsOutput(environment.GITHUB_OUTPUT, result);
|
|
854
|
+
}
|
|
855
|
+
stdout.write(`${json}\n`);
|
|
856
|
+
return 0;
|
|
857
|
+
} catch (unknownError) {
|
|
858
|
+
const error = asSiteAuthoringError(unknownError);
|
|
859
|
+
if (completedPreviewRecovery) error.withPreviewRecovery(completedPreviewRecovery);
|
|
860
|
+
const result = failureResult(error);
|
|
861
|
+
stdout.write(`${serializeResult(result)}\n`);
|
|
862
|
+
stderr.write(`${humanFailure(error)}\n`);
|
|
863
|
+
if (!isValidationInvocation && environment.GITHUB_OUTPUT && !isReferenceInvocation) {
|
|
864
|
+
try {
|
|
865
|
+
await writeGithubActionsOutput(environment.GITHUB_OUTPUT, result);
|
|
866
|
+
} catch (outputError) {
|
|
867
|
+
stderr.write(`${humanFailure(asSiteAuthoringError(outputError))}\n`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return error.exitCode;
|
|
871
|
+
}
|
|
872
|
+
}
|