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 } 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 },
142 { onCancel },
143 );
144
145 // Build frontmatter mapping object
146 const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
147 ["title", frontmatterConfig.titleField, "title"],
148 ["description", frontmatterConfig.descField, "description"],
149 ["publishDate", frontmatterConfig.dateField, "publishDate"],
150 ["coverImage", frontmatterConfig.coverField, "ogImage"],
151 ["tags", frontmatterConfig.tagsField, "tags"],
152 ];
153
154 const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
155 (acc, [key, value, defaultValue]) => {
156 if (value !== defaultValue) {
157 acc[key] = value;
158 }
159 return acc;
160 },
161 {},
162 );
163
164 // Only keep frontmatterMapping if it has any custom fields
165 const frontmatterMapping =
166 Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
167
168 // Publication setup
169 const publicationChoice = await select({
170 message: "Publication setup:",
171 options: [
172 { label: "Create a new publication", value: "create" },
173 { label: "Use an existing publication AT URI", value: "existing" },
174 ],
175 });
176
177 if (publicationChoice === Symbol.for("cancel")) {
178 onCancel();
179 }
180
181 let publicationUri: string;
182 const credentials = await loadCredentials();
183
184 if (publicationChoice === "create") {
185 // Need credentials to create a publication
186 if (!credentials) {
187 log.error(
188 "You must authenticate first. Run 'sequoia auth' before creating a publication.",
189 );
190 process.exit(1);
191 }
192
193 const s = spinner();
194 s.start("Connecting to ATProto...");
195 let agent;
196 try {
197 agent = await createAgent(credentials);
198 s.stop("Connected!");
199 } catch (error) {
200 s.stop("Failed to connect");
201 log.error(
202 "Failed to connect. Check your credentials with 'sequoia auth'.",
203 );
204 process.exit(1);
205 }
206
207 const publicationConfig = await group(
208 {
209 name: () =>
210 text({
211 message: "Publication name:",
212 placeholder: "My Blog",
213 validate: (value) => {
214 if (!value) return "Publication name is required";
215 },
216 }),
217 description: () =>
218 text({
219 message: "Publication description (optional):",
220 placeholder: "A blog about...",
221 }),
222 iconPath: () =>
223 text({
224 message: "Icon image path (leave empty to skip):",
225 placeholder: "./public/favicon.png",
226 }),
227 showInDiscover: () =>
228 confirm({
229 message: "Show in Discover feed?",
230 initialValue: true,
231 }),
232 },
233 { onCancel },
234 );
235
236 s.start("Creating publication...");
237 try {
238 publicationUri = await createPublication(agent, {
239 url: siteConfig.siteUrl,
240 name: publicationConfig.name,
241 description: publicationConfig.description || undefined,
242 iconPath: publicationConfig.iconPath || undefined,
243 showInDiscover: publicationConfig.showInDiscover,
244 });
245 s.stop(`Publication created: ${publicationUri}`);
246 } catch (error) {
247 s.stop("Failed to create publication");
248 log.error(`Failed to create publication: ${error}`);
249 process.exit(1);
250 }
251 } else {
252 const uri = await text({
253 message: "Publication AT URI:",
254 placeholder: "at://did:plc:.../site.standard.publication/...",
255 validate: (value) => {
256 if (!value) return "Publication URI is required";
257 },
258 });
259
260 if (uri === Symbol.for("cancel")) {
261 onCancel();
262 }
263 publicationUri = uri as string;
264 }
265
266 // Get PDS URL from credentials (already loaded earlier)
267 const pdsUrl = credentials?.pdsUrl;
268
269 // Generate config file
270 const configContent = generateConfigTemplate({
271 siteUrl: siteConfig.siteUrl,
272 contentDir: siteConfig.contentDir || "./content",
273 imagesDir: siteConfig.imagesDir || undefined,
274 publicDir: siteConfig.publicDir || "./public",
275 outputDir: siteConfig.outputDir || "./dist",
276 pathPrefix: siteConfig.pathPrefix || "/posts",
277 publicationUri,
278 pdsUrl,
279 frontmatter: frontmatterMapping,
280 });
281
282 const configPath = path.join(process.cwd(), "sequoia.json");
283 await fs.writeFile(configPath, configContent);
284
285 log.success(`Configuration saved to ${configPath}`);
286
287 // Create .well-known/site.standard.publication file
288 const publicDir = siteConfig.publicDir || "./public";
289 const resolvedPublicDir = path.isAbsolute(publicDir)
290 ? publicDir
291 : path.join(process.cwd(), publicDir);
292 const wellKnownDir = path.join(resolvedPublicDir, ".well-known");
293 const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
294
295 // Ensure .well-known directory exists
296 await fs.mkdir(wellKnownDir, { recursive: true });
297 await fs.writeFile(path.join(wellKnownDir, ".gitkeep"), "");
298 await fs.writeFile(wellKnownPath, publicationUri);
299
300 log.success(`Created ${wellKnownPath}`);
301
302 // Update .gitignore
303 const gitignorePath = path.join(process.cwd(), ".gitignore");
304 const stateFilename = ".sequoia-state.json";
305
306 if (await fileExists(gitignorePath)) {
307 const gitignoreContent = await fs.readFile(gitignorePath, "utf-8");
308 if (!gitignoreContent.includes(stateFilename)) {
309 await fs.writeFile(
310 gitignorePath,
311 gitignoreContent + `\n${stateFilename}\n`,
312 );
313 log.info(`Added ${stateFilename} to .gitignore`);
314 }
315 } else {
316 await fs.writeFile(gitignorePath, `${stateFilename}\n`);
317 log.info(`Created .gitignore with ${stateFilename}`);
318 }
319
320 note(
321 "Next steps:\n" +
322 "1. Run 'sequoia publish --dry-run' to preview\n" +
323 "2. Run 'sequoia publish' to publish your content",
324 "Setup complete!",
325 );
326
327 outro("Happy publishing!");
328 },
329});