···11import { command, flag, option, optional, string } from "cmd-ts";
22-import { consola } from "consola";
22+import { log } from "@clack/prompts";
33import * as path from "path";
44import { Glob } from "bun";
55import { loadConfig, loadState, findConfig } from "../lib/config";
···2525 // Load config
2626 const configPath = await findConfig();
2727 if (!configPath) {
2828- consola.error("No sequoia.json found. Run 'sequoia init' first.");
2828+ log.error("No sequoia.json found. Run 'sequoia init' first.");
2929 process.exit(1);
3030 }
3131···3838 ? outputDir
3939 : path.join(configDir, outputDir);
40404141- consola.info(`Scanning for HTML files in: ${resolvedOutputDir}`);
4141+ log.info(`Scanning for HTML files in: ${resolvedOutputDir}`);
42424343 // Load state to get atUri mappings
4444 const state = await loadState(configDir);
···8888 }
89899090 if (pathToAtUri.size === 0) {
9191- consola.warn(
9191+ log.warn(
9292 "No published posts found in state. Run 'sequoia publish' first.",
9393 );
9494 return;
9595 }
96969797- consola.info(`Found ${pathToAtUri.size} published posts in state`);
9797+ log.info(`Found ${pathToAtUri.size} published posts in state`);
98989999 // Scan for HTML files
100100 const glob = new Glob("**/*.html");
···105105 }
106106107107 if (htmlFiles.length === 0) {
108108- consola.warn(`No HTML files found in ${resolvedOutputDir}`);
108108+ log.warn(`No HTML files found in ${resolvedOutputDir}`);
109109 return;
110110 }
111111112112- consola.info(`Found ${htmlFiles.length} HTML files`);
112112+ log.info(`Found ${htmlFiles.length} HTML files`);
113113114114 let injectedCount = 0;
115115 let skippedCount = 0;
···165165 // Find </head> and inject before it
166166 const headCloseIndex = content.indexOf("</head>");
167167 if (headCloseIndex === -1) {
168168- consola.warn(` No </head> found in ${relativePath}, skipping`);
168168+ log.warn(` No </head> found in ${relativePath}, skipping`);
169169 skippedCount++;
170170 continue;
171171 }
172172173173 if (dryRun) {
174174- consola.log(` Would inject into: ${relativePath}`);
175175- consola.log(` ${linkTag}`);
174174+ log.message(` Would inject into: ${relativePath}`);
175175+ log.message(` ${linkTag}`);
176176 injectedCount++;
177177 continue;
178178 }
···185185 content.slice(headCloseIndex);
186186187187 await Bun.write(htmlPath, content);
188188- consola.success(` Injected into: ${relativePath}`);
188188+ log.success(` Injected into: ${relativePath}`);
189189 injectedCount++;
190190 }
191191192192 // Summary
193193- consola.log("\n---");
193193+ log.message("\n---");
194194 if (dryRun) {
195195- consola.info("Dry run complete. No changes made.");
195195+ log.info("Dry run complete. No changes made.");
196196 }
197197- consola.info(`Injected: ${injectedCount}`);
198198- consola.info(`Already has tag: ${alreadyHasCount}`);
199199- consola.info(`Skipped (no match): ${skippedCount}`);
197197+ log.info(`Injected: ${injectedCount}`);
198198+ log.info(`Already has tag: ${alreadyHasCount}`);
199199+ log.info(`Skipped (no match): ${skippedCount}`);
200200201201 if (skippedCount > 0 && !dryRun) {
202202- consola.info(
202202+ log.info(
203203 "\nTip: Skipped files had no matching published post. This is normal for non-post pages.",
204204 );
205205 }
+39-35
packages/cli/src/commands/publish.ts
···11import { command, flag } from "cmd-ts";
22-import { consola } from "consola";
22+import { select, spinner, log } from "@clack/prompts";
33import * as path from "path";
44import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
55import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
···1010 updateFrontmatterWithAtUri,
1111} from "../lib/markdown";
1212import type { BlogPost, BlobObject } from "../lib/types";
1313+import { exitOnCancel } from "../lib/prompts";
13141415export const publishCommand = command({
1516 name: "publish",
···3031 // Load config
3132 const configPath = await findConfig();
3233 if (!configPath) {
3333- consola.error("No publisher.config.ts found. Run 'publisher init' first.");
3434+ log.error("No publisher.config.ts found. Run 'publisher init' first.");
3435 process.exit(1);
3536 }
36373738 const config = await loadConfig(configPath);
3839 const configDir = path.dirname(configPath);
39404040- consola.info(`Site: ${config.siteUrl}`);
4141- consola.info(`Content directory: ${config.contentDir}`);
4141+ log.info(`Site: ${config.siteUrl}`);
4242+ log.info(`Content directory: ${config.contentDir}`);
42434344 // Load credentials
4445 let credentials = await loadCredentials(config.identity);
···4748 if (!credentials) {
4849 const identities = await listCredentials();
4950 if (identities.length === 0) {
5050- consola.error("No credentials found. Run 'sequoia auth' first.");
5151- consola.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.");
5151+ log.error("No credentials found. Run 'sequoia auth' first.");
5252+ log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.");
5253 process.exit(1);
5354 }
54555556 // Multiple identities exist but none selected - prompt user
5656- consola.info("Multiple identities found. Select one to use:");
5757- const selected = await consola.prompt("Identity:", {
5858- type: "select",
5959- options: identities,
6060- });
5757+ log.info("Multiple identities found. Select one to use:");
5858+ const selected = exitOnCancel(await select({
5959+ message: "Identity:",
6060+ options: identities.map(id => ({ value: id, label: id })),
6161+ }));
61626262- credentials = await getCredentials(selected as string);
6363+ credentials = await getCredentials(selected);
6364 if (!credentials) {
6464- consola.error("Failed to load selected credentials.");
6565+ log.error("Failed to load selected credentials.");
6566 process.exit(1);
6667 }
67686868- consola.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`);
6969+ log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`);
6970 }
70717172 // Resolve content directory
···8384 const state = await loadState(configDir);
84858586 // Scan for posts
8686- consola.start("Scanning for posts...");
8787+ const s = spinner();
8888+ s.start("Scanning for posts...");
8789 const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore);
8888- consola.info(`Found ${posts.length} posts`);
9090+ s.stop(`Found ${posts.length} posts`);
89919092 // Determine which posts need publishing
9193 const postsToPublish: Array<{
···123125 }
124126125127 if (postsToPublish.length === 0) {
126126- consola.success("All posts are up to date. Nothing to publish.");
128128+ log.success("All posts are up to date. Nothing to publish.");
127129 return;
128130 }
129131130130- consola.info(`\n${postsToPublish.length} posts to publish:\n`);
132132+ log.info(`\n${postsToPublish.length} posts to publish:\n`);
131133 for (const { post, action, reason } of postsToPublish) {
132134 const icon = action === "create" ? "+" : "~";
133133- consola.log(` ${icon} ${post.frontmatter.title} (${reason})`);
135135+ log.message(` ${icon} ${post.frontmatter.title} (${reason})`);
134136 }
135137136138 if (dryRun) {
137137- consola.info("\nDry run complete. No changes made.");
139139+ log.info("\nDry run complete. No changes made.");
138140 return;
139141 }
140142141143 // Create agent
142142- consola.start(`\nConnecting to ${credentials.pdsUrl}...`);
144144+ s.start(`Connecting to ${credentials.pdsUrl}...`);
143145 let agent;
144146 try {
145147 agent = await createAgent(credentials);
146146- consola.success(`Logged in as ${agent.session?.handle}`);
148148+ s.stop(`Logged in as ${agent.session?.handle}`);
147149 } catch (error) {
148148- consola.error("Failed to login:", error);
150150+ s.stop("Failed to login");
151151+ log.error(`Failed to login: ${error}`);
149152 process.exit(1);
150153 }
151154···155158 let errorCount = 0;
156159157160 for (const { post, action } of postsToPublish) {
158158- consola.start(`Publishing: ${post.frontmatter.title}`);
161161+ s.start(`Publishing: ${post.frontmatter.title}`);
159162160163 try {
161164 // Handle cover image upload
···168171 );
169172170173 if (imagePath) {
171171- consola.info(` Uploading cover image: ${path.basename(imagePath)}`);
174174+ log.info(` Uploading cover image: ${path.basename(imagePath)}`);
172175 coverImage = await uploadImage(agent, imagePath);
173176 if (coverImage) {
174174- consola.info(` Uploaded image blob: ${coverImage.ref.$link}`);
177177+ log.info(` Uploaded image blob: ${coverImage.ref.$link}`);
175178 }
176179 } else {
177177- consola.warn(` Cover image not found: ${post.frontmatter.ogImage}`);
180180+ log.warn(` Cover image not found: ${post.frontmatter.ogImage}`);
178181 }
179182 }
180183···184187185188 if (action === "create") {
186189 atUri = await createDocument(agent, post, config, coverImage);
187187- consola.success(` Created: ${atUri}`);
190190+ s.stop(`Created: ${atUri}`);
188191189192 // Update frontmatter with atUri
190193 const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri);
191194 await Bun.write(post.filePath, updatedContent);
192192- consola.info(` Updated frontmatter in ${path.basename(post.filePath)}`);
195195+ log.info(` Updated frontmatter in ${path.basename(post.filePath)}`);
193196194197 // Use updated content (with atUri) for hash so next run sees matching hash
195198 contentForHash = updatedContent;
···197200 } else {
198201 atUri = post.frontmatter.atUri!;
199202 await updateDocument(agent, post, atUri, config, coverImage);
200200- consola.success(` Updated: ${atUri}`);
203203+ s.stop(`Updated: ${atUri}`);
201204202205 // For updates, rawContent already has atUri
203206 contentForHash = post.rawContent;
···214217 };
215218 } catch (error) {
216219 const errorMessage = error instanceof Error ? error.message : String(error);
217217- consola.error(` Error publishing "${path.basename(post.filePath)}": ${errorMessage}`);
220220+ s.stop(`Error publishing "${path.basename(post.filePath)}"`);
221221+ log.error(` ${errorMessage}`);
218222 errorCount++;
219223 }
220224 }
···223227 await saveState(configDir, state);
224228225229 // Summary
226226- consola.log("\n---");
227227- consola.info(`Published: ${publishedCount}`);
228228- consola.info(`Updated: ${updatedCount}`);
230230+ log.message("\n---");
231231+ log.info(`Published: ${publishedCount}`);
232232+ log.info(`Updated: ${updatedCount}`);
229233 if (errorCount > 0) {
230230- consola.warn(`Errors: ${errorCount}`);
234234+ log.warn(`Errors: ${errorCount}`);
231235 }
232236 },
233237});
+42-39
packages/cli/src/commands/sync.ts
···11import { command, flag } from "cmd-ts";
22-import { consola } from "consola";
22+import { select, spinner, log } from "@clack/prompts";
33import * as path from "path";
44import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
55import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
66import { createAgent, listDocuments } from "../lib/atproto";
77import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown";
88+import { exitOnCancel } from "../lib/prompts";
89910export const syncCommand = command({
1011 name: "sync",
···2526 // Load config
2627 const configPath = await findConfig();
2728 if (!configPath) {
2828- consola.error("No sequoia.json found. Run 'sequoia init' first.");
2929+ log.error("No sequoia.json found. Run 'sequoia init' first.");
2930 process.exit(1);
3031 }
31323233 const config = await loadConfig(configPath);
3334 const configDir = path.dirname(configPath);
34353535- consola.info(`Site: ${config.siteUrl}`);
3636- consola.info(`Publication: ${config.publicationUri}`);
3636+ log.info(`Site: ${config.siteUrl}`);
3737+ log.info(`Publication: ${config.publicationUri}`);
37383839 // Load credentials
3940 let credentials = await loadCredentials(config.identity);
···4142 if (!credentials) {
4243 const identities = await listCredentials();
4344 if (identities.length === 0) {
4444- consola.error("No credentials found. Run 'sequoia auth' first.");
4545+ log.error("No credentials found. Run 'sequoia auth' first.");
4546 process.exit(1);
4647 }
47484848- consola.info("Multiple identities found. Select one to use:");
4949- const selected = await consola.prompt("Identity:", {
5050- type: "select",
5151- options: identities,
5252- });
4949+ log.info("Multiple identities found. Select one to use:");
5050+ const selected = exitOnCancel(await select({
5151+ message: "Identity:",
5252+ options: identities.map(id => ({ value: id, label: id })),
5353+ }));
53545454- credentials = await getCredentials(selected as string);
5555+ credentials = await getCredentials(selected);
5556 if (!credentials) {
5656- consola.error("Failed to load selected credentials.");
5757+ log.error("Failed to load selected credentials.");
5758 process.exit(1);
5859 }
5960 }
60616162 // Create agent
6262- consola.start(`Connecting to ${credentials.pdsUrl}...`);
6363+ const s = spinner();
6464+ s.start(`Connecting to ${credentials.pdsUrl}...`);
6365 let agent;
6466 try {
6567 agent = await createAgent(credentials);
6666- consola.success(`Logged in as ${agent.session?.handle}`);
6868+ s.stop(`Logged in as ${agent.session?.handle}`);
6769 } catch (error) {
6868- consola.error("Failed to login:", error);
7070+ s.stop("Failed to login");
7171+ log.error(`Failed to login: ${error}`);
6972 process.exit(1);
7073 }
71747275 // Fetch documents from PDS
7373- consola.start("Fetching documents from PDS...");
7676+ s.start("Fetching documents from PDS...");
7477 const documents = await listDocuments(agent, config.publicationUri);
7575- consola.info(`Found ${documents.length} documents on PDS`);
7878+ s.stop(`Found ${documents.length} documents on PDS`);
76797780 if (documents.length === 0) {
7878- consola.info("No documents found for this publication.");
8181+ log.info("No documents found for this publication.");
7982 return;
8083 }
8184···8588 : path.join(configDir, config.contentDir);
86898790 // Scan local posts
8888- consola.start("Scanning local content...");
9191+ s.start("Scanning local content...");
8992 const localPosts = await scanContentDirectory(contentDir, config.frontmatter);
9090- consola.info(`Found ${localPosts.length} local posts`);
9393+ s.stop(`Found ${localPosts.length} local posts`);
91949295 // Build a map of path -> local post for matching
9396 // Document path is like /posts/my-post-slug
···106109 let unmatchedCount = 0;
107110 let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
108111109109- consola.log("\nMatching documents to local files:\n");
112112+ log.message("\nMatching documents to local files:\n");
110113111114 for (const doc of documents) {
112115 const docPath = doc.value.path;
···114117115118 if (localPost) {
116119 matchedCount++;
117117- consola.log(` ✓ ${doc.value.title}`);
118118- consola.log(` Path: ${docPath}`);
119119- consola.log(` URI: ${doc.uri}`);
120120- consola.log(` File: ${path.basename(localPost.filePath)}`);
120120+ log.message(` ✓ ${doc.value.title}`);
121121+ log.message(` Path: ${docPath}`);
122122+ log.message(` URI: ${doc.uri}`);
123123+ log.message(` File: ${path.basename(localPost.filePath)}`);
121124122125 // Update state (use relative path from config directory)
123126 const contentHash = await getContentHash(localPost.rawContent);
···134137 filePath: localPost.filePath,
135138 atUri: doc.uri,
136139 });
137137- consola.log(` → Will update frontmatter`);
140140+ log.message(` → Will update frontmatter`);
138141 }
139142 } else {
140143 unmatchedCount++;
141141- consola.log(` ✗ ${doc.value.title} (no matching local file)`);
142142- consola.log(` Path: ${docPath}`);
143143- consola.log(` URI: ${doc.uri}`);
144144+ log.message(` ✗ ${doc.value.title} (no matching local file)`);
145145+ log.message(` Path: ${docPath}`);
146146+ log.message(` URI: ${doc.uri}`);
144147 }
145145- consola.log("");
148148+ log.message("");
146149 }
147150148151 // Summary
149149- consola.log("---");
150150- consola.info(`Matched: ${matchedCount} documents`);
152152+ log.message("---");
153153+ log.info(`Matched: ${matchedCount} documents`);
151154 if (unmatchedCount > 0) {
152152- consola.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`);
155155+ log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`);
153156 }
154157155158 if (dryRun) {
156156- consola.info("\nDry run complete. No changes made.");
159159+ log.info("\nDry run complete. No changes made.");
157160 return;
158161 }
159162160163 // Save updated state
161164 await saveState(configDir, state);
162165 const newPostCount = Object.keys(state.posts).length;
163163- consola.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`);
166166+ log.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`);
164167165168 // Update frontmatter if requested
166169 if (frontmatterUpdates.length > 0) {
167167- consola.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
170170+ s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
168171 for (const { filePath, atUri } of frontmatterUpdates) {
169172 const file = Bun.file(filePath);
170173 const content = await file.text();
171174 const updated = updateFrontmatterWithAtUri(content, atUri);
172175 await Bun.write(filePath, updated);
173173- consola.log(` Updated: ${path.basename(filePath)}`);
176176+ log.message(` Updated: ${path.basename(filePath)}`);
174177 }
175175- consola.success("Frontmatter updated");
178178+ s.stop("Frontmatter updated");
176179 }
177180178178- consola.success("\nSync complete!");
181181+ log.success("\nSync complete!");
179182 },
180183});
+9
packages/cli/src/lib/prompts.ts
···11+import { isCancel, cancel } from "@clack/prompts";
22+33+export function exitOnCancel<T>(value: T | symbol): T {
44+ if (isCancel(value)) {
55+ cancel("Cancelled");
66+ process.exit(0);
77+ }
88+ return value as T;
99+}