feat: added bskyPostRef 2ec4b3cb
Steve · 2026-01-31 19:56 5 file(s) · +310 −8
packages/cli/src/commands/init.ts +37 −1
15 15
import { findConfig, generateConfigTemplate } from "../lib/config";
16 16
import { loadCredentials } from "../lib/credentials";
17 17
import { createAgent, createPublication } from "../lib/atproto";
18 -
import type { FrontmatterMapping } from "../lib/types";
18 +
import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
19 19
20 20
async function fileExists(filePath: string): Promise<boolean> {
21 21
	try {
263 263
			publicationUri = uri as string;
264 264
		}
265 265
266 +
		// Bluesky posting configuration
267 +
		const enableBluesky = await confirm({
268 +
			message: "Enable automatic Bluesky posting when publishing?",
269 +
			initialValue: false,
270 +
		});
271 +
272 +
		if (enableBluesky === Symbol.for("cancel")) {
273 +
			onCancel();
274 +
		}
275 +
276 +
		let blueskyConfig: BlueskyConfig | undefined;
277 +
		if (enableBluesky) {
278 +
			const maxAgeDaysInput = await text({
279 +
				message: "Maximum age (in days) for posts to be shared on Bluesky:",
280 +
				defaultValue: "7",
281 +
				placeholder: "7",
282 +
				validate: (value) => {
283 +
					const num = parseInt(value, 10);
284 +
					if (isNaN(num) || num < 1) {
285 +
						return "Please enter a positive number";
286 +
					}
287 +
				},
288 +
			});
289 +
290 +
			if (maxAgeDaysInput === Symbol.for("cancel")) {
291 +
				onCancel();
292 +
			}
293 +
294 +
			const maxAgeDays = parseInt(maxAgeDaysInput as string, 10);
295 +
			blueskyConfig = {
296 +
				enabled: true,
297 +
				...(maxAgeDays !== 7 && { maxAgeDays }),
298 +
			};
299 +
		}
300 +
266 301
		// Get PDS URL from credentials (already loaded earlier)
267 302
		const pdsUrl = credentials?.pdsUrl;
268 303
277 312
			publicationUri,
278 313
			pdsUrl,
279 314
			frontmatter: frontmatterMapping,
315 +
			bluesky: blueskyConfig,
280 316
		});
281 317
282 318
		const configPath = path.join(process.cwd(), "sequoia.json");
packages/cli/src/commands/publish.ts +77 −5
4 4
import * as path from "path";
5 5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6 6
import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
7 -
import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath } from "../lib/atproto";
7 +
import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument } from "../lib/atproto";
8 8
import {
9 9
  scanContentDirectory,
10 10
  getContentHash,
11 11
  updateFrontmatterWithAtUri,
12 12
} from "../lib/markdown";
13 -
import type { BlogPost, BlobObject } from "../lib/types";
13 +
import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
14 14
import { exitOnCancel } from "../lib/prompts";
15 15
16 16
export const publishCommand = command({
131 131
    }
132 132
133 133
    log.info(`\n${postsToPublish.length} posts to publish:\n`);
134 +
135 +
    // Bluesky posting configuration
136 +
    const blueskyEnabled = config.bluesky?.enabled ?? false;
137 +
    const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
138 +
    const cutoffDate = new Date();
139 +
    cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
140 +
134 141
    for (const { post, action, reason } of postsToPublish) {
135 142
      const icon = action === "create" ? "+" : "~";
136 -
      log.message(`  ${icon} ${post.frontmatter.title} (${reason})`);
143 +
      const relativeFilePath = path.relative(configDir, post.filePath);
144 +
      const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
145 +
146 +
      let bskyNote = "";
147 +
      if (blueskyEnabled) {
148 +
        if (existingBskyPostRef) {
149 +
          bskyNote = " [bsky: exists]";
150 +
        } else {
151 +
          const publishDate = new Date(post.frontmatter.publishDate);
152 +
          if (publishDate < cutoffDate) {
153 +
            bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
154 +
          } else {
155 +
            bskyNote = " [bsky: will post]";
156 +
          }
157 +
        }
158 +
      }
159 +
160 +
      log.message(`  ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
137 161
    }
138 162
139 163
    if (dryRun) {
164 +
      if (blueskyEnabled) {
165 +
        log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
166 +
      }
140 167
      log.info("\nDry run complete. No changes made.");
141 168
      return;
142 169
    }
157 184
    let publishedCount = 0;
158 185
    let updatedCount = 0;
159 186
    let errorCount = 0;
187 +
    let bskyPostCount = 0;
160 188
161 189
    for (const { post, action } of postsToPublish) {
162 190
      s.start(`Publishing: ${post.frontmatter.title}`);
182 210
          }
183 211
        }
184 212
185 -
        // Track atUri and content for state saving
213 +
        // Track atUri, content for state saving, and bskyPostRef
186 214
        let atUri: string;
187 215
        let contentForHash: string;
216 +
        let bskyPostRef: StrongRef | undefined;
217 +
        const relativeFilePath = path.relative(configDir, post.filePath);
218 +
219 +
        // Check if bskyPostRef already exists in state
220 +
        const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
188 221
189 222
        if (action === "create") {
190 223
          atUri = await createDocument(agent, post, config, coverImage);
208 241
          updatedCount++;
209 242
        }
210 243
244 +
        // Create Bluesky post if enabled and conditions are met
245 +
        if (blueskyEnabled) {
246 +
          if (existingBskyPostRef) {
247 +
            log.info(`  Bluesky post already exists, skipping`);
248 +
            bskyPostRef = existingBskyPostRef;
249 +
          } else {
250 +
            const publishDate = new Date(post.frontmatter.publishDate);
251 +
252 +
            if (publishDate < cutoffDate) {
253 +
              log.info(`  Post is older than ${maxAgeDays} days, skipping Bluesky post`);
254 +
            } else {
255 +
              // Create Bluesky post
256 +
              try {
257 +
                const pathPrefix = config.pathPrefix || "/posts";
258 +
                const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
259 +
260 +
                bskyPostRef = await createBlueskyPost(agent, {
261 +
                  title: post.frontmatter.title,
262 +
                  description: post.frontmatter.description,
263 +
                  canonicalUrl,
264 +
                  coverImage,
265 +
                  publishedAt: post.frontmatter.publishDate,
266 +
                });
267 +
268 +
                // Update document record with bskyPostRef
269 +
                await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
270 +
                log.info(`  Created Bluesky post: ${bskyPostRef.uri}`);
271 +
                bskyPostCount++;
272 +
              } catch (bskyError) {
273 +
                const errorMsg = bskyError instanceof Error ? bskyError.message : String(bskyError);
274 +
                log.warn(`  Failed to create Bluesky post: ${errorMsg}`);
275 +
              }
276 +
            }
277 +
          }
278 +
        }
279 +
211 280
        // Update state (use relative path from config directory)
212 281
        const contentHash = await getContentHash(contentForHash);
213 -
        const relativeFilePath = path.relative(configDir, post.filePath);
214 282
        state.posts[relativeFilePath] = {
215 283
          contentHash,
216 284
          atUri,
217 285
          lastPublished: new Date().toISOString(),
286 +
          bskyPostRef,
218 287
        };
219 288
      } catch (error) {
220 289
        const errorMessage = error instanceof Error ? error.message : String(error);
231 300
    log.message("\n---");
232 301
    log.info(`Published: ${publishedCount}`);
233 302
    log.info(`Updated: ${updatedCount}`);
303 +
    if (bskyPostCount > 0) {
304 +
      log.info(`Bluesky posts: ${bskyPostCount}`);
305 +
    }
234 306
    if (errorCount > 0) {
235 307
      log.warn(`Errors: ${errorCount}`);
236 308
    }
packages/cli/src/lib/atproto.ts +176 −1
2 2
import * as fs from "fs/promises";
3 3
import * as path from "path";
4 4
import * as mimeTypes from "mime-types";
5 -
import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types";
5 +
import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types";
6 6
import { stripMarkdownForText } from "./markdown";
7 7
8 8
async function fileExists(filePath: string): Promise<boolean> {
352 352
353 353
  return response.data.uri;
354 354
}
355 +
356 +
// --- Bluesky Post Creation ---
357 +
358 +
export interface CreateBlueskyPostOptions {
359 +
  title: string;
360 +
  description?: string;
361 +
  canonicalUrl: string;
362 +
  coverImage?: BlobObject;
363 +
  publishedAt: string; // Used as createdAt for the post
364 +
}
365 +
366 +
/**
367 +
 * Count graphemes in a string (for Bluesky's 300 grapheme limit)
368 +
 */
369 +
function countGraphemes(str: string): number {
370 +
  // Use Intl.Segmenter if available, otherwise fallback to spread operator
371 +
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
372 +
    const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
373 +
    return [...segmenter.segment(str)].length;
374 +
  }
375 +
  return [...str].length;
376 +
}
377 +
378 +
/**
379 +
 * Truncate a string to a maximum number of graphemes
380 +
 */
381 +
function truncateToGraphemes(str: string, maxGraphemes: number): string {
382 +
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
383 +
    const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
384 +
    const segments = [...segmenter.segment(str)];
385 +
    if (segments.length <= maxGraphemes) return str;
386 +
    return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "...";
387 +
  }
388 +
  // Fallback
389 +
  const chars = [...str];
390 +
  if (chars.length <= maxGraphemes) return str;
391 +
  return chars.slice(0, maxGraphemes - 3).join("") + "...";
392 +
}
393 +
394 +
/**
395 +
 * Create a Bluesky post with external link embed
396 +
 */
397 +
export async function createBlueskyPost(
398 +
  agent: AtpAgent,
399 +
  options: CreateBlueskyPostOptions
400 +
): Promise<StrongRef> {
401 +
  const { title, description, canonicalUrl, coverImage, publishedAt } = options;
402 +
403 +
  // Build post text: title + description + URL
404 +
  // Max 300 graphemes for Bluesky posts
405 +
  const MAX_GRAPHEMES = 300;
406 +
407 +
  let postText: string;
408 +
  const urlPart = `\n\n${canonicalUrl}`;
409 +
  const urlGraphemes = countGraphemes(urlPart);
410 +
411 +
  if (description) {
412 +
    // Try: title + description + URL
413 +
    const fullText = `${title}\n\n${description}${urlPart}`;
414 +
    if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
415 +
      postText = fullText;
416 +
    } else {
417 +
      // Truncate description to fit
418 +
      const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n");
419 +
      if (availableForDesc > 10) {
420 +
        const truncatedDesc = truncateToGraphemes(description, availableForDesc);
421 +
        postText = `${title}\n\n${truncatedDesc}${urlPart}`;
422 +
      } else {
423 +
        // Just title + URL
424 +
        postText = `${title}${urlPart}`;
425 +
      }
426 +
    }
427 +
  } else {
428 +
    // Just title + URL
429 +
    postText = `${title}${urlPart}`;
430 +
  }
431 +
432 +
  // Final truncation if still too long (shouldn't happen but safety check)
433 +
  if (countGraphemes(postText) > MAX_GRAPHEMES) {
434 +
    postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
435 +
  }
436 +
437 +
  // Calculate byte indices for the URL facet
438 +
  const encoder = new TextEncoder();
439 +
  const urlStartInText = postText.lastIndexOf(canonicalUrl);
440 +
  const beforeUrl = postText.substring(0, urlStartInText);
441 +
  const byteStart = encoder.encode(beforeUrl).length;
442 +
  const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
443 +
444 +
  // Build facets for the URL link
445 +
  const facets = [
446 +
    {
447 +
      index: {
448 +
        byteStart,
449 +
        byteEnd,
450 +
      },
451 +
      features: [
452 +
        {
453 +
          $type: "app.bsky.richtext.facet#link",
454 +
          uri: canonicalUrl,
455 +
        },
456 +
      ],
457 +
    },
458 +
  ];
459 +
460 +
  // Build external embed
461 +
  const embed: Record<string, unknown> = {
462 +
    $type: "app.bsky.embed.external",
463 +
    external: {
464 +
      uri: canonicalUrl,
465 +
      title: title.substring(0, 500), // Max 500 chars for title
466 +
      description: (description || "").substring(0, 1000), // Max 1000 chars for description
467 +
    },
468 +
  };
469 +
470 +
  // Add thumbnail if coverImage is available
471 +
  if (coverImage) {
472 +
    (embed.external as Record<string, unknown>).thumb = coverImage;
473 +
  }
474 +
475 +
  // Create the post record
476 +
  const record: Record<string, unknown> = {
477 +
    $type: "app.bsky.feed.post",
478 +
    text: postText,
479 +
    facets,
480 +
    embed,
481 +
    createdAt: new Date(publishedAt).toISOString(),
482 +
  };
483 +
484 +
  const response = await agent.com.atproto.repo.createRecord({
485 +
    repo: agent.session!.did,
486 +
    collection: "app.bsky.feed.post",
487 +
    record,
488 +
  });
489 +
490 +
  return {
491 +
    uri: response.data.uri,
492 +
    cid: response.data.cid,
493 +
  };
494 +
}
495 +
496 +
/**
497 +
 * Add bskyPostRef to an existing document record
498 +
 */
499 +
export async function addBskyPostRefToDocument(
500 +
  agent: AtpAgent,
501 +
  documentAtUri: string,
502 +
  bskyPostRef: StrongRef
503 +
): Promise<void> {
504 +
  const parsed = parseAtUri(documentAtUri);
505 +
  if (!parsed) {
506 +
    throw new Error(`Invalid document URI: ${documentAtUri}`);
507 +
  }
508 +
509 +
  // Fetch existing record
510 +
  const existingRecord = await agent.com.atproto.repo.getRecord({
511 +
    repo: parsed.did,
512 +
    collection: parsed.collection,
513 +
    rkey: parsed.rkey,
514 +
  });
515 +
516 +
  // Add bskyPostRef to the record
517 +
  const updatedRecord = {
518 +
    ...(existingRecord.data.value as Record<string, unknown>),
519 +
    bskyPostRef,
520 +
  };
521 +
522 +
  // Update the record
523 +
  await agent.com.atproto.repo.putRecord({
524 +
    repo: parsed.did,
525 +
    collection: parsed.collection,
526 +
    rkey: parsed.rkey,
527 +
    record: updatedRecord,
528 +
  });
529 +
}
packages/cli/src/lib/config.ts +6 −1
1 1
import * as fs from "fs/promises";
2 2
import * as path from "path";
3 -
import type { PublisherConfig, PublisherState, FrontmatterMapping } from "./types";
3 +
import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types";
4 4
5 5
const CONFIG_FILENAME = "sequoia.json";
6 6
const STATE_FILENAME = ".sequoia-state.json";
76 76
	pdsUrl?: string;
77 77
	frontmatter?: FrontmatterMapping;
78 78
	ignore?: string[];
79 +
	bluesky?: BlueskyConfig;
79 80
}): string {
80 81
	const config: Record<string, unknown> = {
81 82
		siteUrl: options.siteUrl,
110 111
111 112
	if (options.ignore && options.ignore.length > 0) {
112 113
		config.ignore = options.ignore;
114 +
	}
115 +
116 +
	if (options.bluesky) {
117 +
		config.bluesky = options.bluesky;
113 118
	}
114 119
115 120
	return JSON.stringify(config, null, 2);
packages/cli/src/lib/types.ts +14 −0
6 6
	tags?: string; // Field name for tags (default: "tags")
7 7
}
8 8
9 +
// Strong reference for Bluesky post (com.atproto.repo.strongRef)
10 +
export interface StrongRef {
11 +
	uri: string; // at:// URI format
12 +
	cid: string; // Content ID
13 +
}
14 +
15 +
// Bluesky posting configuration
16 +
export interface BlueskyConfig {
17 +
	enabled: boolean;
18 +
	maxAgeDays?: number; // Only post if published within N days (default: 7)
19 +
}
20 +
9 21
export interface PublisherConfig {
10 22
	siteUrl: string;
11 23
	contentDir: string;
18 30
	identity?: string; // Which stored identity to use (matches identifier)
19 31
	frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
20 32
	ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
33 +
	bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
21 34
}
22 35
23 36
export interface Credentials {
62 75
	contentHash: string;
63 76
	atUri?: string;
64 77
	lastPublished?: string;
78 +
	bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
65 79
}
66 80
67 81
export interface PublicationRecord {