@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,57 @@
|
|
|
1
|
+
import { listAuthorableSites, withRefusalGuidance } from "../api.js";
|
|
2
|
+
import { CLI_BINARY_NAME, VERB_SITES, VERB_USE } from "../constants.js";
|
|
3
|
+
import { sanitizeDiagnostic } from "../errors.js";
|
|
4
|
+
import { boundedList, openAccountSession, successResult } from "../session.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `taproot-site sites` — the sites this sign-in may author.
|
|
8
|
+
*
|
|
9
|
+
* The answer that used to require reading a site id out of the browser's URL
|
|
10
|
+
* bar and pasting it into a hand-written configuration file (TR00645). It runs
|
|
11
|
+
* on the sign-in credential, which is the only thing that can enumerate an
|
|
12
|
+
* account's sites, and it is one of exactly two things that credential can do.
|
|
13
|
+
*
|
|
14
|
+
* The server has already filtered the list to sites an exchange would accept,
|
|
15
|
+
* so everything printed here is something `use` can select and the next command
|
|
16
|
+
* can author.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* More than a terminal can usefully read at once, and well inside the result
|
|
21
|
+
* size bound. A studio account with more sites than this gets a reported
|
|
22
|
+
* truncation rather than a failed command.
|
|
23
|
+
*/
|
|
24
|
+
const MAXIMUM_SITES = 200;
|
|
25
|
+
|
|
26
|
+
export async function sites(invocation) {
|
|
27
|
+
const { client, signIn, onProgress } = await openAccountSession(invocation);
|
|
28
|
+
|
|
29
|
+
return await withRefusalGuidance(onProgress, "site list", async () => {
|
|
30
|
+
const authorable = await listAuthorableSites(client);
|
|
31
|
+
const reported = boundedList(authorable, MAXIMUM_SITES);
|
|
32
|
+
|
|
33
|
+
if (authorable.length === 0) {
|
|
34
|
+
onProgress("This account has no sites that can be authored from the CLI.");
|
|
35
|
+
} else {
|
|
36
|
+
for (const site of reported.items) {
|
|
37
|
+
// Sanitized on the way to the terminal, raw in the JSON result. Site
|
|
38
|
+
// names are authored by people and can carry terminal controls or bidi
|
|
39
|
+
// overrides; a listing is exactly where that would be used to make one
|
|
40
|
+
// site read as another the operator then selects.
|
|
41
|
+
const name = sanitizeDiagnostic(site.name, site.siteId);
|
|
42
|
+
const domain = site.primaryDomain ? ` ${sanitizeDiagnostic(site.primaryDomain, "")}` : "";
|
|
43
|
+
onProgress(`${site.siteId} ${name}${domain}`);
|
|
44
|
+
}
|
|
45
|
+
onProgress(`Select one with '${CLI_BINARY_NAME} ${VERB_USE} <name or id>'.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return successResult(VERB_SITES, undefined, {
|
|
49
|
+
accountId: signIn.accountId,
|
|
50
|
+
sites: {
|
|
51
|
+
total: authorable.length,
|
|
52
|
+
items: reported.items,
|
|
53
|
+
...(reported.truncated ? { itemsTruncated: true } : {}),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getPublishingReadiness,
|
|
3
|
+
IMAGE_PROCESSING_STATE_COMPLETE,
|
|
4
|
+
IMAGE_PROCESSING_STATE_FAILED,
|
|
5
|
+
listDeployments,
|
|
6
|
+
listSiteImages,
|
|
7
|
+
withRefusalGuidance,
|
|
8
|
+
} from "../api.js";
|
|
9
|
+
import { VERB_STATUS } from "../constants.js";
|
|
10
|
+
import { boundedList, openSession, successResult } from "../session.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `status` — deployments, publishing readiness, and image processing.
|
|
14
|
+
*
|
|
15
|
+
* Broken references are deliberately absent. `GetSiteBrokenReferences` is
|
|
16
|
+
* session-only under TR00602's read list, and TR00604 adds no server surface,
|
|
17
|
+
* so there is no key-authorized way to read them. The verb says that out loud —
|
|
18
|
+
* on the human channel and as a field in the JSON result — rather than
|
|
19
|
+
* reporting a clean site and implying a coverage it does not have.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MAXIMUM_DEPLOYMENTS = 20;
|
|
23
|
+
const MAXIMUM_FAILED_IMAGES = 50;
|
|
24
|
+
const MAXIMUM_BLOCKERS = 50;
|
|
25
|
+
|
|
26
|
+
const BROKEN_REFERENCES_NOTE =
|
|
27
|
+
"GetSiteBrokenReferences is session-only on the shipped contract, so a key-authorized CLI cannot read it.";
|
|
28
|
+
|
|
29
|
+
export async function status(invocation) {
|
|
30
|
+
const { client, siteId, onProgress } = await openSession(invocation);
|
|
31
|
+
|
|
32
|
+
return await withRefusalGuidance(onProgress, "status check", async () => {
|
|
33
|
+
onProgress("Reading publishing readiness.");
|
|
34
|
+
const readiness = await getPublishingReadiness(client, siteId);
|
|
35
|
+
onProgress("Reading the deployment log.");
|
|
36
|
+
const {
|
|
37
|
+
deployments,
|
|
38
|
+
truncated: deploymentsTruncated,
|
|
39
|
+
} = await listDeployments(client, siteId, { pageSize: MAXIMUM_DEPLOYMENTS });
|
|
40
|
+
onProgress("Reading image processing state.");
|
|
41
|
+
const { images, summary, truncated } = await listSiteImages(client, siteId);
|
|
42
|
+
|
|
43
|
+
const byState = {};
|
|
44
|
+
for (const image of images) {
|
|
45
|
+
byState[image.processingState] = (byState[image.processingState] ?? 0) + 1;
|
|
46
|
+
}
|
|
47
|
+
const failedImages = boundedList(
|
|
48
|
+
images
|
|
49
|
+
.filter((image) => image.processingState === IMAGE_PROCESSING_STATE_FAILED)
|
|
50
|
+
.map((image) => ({
|
|
51
|
+
imageId: image.imageId,
|
|
52
|
+
uploadedName: image.uploadedName,
|
|
53
|
+
reason: image.processingFailureReason,
|
|
54
|
+
})),
|
|
55
|
+
MAXIMUM_FAILED_IMAGES,
|
|
56
|
+
);
|
|
57
|
+
const reportedDeployments = boundedList(deployments, MAXIMUM_DEPLOYMENTS);
|
|
58
|
+
const blockers = boundedList(readiness.blockers, MAXIMUM_BLOCKERS);
|
|
59
|
+
|
|
60
|
+
onProgress(`Broken references are not reported: ${BROKEN_REFERENCES_NOTE}`);
|
|
61
|
+
|
|
62
|
+
return successResult(VERB_STATUS, siteId, {
|
|
63
|
+
readiness: {
|
|
64
|
+
state: readiness.state,
|
|
65
|
+
approvedPageCount: readiness.approvedPageCount,
|
|
66
|
+
blockedPageCount: readiness.blockedPageCount,
|
|
67
|
+
hasCandidateChanges: readiness.hasCandidateChanges,
|
|
68
|
+
hasSuccessfulStagingDeployment: readiness.hasSuccessfulStagingDeployment,
|
|
69
|
+
blockers: blockers.items,
|
|
70
|
+
...(blockers.truncated ? { blockersTruncated: true } : {}),
|
|
71
|
+
},
|
|
72
|
+
deployments: {
|
|
73
|
+
// One page of the log, not the site's history. `listTruncated` says so
|
|
74
|
+
// rather than letting `total` read as a count of every deployment the
|
|
75
|
+
// site has ever had.
|
|
76
|
+
total: deployments.length,
|
|
77
|
+
items: reportedDeployments.items,
|
|
78
|
+
...(reportedDeployments.truncated ? { itemsTruncated: true } : {}),
|
|
79
|
+
...(deploymentsTruncated ? { listTruncated: true } : {}),
|
|
80
|
+
},
|
|
81
|
+
images: {
|
|
82
|
+
total: summary.totalImages,
|
|
83
|
+
processing: summary.processingImages,
|
|
84
|
+
complete: byState[IMAGE_PROCESSING_STATE_COMPLETE] ?? 0,
|
|
85
|
+
failed: byState[IMAGE_PROCESSING_STATE_FAILED] ?? 0,
|
|
86
|
+
byState,
|
|
87
|
+
failedItems: failedImages.items,
|
|
88
|
+
...(failedImages.truncated ? { failedItemsTruncated: true } : {}),
|
|
89
|
+
...(truncated ? { listTruncated: true } : {}),
|
|
90
|
+
},
|
|
91
|
+
brokenReferences: { covered: false, reason: BROKEN_REFERENCES_NOTE },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { getSettingsGroup, saveSiteFooterSettings, setSetting, withRefusalGuidance } from "../api.js";
|
|
2
|
+
import {
|
|
3
|
+
applyFooterColors,
|
|
4
|
+
buildAppearanceScalarOperations,
|
|
5
|
+
footerColorOverlay,
|
|
6
|
+
} from "../appearance-contract.js";
|
|
7
|
+
import { VERB_THEME_PUSH } from "../constants.js";
|
|
8
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
9
|
+
import { computeFooterDraftHash } from "../footer-draft-hash.js";
|
|
10
|
+
import { projectFooterSettingsForWorkspace } from "../footer-contract.js";
|
|
11
|
+
import {
|
|
12
|
+
advanceFooterManifest,
|
|
13
|
+
readAppearanceWorkspaceContext,
|
|
14
|
+
requireFooterContentPushed,
|
|
15
|
+
} from "../footer-workspace.js";
|
|
16
|
+
import { boundedList, openSession, successResult } from "../session.js";
|
|
17
|
+
import {
|
|
18
|
+
SETTINGS_TYPE_BRAND,
|
|
19
|
+
SETTINGS_TYPE_SITE_HEADER,
|
|
20
|
+
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
21
|
+
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
22
|
+
} from "../settings-catalog.js";
|
|
23
|
+
import {
|
|
24
|
+
validateAndEncodeThemePair,
|
|
25
|
+
} from "../theme-validation.js";
|
|
26
|
+
import {
|
|
27
|
+
readWorkspaceJson,
|
|
28
|
+
SETTINGS_DIRECTORY,
|
|
29
|
+
WORKSPACE_LIMITS,
|
|
30
|
+
workspaceFileExists,
|
|
31
|
+
writeWorkspaceJson,
|
|
32
|
+
} from "../workspace.js";
|
|
33
|
+
|
|
34
|
+
const SETTINGS_FILES = Object.freeze({
|
|
35
|
+
[SETTINGS_TYPE_TAPROOT_STYLES]: "taproot-styles.json",
|
|
36
|
+
[SETTINGS_TYPE_BRAND]: "brand.json",
|
|
37
|
+
[SETTINGS_TYPE_SITE_HEADER]: "site-header.json",
|
|
38
|
+
[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]: "site-publishing-preferences.json",
|
|
39
|
+
});
|
|
40
|
+
function isPlainObject(value) {
|
|
41
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fail(code, message, field) {
|
|
45
|
+
throw new SiteAuthoringError(code, message, { field });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readSettingsDocument(workspaceDir, siteId, settingsType, expectedEntityId = siteId) {
|
|
49
|
+
const file = `${SETTINGS_DIRECTORY}/${SETTINGS_FILES[settingsType]}`;
|
|
50
|
+
if (!await workspaceFileExists(workspaceDir, file)) {
|
|
51
|
+
fail("theme.settings_missing", `Workspace file '${file}' is missing. Run 'taproot-site pull' first.`, file);
|
|
52
|
+
}
|
|
53
|
+
const document = await readWorkspaceJson(workspaceDir, file, WORKSPACE_LIMITS.settingsBytes);
|
|
54
|
+
if (!isPlainObject(document) || !isPlainObject(document.settings)) {
|
|
55
|
+
fail("theme.settings_invalid", `${file} must be the settings document written by pull.`, file);
|
|
56
|
+
}
|
|
57
|
+
if (document.entityId !== expectedEntityId || document.settingsType !== settingsType) {
|
|
58
|
+
const binding = expectedEntityId === siteId ? "site" : "fixture entity";
|
|
59
|
+
const guidance = expectedEntityId === siteId ? " Run pull into this workspace again." : "";
|
|
60
|
+
fail(
|
|
61
|
+
"theme.settings_site_mismatch",
|
|
62
|
+
`${file} is not bound to ${binding} ${expectedEntityId} and settings group ${settingsType}.${guidance}`,
|
|
63
|
+
file,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return document.settings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The complete local validation phase shared by live push and offline fixtures. */
|
|
70
|
+
export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds, expectedEntityIds) {
|
|
71
|
+
const [style, brand, header, publishing] = await Promise.all([
|
|
72
|
+
readSettingsDocument(
|
|
73
|
+
workspaceDir,
|
|
74
|
+
siteId,
|
|
75
|
+
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
76
|
+
expectedEntityIds?.get(SETTINGS_TYPE_TAPROOT_STYLES),
|
|
77
|
+
),
|
|
78
|
+
readSettingsDocument(
|
|
79
|
+
workspaceDir,
|
|
80
|
+
siteId,
|
|
81
|
+
SETTINGS_TYPE_BRAND,
|
|
82
|
+
expectedEntityIds?.get(SETTINGS_TYPE_BRAND),
|
|
83
|
+
),
|
|
84
|
+
readSettingsDocument(
|
|
85
|
+
workspaceDir,
|
|
86
|
+
siteId,
|
|
87
|
+
SETTINGS_TYPE_SITE_HEADER,
|
|
88
|
+
expectedEntityIds?.get(SETTINGS_TYPE_SITE_HEADER),
|
|
89
|
+
),
|
|
90
|
+
readSettingsDocument(
|
|
91
|
+
workspaceDir,
|
|
92
|
+
siteId,
|
|
93
|
+
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
94
|
+
expectedEntityIds?.get(SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES),
|
|
95
|
+
),
|
|
96
|
+
]);
|
|
97
|
+
const themes = validateAndEncodeThemePair(style.lightTheme, style.darkTheme);
|
|
98
|
+
const scalarOperations = buildAppearanceScalarOperations(
|
|
99
|
+
{
|
|
100
|
+
[SETTINGS_TYPE_TAPROOT_STYLES]: style,
|
|
101
|
+
[SETTINGS_TYPE_BRAND]: brand,
|
|
102
|
+
[SETTINGS_TYPE_SITE_HEADER]: header,
|
|
103
|
+
},
|
|
104
|
+
knownImageIds,
|
|
105
|
+
);
|
|
106
|
+
const footerColors = footerColorOverlay(publishing.footerSettings);
|
|
107
|
+
return { style, brand, header, publishing, themes, scalarOperations, footerColors };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function themePush(invocation) {
|
|
111
|
+
const { client, config, siteId, onProgress } = await openSession(invocation);
|
|
112
|
+
const appearanceContext = await readAppearanceWorkspaceContext(config.workspaceDir, siteId);
|
|
113
|
+
const { publishing, themes, scalarOperations, footerColors } = await validateThemeWorkspace(
|
|
114
|
+
config.workspaceDir,
|
|
115
|
+
siteId,
|
|
116
|
+
appearanceContext.knownImageIds,
|
|
117
|
+
);
|
|
118
|
+
// The fresh footer read below replaces the workspace document, so refuse
|
|
119
|
+
// while it still carries footer-content edits that footer push has not
|
|
120
|
+
// saved. Colour-only differences are theme push's own to write.
|
|
121
|
+
requireFooterContentPushed(appearanceContext.footer, publishing.footerSettings);
|
|
122
|
+
onProgress(`Validated the complete light/dark theme pair and ${scalarOperations.length} appearance settings.`);
|
|
123
|
+
for (const warning of themes.warnings) onProgress(`Espalier warning: ${warning}`);
|
|
124
|
+
|
|
125
|
+
const written = [];
|
|
126
|
+
try {
|
|
127
|
+
await withRefusalGuidance(onProgress, "theme push", async () => {
|
|
128
|
+
// Re-read immediately before the concurrency-protected save and carry only
|
|
129
|
+
// the ten workspace color values across. Footer prose, links, and images
|
|
130
|
+
// always come from this fresh server document, never the pull snapshot.
|
|
131
|
+
const currentPublishing = await getSettingsGroup(
|
|
132
|
+
client,
|
|
133
|
+
siteId,
|
|
134
|
+
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
135
|
+
);
|
|
136
|
+
const currentFooter = currentPublishing.sitePublishingPreferences?.footerSettings ?? {};
|
|
137
|
+
const nextFooter = applyFooterColors(currentFooter, footerColors);
|
|
138
|
+
const footerResponse = await saveSiteFooterSettings(
|
|
139
|
+
client,
|
|
140
|
+
siteId,
|
|
141
|
+
nextFooter,
|
|
142
|
+
computeFooterDraftHash(currentFooter),
|
|
143
|
+
);
|
|
144
|
+
written.push("footerSettings.light/dark colors");
|
|
145
|
+
if (!isPlainObject(footerResponse.footerSettings)) {
|
|
146
|
+
throw new SiteAuthoringError(
|
|
147
|
+
"api.footer_settings_contract",
|
|
148
|
+
"Taproot returned no normalized footer document after saving the color overlay.",
|
|
149
|
+
{ field: "footerSettings" },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const savedFooter = footerResponse.footerSettings;
|
|
153
|
+
const projectedFooter = projectFooterSettingsForWorkspace(savedFooter);
|
|
154
|
+
await writeWorkspaceJson(
|
|
155
|
+
config.workspaceDir,
|
|
156
|
+
`${SETTINGS_DIRECTORY}/${SETTINGS_FILES[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]}`,
|
|
157
|
+
{
|
|
158
|
+
entityId: siteId,
|
|
159
|
+
settingsType: SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
160
|
+
settings: { ...publishing, footerSettings: projectedFooter },
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
await advanceFooterManifest(config.workspaceDir, siteId, projectedFooter);
|
|
164
|
+
onProgress("Saved the light and dark footer scheme colors over the current footer document.");
|
|
165
|
+
|
|
166
|
+
for (const operation of scalarOperations) {
|
|
167
|
+
await setSetting(client, siteId, operation.settingsType, operation.setting, operation.value);
|
|
168
|
+
written.push(`${operation.settingsType}.${operation.setting}`);
|
|
169
|
+
onProgress(`Saved ${operation.setting}.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The pair is locally valid and complete before either mutation starts.
|
|
173
|
+
// These writes are last so the first one is also the point at which the
|
|
174
|
+
// server marks the theme externally managed and the visual editor adopts
|
|
175
|
+
// its guarded mode.
|
|
176
|
+
await setSetting(client, siteId, SETTINGS_TYPE_TAPROOT_STYLES, "lightTheme", themes.light);
|
|
177
|
+
written.push(`${SETTINGS_TYPE_TAPROOT_STYLES}.lightTheme`);
|
|
178
|
+
await setSetting(client, siteId, SETTINGS_TYPE_TAPROOT_STYLES, "darkTheme", themes.dark);
|
|
179
|
+
written.push(`${SETTINGS_TYPE_TAPROOT_STYLES}.darkTheme`);
|
|
180
|
+
onProgress("Saved both complete themes; Brand & Style now treats the color model as externally managed.");
|
|
181
|
+
});
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error instanceof SiteAuthoringError && written.length > 0) {
|
|
184
|
+
throw error.withCompletedWrites(written);
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return successResult(VERB_THEME_PUSH, siteId, {
|
|
190
|
+
written: boundedList(written, 100),
|
|
191
|
+
warnings: {
|
|
192
|
+
items: themes.warnings,
|
|
193
|
+
count: themes.warningCount,
|
|
194
|
+
...(themes.warningsTruncated ? { truncated: true } : {}),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
package/src/verbs/use.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { listAuthorableSites, withRefusalGuidance } from "../api.js";
|
|
4
|
+
import { atomicWriteFile } from "../atomic-file.js";
|
|
5
|
+
import {
|
|
6
|
+
CLI_BINARY_NAME,
|
|
7
|
+
CONFIG_FILE_NAME,
|
|
8
|
+
CONFIG_VERSION,
|
|
9
|
+
DEFAULT_WORKSPACE_DIR,
|
|
10
|
+
VERB_SITES,
|
|
11
|
+
VERB_USE,
|
|
12
|
+
} from "../constants.js";
|
|
13
|
+
import { isCanonicalUuid, sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
|
|
14
|
+
import { openAccountSession, successResult } from "../session.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `taproot-site use <site>` — choose the site the next command writes to.
|
|
18
|
+
*
|
|
19
|
+
* The half of TR00645's onboarding fix that `sites` sets up: rather than
|
|
20
|
+
* hand-writing a site id into JSON, an operator names a site and this records
|
|
21
|
+
* the choice. Resolution accepts an id, an exact name, or an unambiguous
|
|
22
|
+
* case-insensitive name, because the id is the thing a human is least able to
|
|
23
|
+
* type from memory and most likely to get subtly wrong.
|
|
24
|
+
*
|
|
25
|
+
* An ambiguous name is refused rather than guessed. Two sites called "Blog" on
|
|
26
|
+
* one account is a real situation, and picking one of them silently would point
|
|
27
|
+
* every subsequent write at a site the operator did not choose.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
function selectSite(authorable, selector) {
|
|
31
|
+
if (isCanonicalUuid(selector)) {
|
|
32
|
+
return authorable.filter((site) => site.siteId === selector);
|
|
33
|
+
}
|
|
34
|
+
const exact = authorable.filter((site) => site.name === selector);
|
|
35
|
+
if (exact.length > 0) return exact;
|
|
36
|
+
const folded = selector.toLocaleLowerCase();
|
|
37
|
+
return authorable.filter((site) => site.name.toLocaleLowerCase() === folded);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function use(invocation) {
|
|
41
|
+
const selector = typeof invocation.siteSelector === "string" ? invocation.siteSelector.trim() : "";
|
|
42
|
+
if (selector.length === 0) {
|
|
43
|
+
throw new SiteAuthoringError(
|
|
44
|
+
"use.selector_missing",
|
|
45
|
+
`Name the site to use: '${CLI_BINARY_NAME} ${VERB_USE} <name or id>'. `
|
|
46
|
+
+ `'${CLI_BINARY_NAME} ${VERB_SITES}' lists them.`,
|
|
47
|
+
{ field: "selector", exitCode: 2 },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { client, config, signIn, onProgress } = await openAccountSession(invocation);
|
|
52
|
+
// With no configuration yet, this is the command that creates one, in the
|
|
53
|
+
// directory the operator ran it from.
|
|
54
|
+
const configPath = config?.configPath
|
|
55
|
+
?? path.join(path.resolve(invocation.cwd ?? process.cwd()), CONFIG_FILE_NAME);
|
|
56
|
+
|
|
57
|
+
return await withRefusalGuidance(onProgress, "site selection", async () => {
|
|
58
|
+
const authorable = await listAuthorableSites(client);
|
|
59
|
+
const matches = selectSite(authorable, selector);
|
|
60
|
+
|
|
61
|
+
if (matches.length === 0) {
|
|
62
|
+
throw new SiteAuthoringError(
|
|
63
|
+
"use.site_unknown",
|
|
64
|
+
`No site on this account matches '${selector}'. `
|
|
65
|
+
+ `Run '${CLI_BINARY_NAME} ${VERB_SITES}' to see what is available.`,
|
|
66
|
+
{ field: "selector" },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (matches.length > 1) {
|
|
70
|
+
throw new SiteAuthoringError(
|
|
71
|
+
"use.site_ambiguous",
|
|
72
|
+
`More than one site is named '${selector}': ${matches.map((site) => site.siteId).join(", ")}. `
|
|
73
|
+
+ "Use the site id instead.",
|
|
74
|
+
{ field: "selector" },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [site] = matches;
|
|
79
|
+
// An existing configuration keeps its workspace; a new one gets a real
|
|
80
|
+
// child directory. Never "." — `validateWorkspaceDirectoryText` rejects a
|
|
81
|
+
// "." segment outright, so writing it would produce a configuration the
|
|
82
|
+
// very next command refuses to load.
|
|
83
|
+
const workspaceDir = config === undefined
|
|
84
|
+
? DEFAULT_WORKSPACE_DIR
|
|
85
|
+
: path.relative(config.configDirectory, config.workspaceDir) || DEFAULT_WORKSPACE_DIR;
|
|
86
|
+
const contents = `${
|
|
87
|
+
JSON.stringify(
|
|
88
|
+
{
|
|
89
|
+
configVersion: CONFIG_VERSION,
|
|
90
|
+
siteId: site.siteId,
|
|
91
|
+
workspaceDir,
|
|
92
|
+
// No endpoint. Which Taproot this is talking to lives with the
|
|
93
|
+
// credential, per machine — writing it here would pin a project to
|
|
94
|
+
// whichever environment happened to be selected when someone ran
|
|
95
|
+
// `use`, which is how a repository ends up authoring against local
|
|
96
|
+
// in CI.
|
|
97
|
+
},
|
|
98
|
+
undefined,
|
|
99
|
+
2,
|
|
100
|
+
)
|
|
101
|
+
}\n`;
|
|
102
|
+
|
|
103
|
+
// The configuration is not a secret, so 0644 rather than the credential
|
|
104
|
+
// store's 0600 — but the write is the same atomic one, because a
|
|
105
|
+
// half-written config would leave the next command pointed at nothing.
|
|
106
|
+
await atomicWriteFile(configPath, contents, {
|
|
107
|
+
mode: 0o644,
|
|
108
|
+
failures: {
|
|
109
|
+
inspect: () =>
|
|
110
|
+
new SiteAuthoringError("use.config_unwritable", `Could not inspect '${configPath}' before writing.`),
|
|
111
|
+
notRegular: () =>
|
|
112
|
+
new SiteAuthoringError("use.config_not_regular", `'${configPath}' is not a regular file.`),
|
|
113
|
+
write: () =>
|
|
114
|
+
new SiteAuthoringError("use.config_unwritable", `Could not write '${configPath}'.`),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Sanitized for the same reason the listing is: the name is authored
|
|
119
|
+
// by a person and this line is what confirms the selection.
|
|
120
|
+
onProgress(`Now authoring ${sanitizeDiagnostic(site.name, site.siteId)} (${site.siteId}).`);
|
|
121
|
+
onProgress(`Recorded in ${configPath}.`);
|
|
122
|
+
|
|
123
|
+
return successResult(VERB_USE, site.siteId, {
|
|
124
|
+
accountId: signIn.accountId,
|
|
125
|
+
site: { siteId: site.siteId, name: site.name, primaryDomain: site.primaryDomain },
|
|
126
|
+
configPath: configPath,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|