packages/cli/src/commands/publish.ts 7.5 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 } from "../lib/atproto";
8
import {
9
  scanContentDirectory,
10
  getContentHash,
11
  updateFrontmatterWithAtUri,
12
} from "../lib/markdown";
13
import type { BlogPost, BlobObject } 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
100
    for (const post of posts) {
101
      const contentHash = await getContentHash(post.rawContent);
102
      const relativeFilePath = path.relative(configDir, post.filePath);
103
      const postState = state.posts[relativeFilePath];
104
105
      if (force) {
106
        postsToPublish.push({
107
          post,
108
          action: post.frontmatter.atUri ? "update" : "create",
109
          reason: "forced",
110
        });
111
      } else if (!postState) {
112
        // New post
113
        postsToPublish.push({
114
          post,
115
          action: "create",
116
          reason: "new post",
117
        });
118
      } else if (postState.contentHash !== contentHash) {
119
        // Changed post
120
        postsToPublish.push({
121
          post,
122
          action: post.frontmatter.atUri ? "update" : "create",
123
          reason: "content changed",
124
        });
125
      }
126
    }
127
128
    if (postsToPublish.length === 0) {
129
      log.success("All posts are up to date. Nothing to publish.");
130
      return;
131
    }
132
133
    log.info(`\n${postsToPublish.length} posts to publish:\n`);
134
    for (const { post, action, reason } of postsToPublish) {
135
      const icon = action === "create" ? "+" : "~";
136
      log.message(`  ${icon} ${post.frontmatter.title} (${reason})`);
137
    }
138
139
    if (dryRun) {
140
      log.info("\nDry run complete. No changes made.");
141
      return;
142
    }
143
144
    // Create agent
145
    s.start(`Connecting to ${credentials.pdsUrl}...`);
146
    let agent;
147
    try {
148
      agent = await createAgent(credentials);
149
      s.stop(`Logged in as ${agent.session?.handle}`);
150
    } catch (error) {
151
      s.stop("Failed to login");
152
      log.error(`Failed to login: ${error}`);
153
      process.exit(1);
154
    }
155
156
    // Publish posts
157
    let publishedCount = 0;
158
    let updatedCount = 0;
159
    let errorCount = 0;
160
161
    for (const { post, action } of postsToPublish) {
162
      s.start(`Publishing: ${post.frontmatter.title}`);
163
164
      try {
165
        // Handle cover image upload
166
        let coverImage: BlobObject | undefined;
167
        if (post.frontmatter.ogImage) {
168
          const imagePath = await resolveImagePath(
169
            post.frontmatter.ogImage,
170
            imagesDir,
171
            contentDir
172
          );
173
174
          if (imagePath) {
175
            log.info(`  Uploading cover image: ${path.basename(imagePath)}`);
176
            coverImage = await uploadImage(agent, imagePath);
177
            if (coverImage) {
178
              log.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
179
            }
180
          } else {
181
            log.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
182
          }
183
        }
184
185
        // Track atUri and content for state saving
186
        let atUri: string;
187
        let contentForHash: string;
188
189
        if (action === "create") {
190
          atUri = await createDocument(agent, post, config, coverImage);
191
          s.stop(`Created: ${atUri}`);
192
193
          // Update frontmatter with atUri
194
          const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri);
195
          await fs.writeFile(post.filePath, updatedContent);
196
          log.info(`  Updated frontmatter in ${path.basename(post.filePath)}`);
197
198
          // Use updated content (with atUri) for hash so next run sees matching hash
199
          contentForHash = updatedContent;
200
          publishedCount++;
201
        } else {
202
          atUri = post.frontmatter.atUri!;
203
          await updateDocument(agent, post, atUri, config, coverImage);
204
          s.stop(`Updated: ${atUri}`);
205
206
          // For updates, rawContent already has atUri
207
          contentForHash = post.rawContent;
208
          updatedCount++;
209
        }
210
211
        // Update state (use relative path from config directory)
212
        const contentHash = await getContentHash(contentForHash);
213
        const relativeFilePath = path.relative(configDir, post.filePath);
214
        state.posts[relativeFilePath] = {
215
          contentHash,
216
          atUri,
217
          lastPublished: new Date().toISOString(),
218
        };
219
      } catch (error) {
220
        const errorMessage = error instanceof Error ? error.message : String(error);
221
        s.stop(`Error publishing "${path.basename(post.filePath)}"`);
222
        log.error(`  ${errorMessage}`);
223
        errorCount++;
224
      }
225
    }
226
227
    // Save state
228
    await saveState(configDir, state);
229
230
    // Summary
231
    log.message("\n---");
232
    log.info(`Published: ${publishedCount}`);
233
    log.info(`Updated: ${updatedCount}`);
234
    if (errorCount > 0) {
235
      log.warn(`Errors: ${errorCount}`);
236
    }
237
  },
238
});