packages/cli/src/commands/publish.ts 7.5 K raw
1
import { command, flag } from "cmd-ts";
2
import { select, spinner, log } from "@clack/prompts";
3
import * as path from "path";
4
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
5
import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
6
import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath } from "../lib/atproto";
7
import {
8
  scanContentDirectory,
9
  getContentHash,
10
  updateFrontmatterWithAtUri,
11
} from "../lib/markdown";
12
import type { BlogPost, BlobObject } from "../lib/types";
13
import { exitOnCancel } from "../lib/prompts";
14
15
export const publishCommand = command({
16
  name: "publish",
17
  description: "Publish content to ATProto",
18
  args: {
19
    force: flag({
20
      long: "force",
21
      short: "f",
22
      description: "Force publish all posts, ignoring change detection",
23
    }),
24
    dryRun: flag({
25
      long: "dry-run",
26
      short: "n",
27
      description: "Preview what would be published without making changes",
28
    }),
29
  },
30
  handler: async ({ force, dryRun }) => {
31
    // Load config
32
    const configPath = await findConfig();
33
    if (!configPath) {
34
      log.error("No publisher.config.ts found. Run 'publisher init' first.");
35
      process.exit(1);
36
    }
37
38
    const config = await loadConfig(configPath);
39
    const configDir = path.dirname(configPath);
40
41
    log.info(`Site: ${config.siteUrl}`);
42
    log.info(`Content directory: ${config.contentDir}`);
43
44
    // Load credentials
45
    let credentials = await loadCredentials(config.identity);
46
47
    // If no credentials resolved, check if we need to prompt for identity selection
48
    if (!credentials) {
49
      const identities = await listCredentials();
50
      if (identities.length === 0) {
51
        log.error("No credentials found. Run 'sequoia auth' first.");
52
        log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.");
53
        process.exit(1);
54
      }
55
56
      // Multiple identities exist but none selected - prompt user
57
      log.info("Multiple identities found. Select one to use:");
58
      const selected = exitOnCancel(await select({
59
        message: "Identity:",
60
        options: identities.map(id => ({ value: id, label: id })),
61
      }));
62
63
      credentials = await getCredentials(selected);
64
      if (!credentials) {
65
        log.error("Failed to load selected credentials.");
66
        process.exit(1);
67
      }
68
69
      log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`);
70
    }
71
72
    // Resolve content directory
73
    const contentDir = path.isAbsolute(config.contentDir)
74
      ? config.contentDir
75
      : path.join(configDir, config.contentDir);
76
77
    const imagesDir = config.imagesDir
78
      ? path.isAbsolute(config.imagesDir)
79
        ? config.imagesDir
80
        : path.join(configDir, config.imagesDir)
81
      : undefined;
82
83
    // Load state
84
    const state = await loadState(configDir);
85
86
    // Scan for posts
87
    const s = spinner();
88
    s.start("Scanning for posts...");
89
    const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore);
90
    s.stop(`Found ${posts.length} posts`);
91
92
    // Determine which posts need publishing
93
    const postsToPublish: Array<{
94
      post: BlogPost;
95
      action: "create" | "update";
96
      reason: string;
97
    }> = [];
98
99
    for (const post of posts) {
100
      const contentHash = await getContentHash(post.rawContent);
101
      const relativeFilePath = path.relative(configDir, post.filePath);
102
      const postState = state.posts[relativeFilePath];
103
104
      if (force) {
105
        postsToPublish.push({
106
          post,
107
          action: post.frontmatter.atUri ? "update" : "create",
108
          reason: "forced",
109
        });
110
      } else if (!postState) {
111
        // New post
112
        postsToPublish.push({
113
          post,
114
          action: "create",
115
          reason: "new post",
116
        });
117
      } else if (postState.contentHash !== contentHash) {
118
        // Changed post
119
        postsToPublish.push({
120
          post,
121
          action: post.frontmatter.atUri ? "update" : "create",
122
          reason: "content changed",
123
        });
124
      }
125
    }
126
127
    if (postsToPublish.length === 0) {
128
      log.success("All posts are up to date. Nothing to publish.");
129
      return;
130
    }
131
132
    log.info(`\n${postsToPublish.length} posts to publish:\n`);
133
    for (const { post, action, reason } of postsToPublish) {
134
      const icon = action === "create" ? "+" : "~";
135
      log.message(`  ${icon} ${post.frontmatter.title} (${reason})`);
136
    }
137
138
    if (dryRun) {
139
      log.info("\nDry run complete. No changes made.");
140
      return;
141
    }
142
143
    // Create agent
144
    s.start(`Connecting to ${credentials.pdsUrl}...`);
145
    let agent;
146
    try {
147
      agent = await createAgent(credentials);
148
      s.stop(`Logged in as ${agent.session?.handle}`);
149
    } catch (error) {
150
      s.stop("Failed to login");
151
      log.error(`Failed to login: ${error}`);
152
      process.exit(1);
153
    }
154
155
    // Publish posts
156
    let publishedCount = 0;
157
    let updatedCount = 0;
158
    let errorCount = 0;
159
160
    for (const { post, action } of postsToPublish) {
161
      s.start(`Publishing: ${post.frontmatter.title}`);
162
163
      try {
164
        // Handle cover image upload
165
        let coverImage: BlobObject | undefined;
166
        if (post.frontmatter.ogImage) {
167
          const imagePath = resolveImagePath(
168
            post.frontmatter.ogImage,
169
            imagesDir,
170
            contentDir
171
          );
172
173
          if (imagePath) {
174
            log.info(`  Uploading cover image: ${path.basename(imagePath)}`);
175
            coverImage = await uploadImage(agent, imagePath);
176
            if (coverImage) {
177
              log.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
178
            }
179
          } else {
180
            log.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
181
          }
182
        }
183
184
        // Track atUri and content for state saving
185
        let atUri: string;
186
        let contentForHash: string;
187
188
        if (action === "create") {
189
          atUri = await createDocument(agent, post, config, coverImage);
190
          s.stop(`Created: ${atUri}`);
191
192
          // Update frontmatter with atUri
193
          const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri);
194
          await Bun.write(post.filePath, updatedContent);
195
          log.info(`  Updated frontmatter in ${path.basename(post.filePath)}`);
196
197
          // Use updated content (with atUri) for hash so next run sees matching hash
198
          contentForHash = updatedContent;
199
          publishedCount++;
200
        } else {
201
          atUri = post.frontmatter.atUri!;
202
          await updateDocument(agent, post, atUri, config, coverImage);
203
          s.stop(`Updated: ${atUri}`);
204
205
          // For updates, rawContent already has atUri
206
          contentForHash = post.rawContent;
207
          updatedCount++;
208
        }
209
210
        // Update state (use relative path from config directory)
211
        const contentHash = await getContentHash(contentForHash);
212
        const relativeFilePath = path.relative(configDir, post.filePath);
213
        state.posts[relativeFilePath] = {
214
          contentHash,
215
          atUri,
216
          lastPublished: new Date().toISOString(),
217
        };
218
      } catch (error) {
219
        const errorMessage = error instanceof Error ? error.message : String(error);
220
        s.stop(`Error publishing "${path.basename(post.filePath)}"`);
221
        log.error(`  ${errorMessage}`);
222
        errorCount++;
223
      }
224
    }
225
226
    // Save state
227
    await saveState(configDir, state);
228
229
    // Summary
230
    log.message("\n---");
231
    log.info(`Published: ${publishedCount}`);
232
    log.info(`Updated: ${updatedCount}`);
233
    if (errorCount > 0) {
234
      log.warn(`Errors: ${errorCount}`);
235
    }
236
  },
237
});