@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.
Files changed (57) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +239 -0
  3. package/bin/taproot-site.js +5 -0
  4. package/package.json +40 -0
  5. package/src/api.js +1507 -0
  6. package/src/appearance-contract.js +311 -0
  7. package/src/atomic-file.js +136 -0
  8. package/src/bounded-file.js +59 -0
  9. package/src/capabilities.js +29 -0
  10. package/src/cli.js +872 -0
  11. package/src/config.js +412 -0
  12. package/src/constants.js +173 -0
  13. package/src/content/components.js +870 -0
  14. package/src/content/free-form-section-registry.json +524 -0
  15. package/src/content/free-form-sections.js +582 -0
  16. package/src/content/index.js +29 -0
  17. package/src/content/inline-facts.js +97 -0
  18. package/src/content/markdown.js +1625 -0
  19. package/src/content/tiptap-prosemirror.ts +1266 -0
  20. package/src/content/validate-document.js +1079 -0
  21. package/src/content/vocabulary.js +397 -0
  22. package/src/credentials.js +544 -0
  23. package/src/errors.js +133 -0
  24. package/src/footer-contract.js +687 -0
  25. package/src/footer-draft-hash.js +203 -0
  26. package/src/footer-workspace.js +136 -0
  27. package/src/image-metadata.js +169 -0
  28. package/src/index.js +16 -0
  29. package/src/json-guard.js +111 -0
  30. package/src/output.js +157 -0
  31. package/src/presentation-reference.js +600 -0
  32. package/src/reference-help.js +927 -0
  33. package/src/session.js +316 -0
  34. package/src/settings-catalog.js +187 -0
  35. package/src/settings.js +157 -0
  36. package/src/theme-validation.js +344 -0
  37. package/src/transport.js +777 -0
  38. package/src/verbs/approve.js +178 -0
  39. package/src/verbs/deploy.js +327 -0
  40. package/src/verbs/env.js +89 -0
  41. package/src/verbs/footer-push.js +115 -0
  42. package/src/verbs/index.js +125 -0
  43. package/src/verbs/login.js +529 -0
  44. package/src/verbs/logout.js +39 -0
  45. package/src/verbs/media-upload.js +319 -0
  46. package/src/verbs/nav-push.js +333 -0
  47. package/src/verbs/pages-push.js +990 -0
  48. package/src/verbs/preview-page.js +107 -0
  49. package/src/verbs/preview-revoke.js +56 -0
  50. package/src/verbs/pull.js +727 -0
  51. package/src/verbs/sites.js +57 -0
  52. package/src/verbs/status.js +94 -0
  53. package/src/verbs/theme-push.js +197 -0
  54. package/src/verbs/use.js +129 -0
  55. package/src/verbs/validate.js +477 -0
  56. package/src/verbs/whoami.js +108 -0
  57. package/src/workspace.js +1085 -0
@@ -0,0 +1,178 @@
1
+ import {
2
+ listSitePages,
3
+ PAGE_STATUS_DELETED,
4
+ publishDrafts,
5
+ TEMPLATE_TYPE_FREE_FORM,
6
+ withRefusalGuidance,
7
+ } from "../api.js";
8
+ import { VERB_APPROVE } from "../constants.js";
9
+ import { SiteAuthoringError } from "../errors.js";
10
+ import { boundedList, openSession, successResult } from "../session.js";
11
+ import {
12
+ normalizePagePath,
13
+ readManifest,
14
+ readOnlySystem404Projections,
15
+ SYSTEM_PAGE_NOT_FOUND_PATH,
16
+ writeManifest,
17
+ } from "../workspace.js";
18
+
19
+ /**
20
+ * `approve` — publish the site's drafts.
21
+ *
22
+ * `PublishDrafts` *stages*: it moves a page's draft into the approved candidate
23
+ * pool. Nothing reaches an audience until `deploy` runs, and this verb says so
24
+ * rather than letting an agent read "publish" as "live".
25
+ *
26
+ * The selection is the workspace's own: pages that the manifest knows about and
27
+ * that Taproot currently reports as carrying a draft. Restricting to the
28
+ * manifest is deliberate — a site can have drafts an owner is still working on
29
+ * in the browser, and an agent's approve step must not sweep those into the
30
+ * next deployment.
31
+ */
32
+
33
+ const MAXIMUM_REPORTED = 200;
34
+ // `PublishDrafts` resolves the credential from the first page id in the batch,
35
+ // so batches stay one-site and bounded rather than unbounded and clever.
36
+ const MAXIMUM_BATCH = 100;
37
+
38
+ export async function approve(invocation) {
39
+ const { client, config, siteId, onProgress } = await openSession(invocation);
40
+ const manifest = await readManifest(config.workspaceDir, siteId);
41
+ const manifestByPageId = new Map(
42
+ manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
43
+ );
44
+ const readOnlyProjections = readOnlySystem404Projections(manifest);
45
+
46
+ // Positional arguments narrow the selection to those page paths; `cli.js`
47
+ // hands them over as `pagePaths`, and a programmatic caller can supply the
48
+ // same key directly. With none, every draft the manifest tracks is staged.
49
+ //
50
+ // A path this CLI cannot use is refused by name rather than dropped. Dropping
51
+ // it silently is the worst available outcome: the remaining selection would
52
+ // narrow — possibly to nothing — and the verb would exit 0 reporting the work
53
+ // done, telling an agent that asked to stage a page that it had been staged.
54
+ const requestedPaths = Array.isArray(invocation.pagePaths)
55
+ ? new Set(invocation.pagePaths.map((value) => {
56
+ const normalized = normalizePagePath(value);
57
+ if (normalized === undefined) {
58
+ throw new SiteAuthoringError(
59
+ "approve.page_path_invalid",
60
+ `'${typeof value === "string" ? value : String(value)}' is not a usable page path. `
61
+ + "Use the path as 'pull' records it in the manifest — no '.', '..', empty, or backslash segments.",
62
+ { field: typeof value === "string" ? value : undefined, exitCode: 2 },
63
+ );
64
+ }
65
+ return normalized;
66
+ }))
67
+ : undefined;
68
+
69
+ return await withRefusalGuidance(onProgress, "approve", async () => {
70
+ onProgress("Listing the site's pages to find approvable drafts.");
71
+ const { pages, truncated } = await listSitePages(client, siteId, { onProgress });
72
+ if (truncated) {
73
+ // The selection *is* this list. A partial one stages a subset and then
74
+ // reports success, which reads as "everything is approved".
75
+ throw new SiteAuthoringError(
76
+ "approve.live_list_truncated",
77
+ "The site has more pages than this CLI can enumerate, so the drafts to approve cannot be determined. "
78
+ + "Nothing was approved.",
79
+ { field: "pages" },
80
+ );
81
+ }
82
+ const pagesById = new Map(pages.map((summary) => [summary.pageId, summary]));
83
+ for (const projection of readOnlyProjections) {
84
+ const summary = pagesById.get(projection.pageId);
85
+ if (
86
+ summary?.status === PAGE_STATUS_DELETED
87
+ || normalizePagePath(summary?.path)?.toLowerCase() !== SYSTEM_PAGE_NOT_FOUND_PATH
88
+ || summary?.templateType !== TEMPLATE_TYPE_FREE_FORM
89
+ ) {
90
+ const index = manifest.pages.indexOf(projection);
91
+ throw new SiteAuthoringError(
92
+ "workspace.manifest_invalid",
93
+ `The read-only page projection at pages[${index}] does not identify the live free-form system 404. `
94
+ + "Nothing was approved; run 'taproot-site pull' again.",
95
+ { field: `pages[${index}].pageId` },
96
+ );
97
+ }
98
+ }
99
+ const readOnlyPageIds = new Set(readOnlyProjections.map((entry) => entry.pageId));
100
+ if (requestedPaths !== undefined) {
101
+ const requestedReadOnly = readOnlyProjections.find((entry) =>
102
+ requestedPaths.has(normalizePagePath(entry.path) ?? entry.path));
103
+ if (requestedReadOnly !== undefined) {
104
+ const pagePath = normalizePagePath(requestedReadOnly.path) ?? requestedReadOnly.path;
105
+ throw new SiteAuthoringError(
106
+ "approve.page_read_only",
107
+ `Page path '${pagePath}' is the pulled read-only system 404 projection and cannot be approved.`,
108
+ { field: pagePath },
109
+ );
110
+ }
111
+ }
112
+ const candidates = pages.filter((summary) =>
113
+ summary.status !== PAGE_STATUS_DELETED
114
+ && summary.hasDraft
115
+ && manifestByPageId.has(summary.pageId)
116
+ && !readOnlyPageIds.has(summary.pageId)
117
+ && (requestedPaths === undefined || requestedPaths.has(normalizePagePath(summary.path) ?? summary.path)));
118
+
119
+ if (requestedPaths !== undefined) {
120
+ const matched = new Set(candidates.map((summary) => normalizePagePath(summary.path) ?? summary.path));
121
+ const missing = [...requestedPaths].filter((value) => !matched.has(value)).sort();
122
+ if (missing.length > 0) {
123
+ // The homepage normalizes to the empty path, which the result
124
+ // emitters drop as falsy; name it by its documented '/' spelling so
125
+ // the stable error contract keeps a field for every unknown path.
126
+ throw new SiteAuthoringError(
127
+ "approve.page_not_found",
128
+ `No approvable draft was found for page path '${missing[0] || "/"}'.`,
129
+ { field: missing[0] || "/" },
130
+ );
131
+ }
132
+ }
133
+
134
+ if (candidates.length === 0) {
135
+ onProgress("No page in this workspace is carrying a draft; there is nothing to approve.");
136
+ return successResult(VERB_APPROVE, siteId, {
137
+ approved: { total: 0, items: [] },
138
+ stagedNotDeployed: true,
139
+ nextStep: "deploy --staging",
140
+ });
141
+ }
142
+
143
+ const approved = [];
144
+ let manifestDirty = false;
145
+ try {
146
+ for (let offset = 0; offset < candidates.length; offset += MAXIMUM_BATCH) {
147
+ const batch = candidates.slice(offset, offset + MAXIMUM_BATCH);
148
+ onProgress(`Approving ${batch.length} draft page(s).`);
149
+ const summaries = await publishDrafts(client, batch.map((summary) => summary.pageId));
150
+ manifestDirty = true;
151
+ for (const summary of summaries) {
152
+ approved.push({ pageId: summary.pageId, path: summary.path, status: summary.status });
153
+ const entry = manifestByPageId.get(summary.pageId);
154
+ if (entry !== undefined) {
155
+ entry.status = summary.status;
156
+ entry.hasDraft = summary.hasDraft;
157
+ entry.pendingApproval = false;
158
+ }
159
+ }
160
+ }
161
+ } finally {
162
+ if (manifestDirty) await writeManifest(config.workspaceDir, manifest);
163
+ }
164
+
165
+ const reported = boundedList(approved, MAXIMUM_REPORTED);
166
+ onProgress(`Approved ${approved.length} page(s). Nothing is published until 'taproot-site deploy' runs.`);
167
+ return successResult(VERB_APPROVE, siteId, {
168
+ approved: {
169
+ total: approved.length,
170
+ items: reported.items,
171
+ ...(reported.truncated ? { itemsTruncated: true } : {}),
172
+ },
173
+ // Said explicitly so a caller cannot read "approve" as "live".
174
+ stagedNotDeployed: true,
175
+ nextStep: "deploy --staging",
176
+ });
177
+ });
178
+ }
@@ -0,0 +1,327 @@
1
+ import {
2
+ DEPLOYMENT_ENVIRONMENT_PRODUCTION,
3
+ DEPLOYMENT_ENVIRONMENT_STAGING,
4
+ DEPLOYMENT_STATUS_COMPLETED,
5
+ deploySite,
6
+ getPublishingReadiness,
7
+ getStagingPreviewStatus,
8
+ listDeployments,
9
+ listSitePages,
10
+ PAGE_STATUS_APPROVED,
11
+ waitForDeployment,
12
+ withRefusalGuidance,
13
+ } from "../api.js";
14
+ import {
15
+ DEPLOY_TARGET_PRODUCTION,
16
+ DEPLOY_TARGET_STAGING,
17
+ VERB_DEPLOY,
18
+ } from "../constants.js";
19
+ import { SiteAuthoringError } from "../errors.js";
20
+ import { boundedList, openSession, successResult } from "../session.js";
21
+ import { CANDIDATE_SELECTABLE_SETTINGS_TYPES } from "../settings-catalog.js";
22
+ import { readManifest, writeManifest } from "../workspace.js";
23
+
24
+ /**
25
+ * `deploy --staging` / `deploy --production` — readiness, deploy, poll to
26
+ * completion.
27
+ *
28
+ * Staging publishes a *candidate*: approved pages, navigation, and the
29
+ * candidate-selectable settings groups. The server refuses an empty candidate,
30
+ * so the CLI refuses it first and says what to do about it.
31
+ *
32
+ * Production is a promotion and only a promotion. `DeploySite` rejects a
33
+ * production request that carries both a `stagingDeploymentId` and explicit
34
+ * candidate changes ("Choose either a completed staging deployment or explicit
35
+ * candidate changes, not both"), so the CLI never sends a selection alongside
36
+ * one — and refuses locally if a caller supplies both.
37
+ *
38
+ * Completion is observed by polling `GET /v1/sites/{id}/deployments`. The SSE
39
+ * deployment stream is user-session-scoped and therefore unreachable from a
40
+ * key-authorized CLI; the repo's no-polling rule governs UX surfaces and does
41
+ * not reach this one. proto3 omits zero-valued enums, so a deployment listed
42
+ * without a status is queued, not unknown.
43
+ *
44
+ * The published-page quota has no pre-flight read anywhere on the contract. It
45
+ * surfaces here, at deploy time, as an `UpgradePrompt` field violation —
46
+ * classified `plan_limit` and announced prominently by `announceRefusal` — and
47
+ * the CLI never invents the numeric limit it was not told.
48
+ */
49
+
50
+ const MAXIMUM_REPORTED_BLOCKERS = 50;
51
+ const STAGING_ROUTE_PROBE_MILLISECONDS = 5_000;
52
+ // Staged page ids ride in the readiness query string, so a very large candidate
53
+ // is checked at site level instead of pushing an unbounded URL at the API.
54
+ const MAXIMUM_READINESS_PAGE_IDS = 100;
55
+
56
+ function usageError(code, message, field) {
57
+ return new SiteAuthoringError(code, message, { field, exitCode: 2 });
58
+ }
59
+
60
+ function explicitSelection(invocation) {
61
+ const stagedPageIds = Array.isArray(invocation.stagedPageIds) ? invocation.stagedPageIds : undefined;
62
+ const selectedSettingsTypes = Array.isArray(invocation.selectedSettingsTypes)
63
+ ? invocation.selectedSettingsTypes
64
+ : undefined;
65
+ const includeNavigation = typeof invocation.includeNavigation === "boolean"
66
+ ? invocation.includeNavigation
67
+ : undefined;
68
+ const present = (stagedPageIds !== undefined && stagedPageIds.length > 0)
69
+ || (selectedSettingsTypes !== undefined && selectedSettingsTypes.length > 0)
70
+ || includeNavigation === true;
71
+ return { stagedPageIds, selectedSettingsTypes, includeNavigation, present };
72
+ }
73
+
74
+ function manifestSettingsTypes(manifest) {
75
+ const pulled = Array.isArray(manifest?.settings)
76
+ ? manifest.settings.map((entry) => entry?.settingsType).filter((value) => typeof value === "string")
77
+ : [];
78
+ return CANDIDATE_SELECTABLE_SETTINGS_TYPES.filter((type) => pulled.includes(type));
79
+ }
80
+
81
+ async function recordDeployment(config, manifest, key, deployment) {
82
+ if (manifest === undefined) return;
83
+ manifest.deployments = { ...(manifest.deployments ?? {}) };
84
+ manifest.deployments[key] = {
85
+ id: deployment.id,
86
+ status: deployment.status,
87
+ completedAt: deployment.completedAt,
88
+ };
89
+ await writeManifest(config.workspaceDir, manifest);
90
+ }
91
+
92
+ async function resolveStagingDeploymentId(client, siteId, invocation, manifest, onProgress) {
93
+ if (typeof invocation.stagingDeploymentId === "string" && invocation.stagingDeploymentId !== "") {
94
+ return invocation.stagingDeploymentId;
95
+ }
96
+ const recorded = manifest?.deployments?.staging;
97
+ if (recorded !== null && typeof recorded === "object" && typeof recorded.id === "string" && recorded.id !== "") {
98
+ return recorded.id;
99
+ }
100
+ onProgress("Looking for the most recent completed staging deployment to promote.");
101
+ const { deployments } = await listDeployments(client, siteId, { environment: DEPLOYMENT_ENVIRONMENT_STAGING });
102
+ const completed = deployments.find((deployment) => deployment.status === DEPLOYMENT_STATUS_COMPLETED);
103
+ if (completed === undefined) {
104
+ throw new SiteAuthoringError(
105
+ "deploy.staging_required",
106
+ "Production promotes a completed staging deployment, and this site has none. "
107
+ + "Run 'taproot-site deploy --staging' first.",
108
+ { field: "stagingDeploymentId" },
109
+ );
110
+ }
111
+ return completed.id;
112
+ }
113
+
114
+ function reportReadiness(readiness) {
115
+ const blockers = boundedList(readiness.blockers, MAXIMUM_REPORTED_BLOCKERS);
116
+ return {
117
+ state: readiness.state,
118
+ approvedPageCount: readiness.approvedPageCount,
119
+ selectedPageCount: readiness.selectedPageCount,
120
+ blockedPageCount: readiness.blockedPageCount,
121
+ hasCandidateChanges: readiness.hasCandidateChanges,
122
+ blockers: blockers.items,
123
+ ...(blockers.truncated ? { blockersTruncated: true } : {}),
124
+ };
125
+ }
126
+
127
+ function stagingWarning(message) {
128
+ return { routeCheck: "unresolved", warning: message };
129
+ }
130
+
131
+ async function inspectStagingPreview(client, siteId, onProgress) {
132
+ let status;
133
+ try {
134
+ status = await getStagingPreviewStatus(client, siteId);
135
+ } catch {
136
+ const warning = "The staging deployment completed, but Taproot could not report its staging URL."
137
+ + " Check the site's staging-host configuration before treating the deployment as viewable.";
138
+ onProgress(`Warning: ${warning}`);
139
+ return { url: "", ...stagingWarning(warning) };
140
+ }
141
+ if (!status.ready) {
142
+ const warning = "The staging deployment completed, but Taproot does not report a ready staging URL.";
143
+ onProgress(`Warning: ${warning}`);
144
+ return { url: "", ...stagingWarning(warning) };
145
+ }
146
+
147
+ const stagingUrl = status.stagingUrl;
148
+ onProgress(`Staging URL: ${stagingUrl}`);
149
+ let response;
150
+ try {
151
+ const timeout = client.timeoutSignal(STAGING_ROUTE_PROBE_MILLISECONDS);
152
+ const signal = client.signal ? AbortSignal.any([client.signal, timeout]) : timeout;
153
+ response = await client.fetch(stagingUrl, {
154
+ method: "GET",
155
+ redirect: "manual",
156
+ signal,
157
+ headers: {
158
+ accept: "text/html",
159
+ "user-agent": "taproot-site/staging-route-check",
160
+ },
161
+ });
162
+ } catch {
163
+ const warning = `The staging deployment completed at ${stagingUrl}, but the host could not be reached.`;
164
+ onProgress(`Warning: ${warning}`);
165
+ return { url: stagingUrl, ...stagingWarning(warning) };
166
+ }
167
+
168
+ const location = response.headers.get("location");
169
+ await response.body?.cancel().catch(() => {});
170
+ let handoff;
171
+ try {
172
+ handoff = location ? new URL(location, stagingUrl) : undefined;
173
+ } catch {
174
+ handoff = undefined;
175
+ }
176
+ const stagingHost = new URL(stagingUrl).hostname;
177
+ const resolvesToSite = response.status >= 300
178
+ && response.status < 400
179
+ && handoff?.protocol === "https:"
180
+ && handoff.username === ""
181
+ && handoff.password === ""
182
+ && handoff.hash === ""
183
+ && handoff.pathname === "/api/v1/staging-preview/handoff"
184
+ && handoff.searchParams.getAll("siteId").length === 1
185
+ && handoff.searchParams.get("siteId") === siteId
186
+ && handoff.searchParams.getAll("host").length === 1
187
+ && handoff.searchParams.get("host") === stagingHost;
188
+ if (resolvesToSite) return { url: stagingUrl, routeCheck: "resolved" };
189
+
190
+ const warning = `The staging deployment completed at ${stagingUrl}, but the host returned HTTP ${response.status}`
191
+ + " instead of this site's staging gate. The deployment artifacts are complete, but the URL is not viewable.";
192
+ onProgress(`Warning: ${warning}`);
193
+ return { url: stagingUrl, ...stagingWarning(warning) };
194
+ }
195
+
196
+ export async function deploy(invocation) {
197
+ const target = invocation.deployTarget;
198
+ if (target !== DEPLOY_TARGET_STAGING && target !== DEPLOY_TARGET_PRODUCTION) {
199
+ throw usageError(
200
+ "deploy.target_missing",
201
+ "deploy requires exactly one of --staging or --production.",
202
+ "deployTarget",
203
+ );
204
+ }
205
+ const { client, config, siteId, now, onProgress } = await openSession(invocation);
206
+ const manifest = await readManifest(config.workspaceDir, siteId, { required: false });
207
+ const selection = explicitSelection(invocation);
208
+
209
+ return await withRefusalGuidance(onProgress, "deploy", async () => {
210
+ if (target === DEPLOY_TARGET_PRODUCTION) {
211
+ if (selection.present) {
212
+ throw usageError(
213
+ "deploy.production_selection",
214
+ "deploy --production promotes a completed staging deployment and cannot also carry an explicit page, "
215
+ + "settings, or navigation selection. Deploy the selection to staging first, then promote it.",
216
+ "stagingDeploymentId",
217
+ );
218
+ }
219
+ const stagingDeploymentId = await resolveStagingDeploymentId(client, siteId, invocation, manifest, onProgress);
220
+ onProgress(`Checking publishing readiness before promoting staging deployment ${stagingDeploymentId}.`);
221
+ const readiness = await getPublishingReadiness(client, siteId);
222
+ const created = await deploySite(client, siteId, {
223
+ siteId,
224
+ environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
225
+ stagingDeploymentId,
226
+ });
227
+ onProgress(`Production deployment ${created.id} accepted; waiting for it to complete.`);
228
+ const completed = await waitForDeployment(client, {
229
+ siteId,
230
+ deploymentId: created.id,
231
+ environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
232
+ onProgress,
233
+ now,
234
+ });
235
+ await recordDeployment(config, manifest, "production", completed);
236
+ return successResult(VERB_DEPLOY, siteId, {
237
+ target,
238
+ environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
239
+ promotedStagingDeploymentId: stagingDeploymentId,
240
+ deployment: completed,
241
+ readiness: reportReadiness(readiness),
242
+ });
243
+ }
244
+
245
+ onProgress("Listing approved pages for the staging candidate.");
246
+ const { pages, truncated } = await listSitePages(client, siteId, { onProgress });
247
+ if (truncated) {
248
+ // The candidate is built from this list, so a partial one silently
249
+ // publishes a subset of what was approved — and the deployment that
250
+ // follows reports success.
251
+ throw new SiteAuthoringError(
252
+ "deploy.live_list_truncated",
253
+ "The site has more pages than this CLI can enumerate, so the staging candidate would omit approved "
254
+ + "pages without saying so. Nothing was deployed.",
255
+ { field: "stagedPageIds" },
256
+ );
257
+ }
258
+ const stagedPageIds = selection.stagedPageIds
259
+ ?? pages.filter((summary) => summary.status === PAGE_STATUS_APPROVED).map((summary) => summary.pageId);
260
+ const selectedSettingsTypes = selection.selectedSettingsTypes ?? manifestSettingsTypes(manifest);
261
+ const includeNavigation = selection.includeNavigation ?? manifest?.navigation !== undefined;
262
+ if (stagedPageIds.length === 0 && selectedSettingsTypes.length === 0 && !includeNavigation) {
263
+ throw new SiteAuthoringError(
264
+ "deploy.empty_selection",
265
+ "A staging deployment needs at least one approved page, settings group, or navigation change. "
266
+ + "Run 'taproot-site approve' (and 'taproot-site pull' to pick up navigation and settings) first.",
267
+ { field: "Candidate" },
268
+ );
269
+ }
270
+
271
+ const candidate = {
272
+ stagedPageIds,
273
+ selectedSettingsTypes,
274
+ includeNavigation,
275
+ };
276
+ onProgress(
277
+ `Checking publishing readiness for ${stagedPageIds.length} page(s), `
278
+ + `${selectedSettingsTypes.length} settings group(s), `
279
+ + `navigation ${includeNavigation ? "included" : "excluded"}.`,
280
+ );
281
+ const readiness = await getPublishingReadiness(client, siteId, {
282
+ ...candidate,
283
+ stagedPageIds: stagedPageIds.length > MAXIMUM_READINESS_PAGE_IDS ? undefined : stagedPageIds,
284
+ });
285
+ if (readiness.blockers.length > 0) {
286
+ throw new SiteAuthoringError(
287
+ "deploy.media_blocked",
288
+ `Taproot reports ${readiness.blockers.length} media blocker(s) on this candidate, starting with '${
289
+ readiness.blockers[0].uploadedName || readiness.blockers[0].imageId
290
+ }': ${readiness.blockers[0].message || readiness.blockers[0].state}.`,
291
+ { field: readiness.blockers[0].imageId || "blockers", status: readiness.blockers[0].state },
292
+ );
293
+ }
294
+ if (!readiness.hasCandidateChanges) {
295
+ onProgress("Taproot reports this candidate contains no changes; deploying it anyway would be a no-op.");
296
+ }
297
+
298
+ const created = await deploySite(client, siteId, {
299
+ siteId,
300
+ environment: DEPLOYMENT_ENVIRONMENT_STAGING,
301
+ ...candidate,
302
+ });
303
+ onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
304
+ const completed = await waitForDeployment(client, {
305
+ siteId,
306
+ deploymentId: created.id,
307
+ environment: DEPLOYMENT_ENVIRONMENT_STAGING,
308
+ onProgress,
309
+ now,
310
+ });
311
+ await recordDeployment(config, manifest, "staging", completed);
312
+ const stagingPreview = await inspectStagingPreview(client, siteId, onProgress);
313
+ return successResult(VERB_DEPLOY, siteId, {
314
+ target,
315
+ environment: DEPLOYMENT_ENVIRONMENT_STAGING,
316
+ selection: {
317
+ stagedPageCount: stagedPageIds.length,
318
+ selectedSettingsTypes,
319
+ includeNavigation,
320
+ },
321
+ deployment: completed,
322
+ readiness: reportReadiness(readiness),
323
+ stagingPreview,
324
+ nextStep: "deploy --production",
325
+ });
326
+ });
327
+ }
@@ -0,0 +1,89 @@
1
+ import {
2
+ CLI_BINARY_NAME,
3
+ DEFAULT_API_BASE_URL,
4
+ ENVIRONMENT_LOCAL,
5
+ ENVIRONMENT_PRODUCTION,
6
+ VERB_ENV,
7
+ VERB_LOGIN,
8
+ } from "../constants.js";
9
+ import { credentialApiOrigin, findCredential } from "../credentials.js";
10
+ import { SiteAuthoringError } from "../errors.js";
11
+ import {
12
+ environmentNameFor,
13
+ readStoredApiBaseUrl,
14
+ resolveEnvironmentSelector,
15
+ settingsPath,
16
+ writeStoredApiBaseUrl,
17
+ } from "../settings.js";
18
+ import { successResult } from "../session.js";
19
+
20
+ /**
21
+ * `taproot-site env [production|local|<url>]` — which Taproot to talk to.
22
+ *
23
+ * With no argument it reports; with one it switches. Switching writes nothing
24
+ * but the endpoint: credentials are stored per origin, so moving between
25
+ * environments and back finds the sign-in that was already there rather than
26
+ * discarding it.
27
+ */
28
+ export async function env(invocation) {
29
+ const environment = invocation.environment ?? process.env;
30
+ const selector = typeof invocation.environmentSelector === "string"
31
+ ? invocation.environmentSelector.trim()
32
+ : undefined;
33
+ const onProgress = invocation.onProgress ?? (() => {});
34
+
35
+ const current = (await readStoredApiBaseUrl(environment)) ?? DEFAULT_API_BASE_URL;
36
+ const apiBaseUrl = selector === undefined ? current : resolveApiBaseUrl(selector);
37
+
38
+ // The credential store is read *before* the switch is written, though it is
39
+ // only needed for the report afterwards. A corrupt store refuses; if that
40
+ // refusal came after the write, the command would exit non-zero having
41
+ // silently repointed every later command at a different Taproot — the
42
+ // worst combination, a failure that changed something. Read first, nothing
43
+ // changes on failure.
44
+ const { credential } = await findCredential(environment, credentialApiOrigin(apiBaseUrl));
45
+
46
+ if (selector !== undefined && apiBaseUrl !== current) {
47
+ await writeStoredApiBaseUrl(environment, apiBaseUrl);
48
+ }
49
+
50
+ // Reported for both forms, because "which Taproot am I pointed at, and am I
51
+ // signed in to it" is the actual question behind running this at all — and
52
+ // after a switch the answer usually changes.
53
+ const name = environmentNameFor(apiBaseUrl);
54
+
55
+ if (selector === undefined) {
56
+ onProgress(`Talking to ${name} (${credentialApiOrigin(apiBaseUrl)}).`);
57
+ } else if (apiBaseUrl === current) {
58
+ onProgress(`Already talking to ${name} (${credentialApiOrigin(apiBaseUrl)}).`);
59
+ } else {
60
+ onProgress(`Now talking to ${name} (${credentialApiOrigin(apiBaseUrl)}).`);
61
+ }
62
+ onProgress(
63
+ credential === undefined
64
+ ? `Not signed in there yet — run '${CLI_BINARY_NAME} ${VERB_LOGIN}'.`
65
+ : `Signed in there as account ${credential.accountId}.`,
66
+ );
67
+
68
+ return successResult(VERB_ENV, undefined, {
69
+ environment: name,
70
+ apiOrigin: credentialApiOrigin(apiBaseUrl),
71
+ signedIn: credential !== undefined,
72
+ settingsPath: settingsPath(environment),
73
+ changed: selector !== undefined && apiBaseUrl !== current,
74
+ });
75
+ }
76
+
77
+ function resolveApiBaseUrl(selector) {
78
+ try {
79
+ return resolveEnvironmentSelector(selector);
80
+ } catch {
81
+ throw new SiteAuthoringError(
82
+ "env.unknown",
83
+ `'${selector}' is not a Taproot this CLI talks to. `
84
+ + `Use '${CLI_BINARY_NAME} ${VERB_ENV} ${ENVIRONMENT_PRODUCTION}', `
85
+ + `'${CLI_BINARY_NAME} ${VERB_ENV} ${ENVIRONMENT_LOCAL}', or an explicit loopback URL ending in '/api'.`,
86
+ { field: "environmentSelector", exitCode: 2 },
87
+ );
88
+ }
89
+ }