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