packages/cli/src/commands/publish.ts 10.6 K raw
1
import * as fs from "fs/promises";
2
import { command, flag } from "cmd-ts";
3
import { select, spinner, log } from "@clack/prompts";
4
import * as path from "path";
5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6
import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
7
import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument } from "../lib/atproto";
8
import {
9
  scanContentDirectory,
10
  getContentHash,
11
  updateFrontmatterWithAtUri,
12
} from "../lib/markdown";
13
import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
14
import { exitOnCancel } from "../lib/prompts";
15
16
export const publishCommand = command({
17
  name: "publish",
18
  description: "Publish content to ATProto",
19
  args: {
20
    force: flag({
21
      long: "force",
22
      short: "f",
23
      description: "Force publish all posts, ignoring change detection",
24
    }),
25
    dryRun: flag({
26
      long: "dry-run",
27
      short: "n",
28
      description: "Preview what would be published without making changes",
29
    }),
30
  },
31
  handler: async ({ force, dryRun }) => {
32
    // Load config
33
    const configPath = await findConfig();
34
    if (!configPath) {
35
      log.error("No publisher.config.ts found. Run 'publisher init' first.");
36
      process.exit(1);
37
    }
38
39
    const config = await loadConfig(configPath);
40
    const configDir = path.dirname(configPath);
41
42
    log.info(`Site: ${config.siteUrl}`);
43
    log.info(`Content directory: ${config.contentDir}`);
44
45
    // Load credentials
46
    let credentials = await loadCredentials(config.identity);
47
48
    // If no credentials resolved, check if we need to prompt for identity selection
49
    if (!credentials) {
50
      const identities = await listCredentials();
51
      if (identities.length === 0) {
52
        log.error("No credentials found. Run 'sequoia auth' first.");
53
        log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.");
54
        process.exit(1);
55
      }
56
57
      // Multiple identities exist but none selected - prompt user
58
      log.info("Multiple identities found. Select one to use:");
59
      const selected = exitOnCancel(await select({
60
        message: "Identity:",
61
        options: identities.map(id => ({ value: id, label: id })),
62
      }));
63
64
      credentials = await getCredentials(selected);
65
      if (!credentials) {
66
        log.error("Failed to load selected credentials.");
67
        process.exit(1);
68
      }
69
70
      log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`);
71
    }
72
73
    // Resolve content directory
74
    const contentDir = path.isAbsolute(config.contentDir)
75
      ? config.contentDir
76
      : path.join(configDir, config.contentDir);
77
78
    const imagesDir = config.imagesDir
79
      ? path.isAbsolute(config.imagesDir)
80
        ? config.imagesDir
81
        : path.join(configDir, config.imagesDir)
82
      : undefined;
83
84
    // Load state
85
    const state = await loadState(configDir);
86
87
    // Scan for posts
88
    const s = spinner();
89
    s.start("Scanning for posts...");
90
    const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore);
91
    s.stop(`Found ${posts.length} posts`);
92
93
    // Determine which posts need publishing
94
    const postsToPublish: Array<{
95
      post: BlogPost;
96
      action: "create" | "update";
97
      reason: string;
98
    }> = [];
99
    const draftPosts: BlogPost[] = [];
100
101
    for (const post of posts) {
102
      // Skip draft posts
103
      if (post.frontmatter.draft) {
104
        draftPosts.push(post);
105
        continue;
106
      }
107
108
      const contentHash = await getContentHash(post.rawContent);
109
      const relativeFilePath = path.relative(configDir, post.filePath);
110
      const postState = state.posts[relativeFilePath];
111
112
      if (force) {
113
        postsToPublish.push({
114
          post,
115
          action: post.frontmatter.atUri ? "update" : "create",
116
          reason: "forced",
117
        });
118
      } else if (!postState) {
119
        // New post
120
        postsToPublish.push({
121
          post,
122
          action: "create",
123
          reason: "new post",
124
        });
125
      } else if (postState.contentHash !== contentHash) {
126
        // Changed post
127
        postsToPublish.push({
128
          post,
129
          action: post.frontmatter.atUri ? "update" : "create",
130
          reason: "content changed",
131
        });
132
      }
133
    }
134
135
    if (draftPosts.length > 0) {
136
      log.info(`Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`);
137
    }
138
139
    if (postsToPublish.length === 0) {
140
      log.success("All posts are up to date. Nothing to publish.");
141
      return;
142
    }
143
144
    log.info(`\n${postsToPublish.length} posts to publish:\n`);
145
146
    // Bluesky posting configuration
147
    const blueskyEnabled = config.bluesky?.enabled ?? false;
148
    const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
149
    const cutoffDate = new Date();
150
    cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
151
152
    for (const { post, action, reason } of postsToPublish) {
153
      const icon = action === "create" ? "+" : "~";
154
      const relativeFilePath = path.relative(configDir, post.filePath);
155
      const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
156
157
      let bskyNote = "";
158
      if (blueskyEnabled) {
159
        if (existingBskyPostRef) {
160
          bskyNote = " [bsky: exists]";
161
        } else {
162
          const publishDate = new Date(post.frontmatter.publishDate);
163
          if (publishDate < cutoffDate) {
164
            bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
165
          } else {
166
            bskyNote = " [bsky: will post]";
167
          }
168
        }
169
      }
170
171
      log.message(`  ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
172
    }
173
174
    if (dryRun) {
175
      if (blueskyEnabled) {
176
        log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
177
      }
178
      log.info("\nDry run complete. No changes made.");
179
      return;
180
    }
181
182
    // Create agent
183
    s.start(`Connecting to ${credentials.pdsUrl}...`);
184
    let agent;
185
    try {
186
      agent = await createAgent(credentials);
187
      s.stop(`Logged in as ${agent.session?.handle}`);
188
    } catch (error) {
189
      s.stop("Failed to login");
190
      log.error(`Failed to login: ${error}`);
191
      process.exit(1);
192
    }
193
194
    // Publish posts
195
    let publishedCount = 0;
196
    let updatedCount = 0;
197
    let errorCount = 0;
198
    let bskyPostCount = 0;
199
200
    for (const { post, action } of postsToPublish) {
201
      s.start(`Publishing: ${post.frontmatter.title}`);
202
203
      try {
204
        // Handle cover image upload
205
        let coverImage: BlobObject | undefined;
206
        if (post.frontmatter.ogImage) {
207
          const imagePath = await resolveImagePath(
208
            post.frontmatter.ogImage,
209
            imagesDir,
210
            contentDir
211
          );
212
213
          if (imagePath) {
214
            log.info(`  Uploading cover image: ${path.basename(imagePath)}`);
215
            coverImage = await uploadImage(agent, imagePath);
216
            if (coverImage) {
217
              log.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
218
            }
219
          } else {
220
            log.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
221
          }
222
        }
223
224
        // Track atUri, content for state saving, and bskyPostRef
225
        let atUri: string;
226
        let contentForHash: string;
227
        let bskyPostRef: StrongRef | undefined;
228
        const relativeFilePath = path.relative(configDir, post.filePath);
229
230
        // Check if bskyPostRef already exists in state
231
        const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
232
233
        if (action === "create") {
234
          atUri = await createDocument(agent, post, config, coverImage);
235
          s.stop(`Created: ${atUri}`);
236
237
          // Update frontmatter with atUri
238
          const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri);
239
          await fs.writeFile(post.filePath, updatedContent);
240
          log.info(`  Updated frontmatter in ${path.basename(post.filePath)}`);
241
242
          // Use updated content (with atUri) for hash so next run sees matching hash
243
          contentForHash = updatedContent;
244
          publishedCount++;
245
        } else {
246
          atUri = post.frontmatter.atUri!;
247
          await updateDocument(agent, post, atUri, config, coverImage);
248
          s.stop(`Updated: ${atUri}`);
249
250
          // For updates, rawContent already has atUri
251
          contentForHash = post.rawContent;
252
          updatedCount++;
253
        }
254
255
        // Create Bluesky post if enabled and conditions are met
256
        if (blueskyEnabled) {
257
          if (existingBskyPostRef) {
258
            log.info(`  Bluesky post already exists, skipping`);
259
            bskyPostRef = existingBskyPostRef;
260
          } else {
261
            const publishDate = new Date(post.frontmatter.publishDate);
262
263
            if (publishDate < cutoffDate) {
264
              log.info(`  Post is older than ${maxAgeDays} days, skipping Bluesky post`);
265
            } else {
266
              // Create Bluesky post
267
              try {
268
                const pathPrefix = config.pathPrefix || "/posts";
269
                const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
270
271
                bskyPostRef = await createBlueskyPost(agent, {
272
                  title: post.frontmatter.title,
273
                  description: post.frontmatter.description,
274
                  canonicalUrl,
275
                  coverImage,
276
                  publishedAt: post.frontmatter.publishDate,
277
                });
278
279
                // Update document record with bskyPostRef
280
                await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
281
                log.info(`  Created Bluesky post: ${bskyPostRef.uri}`);
282
                bskyPostCount++;
283
              } catch (bskyError) {
284
                const errorMsg = bskyError instanceof Error ? bskyError.message : String(bskyError);
285
                log.warn(`  Failed to create Bluesky post: ${errorMsg}`);
286
              }
287
            }
288
          }
289
        }
290
291
        // Update state (use relative path from config directory)
292
        const contentHash = await getContentHash(contentForHash);
293
        state.posts[relativeFilePath] = {
294
          contentHash,
295
          atUri,
296
          lastPublished: new Date().toISOString(),
297
          bskyPostRef,
298
        };
299
      } catch (error) {
300
        const errorMessage = error instanceof Error ? error.message : String(error);
301
        s.stop(`Error publishing "${path.basename(post.filePath)}"`);
302
        log.error(`  ${errorMessage}`);
303
        errorCount++;
304
      }
305
    }
306
307
    // Save state
308
    await saveState(configDir, state);
309
310
    // Summary
311
    log.message("\n---");
312
    log.info(`Published: ${publishedCount}`);
313
    log.info(`Updated: ${updatedCount}`);
314
    if (bskyPostCount > 0) {
315
      log.info(`Bluesky posts: ${bskyPostCount}`);
316
    }
317
    if (errorCount > 0) {
318
      log.warn(`Errors: ${errorCount}`);
319
    }
320
  },
321
});