@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
|
@@ -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
|
+
}
|
package/src/verbs/env.js
ADDED
|
@@ -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
|
+
}
|