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