@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,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
+ }
@@ -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
+ }