A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import * as fs from "fs/promises";
2import { command } from "cmd-ts";
3import {
4 intro,
5 outro,
6 note,
7 text,
8 confirm,
9 select,
10 spinner,
11 log,
12 group,
13} from "@clack/prompts";
14import * as path from "path";
15import { findConfig, generateConfigTemplate } from "../lib/config";
16import { loadCredentials } from "../lib/credentials";
17import { createAgent, createPublication } from "../lib/atproto";
18import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
19
20async function fileExists(filePath: string): Promise<boolean> {
21 try {
22 await fs.access(filePath);
23 return true;
24 } catch {
25 return false;
26 }
27}
28
29const onCancel = () => {
30 outro("Setup cancelled");
31 process.exit(0);
32};
33
34export const initCommand = command({
35 name: "init",
36 description: "Initialize a new publisher configuration",
37 args: {},
38 handler: async () => {
39 intro("Sequoia Configuration Setup");
40
41 // Check if config already exists
42 const existingConfig = await findConfig();
43 if (existingConfig) {
44 const overwrite = await confirm({
45 message: `Config already exists at ${existingConfig}. Overwrite?`,
46 initialValue: false,
47 });
48 if (overwrite === Symbol.for("cancel")) {
49 onCancel();
50 }
51 if (!overwrite) {
52 log.info("Keeping existing configuration");
53 return;
54 }
55 }
56
57 note("Follow the prompts to build your config for publishing", "Setup");
58
59 // Site configuration group
60 const siteConfig = await group(
61 {
62 siteUrl: () =>
63 text({
64 message: "Site URL (canonical URL of your site):",
65 placeholder: "https://example.com",
66 validate: (value) => {
67 if (!value) return "Site URL is required";
68 try {
69 new URL(value);
70 } catch {
71 return "Please enter a valid URL";
72 }
73 },
74 }),
75 contentDir: () =>
76 text({
77 message: "Content directory:",
78 placeholder: "./src/content/blog",
79 }),
80 imagesDir: () =>
81 text({
82 message: "Cover images directory (leave empty to skip):",
83 placeholder: "./src/assets",
84 }),
85 publicDir: () =>
86 text({
87 message: "Public/static directory (for .well-known files):",
88 placeholder: "./public",
89 }),
90 outputDir: () =>
91 text({
92 message: "Build output directory (for link tag injection):",
93 placeholder: "./dist",
94 }),
95 pathPrefix: () =>
96 text({
97 message: "URL path prefix for posts:",
98 placeholder: "/posts, /blog, /articles, etc.",
99 }),
100 },
101 { onCancel },
102 );
103
104 log.info(
105 "Configure your frontmatter field mappings (press Enter to use defaults):",
106 );
107
108 // Frontmatter mapping group
109 const frontmatterConfig = await group(
110 {
111 titleField: () =>
112 text({
113 message: "Field name for title:",
114 defaultValue: "title",
115 placeholder: "title",
116 }),
117 descField: () =>
118 text({
119 message: "Field name for description:",
120 defaultValue: "description",
121 placeholder: "description",
122 }),
123 dateField: () =>
124 text({
125 message: "Field name for publish date:",
126 defaultValue: "publishDate",
127 placeholder: "publishDate, pubDate, date, etc.",
128 }),
129 coverField: () =>
130 text({
131 message: "Field name for cover image:",
132 defaultValue: "ogImage",
133 placeholder: "ogImage, coverImage, image, hero, etc.",
134 }),
135 tagsField: () =>
136 text({
137 message: "Field name for tags:",
138 defaultValue: "tags",
139 placeholder: "tags, categories, keywords, etc.",
140 }),
141 draftField: () =>
142 text({
143 message: "Field name for draft status:",
144 defaultValue: "draft",
145 placeholder: "draft, private, hidden, etc.",
146 }),
147 },
148 { onCancel },
149 );
150
151 // Build frontmatter mapping object
152 const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
153 ["title", frontmatterConfig.titleField, "title"],
154 ["description", frontmatterConfig.descField, "description"],
155 ["publishDate", frontmatterConfig.dateField, "publishDate"],
156 ["coverImage", frontmatterConfig.coverField, "ogImage"],
157 ["tags", frontmatterConfig.tagsField, "tags"],
158 ["draft", frontmatterConfig.draftField, "draft"],
159 ];
160
161 const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
162 (acc, [key, value, defaultValue]) => {
163 if (value !== defaultValue) {
164 acc[key] = value;
165 }
166 return acc;
167 },
168 {},
169 );
170
171 // Only keep frontmatterMapping if it has any custom fields
172 const frontmatterMapping =
173 Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
174
175 // Publication setup
176 const publicationChoice = await select({
177 message: "Publication setup:",
178 options: [
179 { label: "Create a new publication", value: "create" },
180 { label: "Use an existing publication AT URI", value: "existing" },
181 ],
182 });
183
184 if (publicationChoice === Symbol.for("cancel")) {
185 onCancel();
186 }
187
188 let publicationUri: string;
189 const credentials = await loadCredentials();
190
191 if (publicationChoice === "create") {
192 // Need credentials to create a publication
193 if (!credentials) {
194 log.error(
195 "You must authenticate first. Run 'sequoia auth' before creating a publication.",
196 );
197 process.exit(1);
198 }
199
200 const s = spinner();
201 s.start("Connecting to ATProto...");
202 let agent;
203 try {
204 agent = await createAgent(credentials);
205 s.stop("Connected!");
206 } catch (error) {
207 s.stop("Failed to connect");
208 log.error(
209 "Failed to connect. Check your credentials with 'sequoia auth'.",
210 );
211 process.exit(1);
212 }
213
214 const publicationConfig = await group(
215 {
216 name: () =>
217 text({
218 message: "Publication name:",
219 placeholder: "My Blog",
220 validate: (value) => {
221 if (!value) return "Publication name is required";
222 },
223 }),
224 description: () =>
225 text({
226 message: "Publication description (optional):",
227 placeholder: "A blog about...",
228 }),
229 iconPath: () =>
230 text({
231 message: "Icon image path (leave empty to skip):",
232 placeholder: "./public/favicon.png",
233 }),
234 showInDiscover: () =>
235 confirm({
236 message: "Show in Discover feed?",
237 initialValue: true,
238 }),
239 },
240 { onCancel },
241 );
242
243 s.start("Creating publication...");
244 try {
245 publicationUri = await createPublication(agent, {
246 url: siteConfig.siteUrl,
247 name: publicationConfig.name,
248 description: publicationConfig.description || undefined,
249 iconPath: publicationConfig.iconPath || undefined,
250 showInDiscover: publicationConfig.showInDiscover,
251 });
252 s.stop(`Publication created: ${publicationUri}`);
253 } catch (error) {
254 s.stop("Failed to create publication");
255 log.error(`Failed to create publication: ${error}`);
256 process.exit(1);
257 }
258 } else {
259 const uri = await text({
260 message: "Publication AT URI:",
261 placeholder: "at://did:plc:.../site.standard.publication/...",
262 validate: (value) => {
263 if (!value) return "Publication URI is required";
264 },
265 });
266
267 if (uri === Symbol.for("cancel")) {
268 onCancel();
269 }
270 publicationUri = uri as string;
271 }
272
273 // Bluesky posting configuration
274 const enableBluesky = await confirm({
275 message: "Enable automatic Bluesky posting when publishing?",
276 initialValue: false,
277 });
278
279 if (enableBluesky === Symbol.for("cancel")) {
280 onCancel();
281 }
282
283 let blueskyConfig: BlueskyConfig | undefined;
284 if (enableBluesky) {
285 const maxAgeDaysInput = await text({
286 message: "Maximum age (in days) for posts to be shared on Bluesky:",
287 defaultValue: "7",
288 placeholder: "7",
289 validate: (value) => {
290 const num = parseInt(value, 10);
291 if (isNaN(num) || num < 1) {
292 return "Please enter a positive number";
293 }
294 },
295 });
296
297 if (maxAgeDaysInput === Symbol.for("cancel")) {
298 onCancel();
299 }
300
301 const maxAgeDays = parseInt(maxAgeDaysInput as string, 10);
302 blueskyConfig = {
303 enabled: true,
304 ...(maxAgeDays !== 7 && { maxAgeDays }),
305 };
306 }
307
308 // Get PDS URL from credentials (already loaded earlier)
309 const pdsUrl = credentials?.pdsUrl;
310
311 // Generate config file
312 const configContent = generateConfigTemplate({
313 siteUrl: siteConfig.siteUrl,
314 contentDir: siteConfig.contentDir || "./content",
315 imagesDir: siteConfig.imagesDir || undefined,
316 publicDir: siteConfig.publicDir || "./public",
317 outputDir: siteConfig.outputDir || "./dist",
318 pathPrefix: siteConfig.pathPrefix || "/posts",
319 publicationUri,
320 pdsUrl,
321 frontmatter: frontmatterMapping,
322 bluesky: blueskyConfig,
323 });
324
325 const configPath = path.join(process.cwd(), "sequoia.json");
326 await fs.writeFile(configPath, configContent);
327
328 log.success(`Configuration saved to ${configPath}`);
329
330 // Create .well-known/site.standard.publication file
331 const publicDir = siteConfig.publicDir || "./public";
332 const resolvedPublicDir = path.isAbsolute(publicDir)
333 ? publicDir
334 : path.join(process.cwd(), publicDir);
335 const wellKnownDir = path.join(resolvedPublicDir, ".well-known");
336 const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
337
338 // Ensure .well-known directory exists
339 await fs.mkdir(wellKnownDir, { recursive: true });
340 await fs.writeFile(path.join(wellKnownDir, ".gitkeep"), "");
341 await fs.writeFile(wellKnownPath, publicationUri);
342
343 log.success(`Created ${wellKnownPath}`);
344
345 // Update .gitignore
346 const gitignorePath = path.join(process.cwd(), ".gitignore");
347 const stateFilename = ".sequoia-state.json";
348
349 if (await fileExists(gitignorePath)) {
350 const gitignoreContent = await fs.readFile(gitignorePath, "utf-8");
351 if (!gitignoreContent.includes(stateFilename)) {
352 await fs.writeFile(
353 gitignorePath,
354 gitignoreContent + `\n${stateFilename}\n`,
355 );
356 log.info(`Added ${stateFilename} to .gitignore`);
357 }
358 } else {
359 await fs.writeFile(gitignorePath, `${stateFilename}\n`);
360 log.info(`Created .gitignore with ${stateFilename}`);
361 }
362
363 note(
364 "Next steps:\n" +
365 "1. Run 'sequoia publish --dry-run' to preview\n" +
366 "2. Run 'sequoia publish' to publish your content",
367 "Setup complete!",
368 );
369
370 outro("Happy publishing!");
371 },
372});