Validate cover image is < 1MB 0c92b25a
Fixes #25
Heath Stewart · 2026-05-03 21:09 5 file(s) · +53 −19
packages/cli/CHANGELOG.md +6 −0
1 +
## [0.5.7]
2 +
3 +
### 🚀 Features
4 +
5 +
- Validate cover image is < 1MB
6 +
1 7
## [0.5.6] - 2026-04-25
2 8
3 9
### 🐛 Bug Fixes
packages/cli/src/commands/publish.ts +41 −17
2 2
import { command, flag } from "cmd-ts";
3 3
import { select, spinner, log } from "@clack/prompts";
4 4
import * as path from "node:path";
5 -
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
5 +
import { CONFIG_FILENAME, loadConfig, loadState, saveState, findConfig } from "../lib/config";
6 6
import {
7 7
	loadCredentials,
8 8
	listAllCredentials,
17 17
	resolveImagePath,
18 18
	createBlueskyPost,
19 19
	addBskyPostRefToDocument,
20 +
    COVER_IMAGE_MAX_SIZE,
20 21
} from "../lib/atproto";
21 22
import {
22 23
	scanContentDirectory,
52 53
		// Load config
53 54
		const configPath = await findConfig();
54 55
		if (!configPath) {
55 -
			log.error("No publisher.config.ts found. Run 'publisher init' first.");
56 +
			log.error(`No ${CONFIG_FILENAME} found. Run 'sequoia init' first.`);
56 57
			process.exit(1);
57 58
		}
58 59
261 262
		const cutoffDate = new Date();
262 263
		cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
263 264
265 +
		let isValid = true;
264 266
		for (const { post, action, reason } of postsToPublish) {
265 267
			const icon = action === "create" ? "+" : "~";
266 268
			const relativeFilePath = path.relative(configDir, post.filePath);
267 269
			const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
270 +
271 +
			if (post.frontmatter.ogImage) {
272 +
				post.coverImagePath = await resolveImagePath(
273 +
					post.frontmatter.ogImage,
274 +
					imagesDir,
275 +
					contentDir,
276 +
				);
277 +
			}
268 278
269 279
			let bskyNote = "";
270 280
			if (blueskyEnabled) {
292 302
			log.message(
293 303
				`  ${icon} ${post.frontmatter.title} (${reason})${bskyNote}${postUrl}`,
294 304
			);
305 +
306 +
			const postValid = await validatePost(post);
307 +
			isValid &&= postValid;
308 +
		}
309 +
310 +
		if (!isValid) {
311 +
			return;
295 312
		}
296 313
297 314
		if (dryRun) {
329 346
			try {
330 347
				// Handle cover image upload
331 348
				let coverImage: BlobObject | undefined;
332 -
				if (post.frontmatter.ogImage) {
333 -
					const imagePath = await resolveImagePath(
334 -
						post.frontmatter.ogImage,
335 -
						imagesDir,
336 -
						contentDir,
337 -
					);
338 -
339 -
					if (imagePath) {
340 -
						log.info(`  Uploading cover image: ${path.basename(imagePath)}`);
341 -
						coverImage = await uploadImage(agent, imagePath);
342 -
						if (coverImage) {
343 -
							log.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
344 -
						}
345 -
					} else {
346 -
						log.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
349 +
				if (post.coverImagePath) {
350 +
					log.info(`  Uploading cover image: ${path.basename(post.coverImagePath)}`);
351 +
					coverImage = await uploadImage(agent, post.coverImagePath);
352 +
					if (coverImage) {
353 +
						log.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
347 354
					}
355 +
				} else {
356 +
					log.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
348 357
				}
349 358
350 359
				// Track atUri, content for state saving, and bskyPostRef
372 381
					contentForHash = updatedContent;
373 382
					publishedCount++;
374 383
				} else {
384 +
385 +
					// Validate post.
375 386
					atUri = post.frontmatter.atUri!;
376 387
					await updateDocument(agent, post, atUri, config, coverImage);
377 388
					s.stop(`Updated: ${atUri}`);
455 466
		}
456 467
	},
457 468
});
469 +
470 +
async function validatePost(post: BlogPost): Promise<boolean> {
471 +
	if (post.coverImagePath) {
472 +
		const stat = await fs.stat(post.coverImagePath);
473 +
		if (stat.size >= COVER_IMAGE_MAX_SIZE) {
474 +
			log.error(`  Cover image "${post.coverImagePath}" must be less than 1MB`);
475 +
			return false;
476 +
		}
477 +
	}
478 +
479 +
	return true;
480 +
}
481 +
packages/cli/src/lib/atproto.ts +4 −1
14 14
} from "./types";
15 15
import { isAppPasswordCredentials, isOAuthCredentials } from "./types";
16 16
17 +
// https://standard.site/docs/lexicons/document/#optional-properties
18 +
export const COVER_IMAGE_MAX_SIZE = 1024 * 1024 - 1;
19 +
17 20
/**
18 21
 * Type guard to check if a record value is a DocumentRecord
19 22
 */
189 192
	ogImage: string,
190 193
	imagesDir: string | undefined,
191 194
	contentDir: string,
192 -
): Promise<string | null> {
195 +
): Promise<string | undefined> {
193 196
	// Try multiple resolution strategies
194 197
195 198
	// 1. If imagesDir is specified, look there
packages/cli/src/lib/config.ts +1 −1
7 7
	BlueskyConfig,
8 8
} from "./types";
9 9
10 -
const CONFIG_FILENAME = "sequoia.json";
10 +
export const CONFIG_FILENAME = "sequoia.json";
11 11
const STATE_FILENAME = ".sequoia-state.json";
12 12
13 13
async function fileExists(filePath: string): Promise<boolean> {
packages/cli/src/lib/types.ts +1 −0
106 106
	content: string;
107 107
	rawContent: string;
108 108
	rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
109 +
	coverImagePath?: string;
109 110
}
110 111
111 112
export interface BlobRef {