Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/buttondown/cli/llms.txt

Use this file to discover all available pages before exploring further.

The Buttondown CLI provides bidirectional sync between your local files and Buttondown’s servers through pull and push commands.

Overview

The sync workflow enables you to:
  • Pull: Download content from Buttondown to local files
  • Push: Upload local changes back to Buttondown
  • Track: Maintain sync state to avoid unnecessary operations

Pull Workflow

Download emails, images, and settings from Buttondown to your local machine.

Push Workflow

Upload local changes, new content, and images back to Buttondown.

Pull Workflow

The pull workflow downloads content from Buttondown and converts it to local files.

Pull Process

From src/commands/pull.tsx:83-162, the pull operation follows these steps:
1

Pull base resources

Downloads automations, newsletter settings, and snippets as JSON files.
for (const resource of BASE_RESOURCES) {
  const data = await resource.remote.get(configuration);
  if (data) {
    await resource.local.set(data, configuration);
  }
}
2

Download images

Fetches all images from Buttondown’s API and saves them to media/.
const remoteImages = await IMAGES_RESOURCE.remote.get(configuration);
await IMAGES_RESOURCE.local.set(remoteImages, configuration);
Each image is:
  • Downloaded from its S3 URL
  • Saved with its original filename
  • Tracked in the sync state
3

Build image mapping

Creates a mapping between remote URLs and local paths.
const syncedImages: Record<string, SyncedImage> = {};
for (const image of remoteImages) {
  const filename = path.basename(image.image);
  const localPath = path.join(configuration.directory, "media", filename);
  syncedImages[image.id] = {
    id: image.id,
    localPath,
    url: image.image,
    filename,
  };
}
4

Download emails

Fetches all emails and converts absolute image URLs to relative paths.
const remoteEmails = await REMOTE_EMAILS_RESOURCE.get(configuration);
const processedEmails = remoteEmails.map((email) => ({
  ...email,
  body: convertAbsoluteToRelativeImages(email.body, emailsDir, imageMap),
}));
await LOCAL_EMAILS_RESOURCE.set(processedEmails, configuration);
This step:
  • Downloads all emails via paginated API requests
  • Converts https://buttondown.s3.amazonaws.com/... to ../media/filename.png
  • Saves as Markdown files with frontmatter
5

Write sync state

Saves the image mapping to .buttondown.json for future sync operations.
await writeSyncState(configuration.directory, { syncedImages });

Pull Command

# Pull to default directory (./buttondown)
buttondown pull

# Pull to custom directory
buttondown pull --directory=./my-newsletter

Push Workflow

The push workflow uploads local changes back to Buttondown.

Push Process

From src/commands/push.tsx:84-184, the push operation follows these steps:
1

Read sync state

Loads the current sync state from .buttondown.json.
const syncState = await readSyncState(configuration.directory);
const syncedImages = { ...syncState.syncedImages };
2

Read local emails

Scans the emails/ directory for all Markdown files.
const localEmails = await LOCAL_EMAILS_RESOURCE.get(configuration);
3

Upload new images

Finds relative image references in emails and uploads any that aren’t already synced.
for (const email of localEmails) {
  const refs = findRelativeImageReferences(email.body);
  for (const ref of refs) {
    const absolutePath = path.resolve(emailsDir, ref.relativePath);
    const alreadySynced = Object.values(syncedImages).find(
      (img) => img.localPath === absolutePath,
    );
    if (!alreadySynced) {
      const result = await uploadImage(configuration, absolutePath);
      syncedImages[result.id] = {
        id: result.id,
        localPath: absolutePath,
        url: result.url,
        filename: result.filename,
      };
    }
  }
}
Images are uploaded via multipart form data to Buttondown’s /images endpoint.
4

Convert image paths

Replaces relative paths with absolute URLs using the updated sync state.
const processedEmails = localEmails.map((email) => ({
  ...email,
  body: resolveRelativeImageReferences(email.body, emailsDir, imageMap),
}));
Converts ../media/header.png to https://buttondown.s3.amazonaws.com/images/abc123.png
5

Detect changes

Compares local emails with remote versions to find what’s changed.
const remoteEmailsById = new Map(
  (await REMOTE_EMAILS_RESOURCE.get(configuration)).map((e) => [e.id, e]),
);

const changedEmails = localEmails.filter((email) => {
  if (!email.id) return true; // New email
  const remote = remoteEmailsById.get(email.id);
  if (!remote) return true; // Doesn't exist remotely
  return serialize(email) !== serialize(remote); // Content differs
});
Only emails that have changed are uploaded, saving time and API requests.
6

Upload changed emails

Pushes modified emails to Buttondown.
await REMOTE_EMAILS_RESOURCE.set(changedEmails, configuration);
  • Emails with an id are updated via PATCH
  • Emails without an id are created via POST
7

Push other resources

Uploads automations, newsletter settings, and snippets.
for (const resource of BASE_RESOURCES) {
  const data = await resource.local.get(configuration);
  if (data) {
    await resource.remote.set(data, configuration);
  }
}
8

Update sync state

Saves the updated image mapping to .buttondown.json.
await writeSyncState(configuration.directory, { syncedImages });

Push Command

# Push from default directory (./buttondown)
buttondown push

# Push from custom directory
buttondown push --directory=./my-newsletter

State Tracking

The CLI uses .buttondown.json to track sync state and avoid unnecessary operations.

State File Structure

From src/sync/state.ts:4-13:
export type SyncedImage = {
  id: string;           // Buttondown image ID
  localPath: string;    // Absolute path on local filesystem
  url: string;          // Remote URL on Buttondown's S3
  filename: string;     // Original filename
};

export type SyncState = {
  syncedImages: Record<string, SyncedImage>;
};
Example .buttondown.json:
{
  "syncedImages": {
    "img_abc123": {
      "id": "img_abc123",
      "localPath": "/Users/you/newsletter/media/header.png",
      "url": "https://buttondown.s3.amazonaws.com/images/abc123.png",
      "filename": "header.png"
    },
    "img_def456": {
      "id": "img_def456",
      "localPath": "/Users/you/newsletter/media/chart.jpg",
      "url": "https://buttondown.s3.amazonaws.com/images/def456.jpg",
      "filename": "chart.jpg"
    }
  }
}

State Operations

export async function readSyncState(directory: string): Promise<SyncState> {
  try {
    const filePath = path.join(directory, ".buttondown.json");
    const content = await readFile(filePath, "utf8");
    return { ...DEFAULT_STATE, ...JSON.parse(content) };
  } catch {
    return { ...DEFAULT_STATE };
  }
}
Returns default state if file doesn’t exist (first sync).
export async function writeSyncState(
  directory: string,
  state: SyncState,
): Promise<void> {
  const filePath = path.join(directory, ".buttondown.json");
  await writeFile(filePath, JSON.stringify(state, null, 2));
}
Pretty-prints JSON with 2-space indentation.

Image Path Resolution

The CLI intelligently converts between relative and absolute image paths.

Absolute to Relative (Pull)

From src/sync/emails.ts:233-251, convertAbsoluteToRelativeImages:
export function convertAbsoluteToRelativeImages(
  content: string,
  emailDir: string,
  syncedImages: Record<string, SyncedImageInfo>,
): string {
  const regex = new RegExp(ABSOLUTE_IMAGE_URL_REGEX);
  return content.replace(regex, (match, altText, imageUrl) => {
    const syncedImage = Object.values(syncedImages).find(
      (img) => img.url === imageUrl,
    );
    if (syncedImage) {
      const relativePath = path.relative(emailDir, syncedImage.localPath);
      return `![${altText}](${relativePath})`;
    }
    return match; // Keep original if not found
  });
}

Relative to Absolute (Push)

From src/sync/emails.ts:203-231, resolveRelativeImageReferences:
export function resolveRelativeImageReferences(
  content: string,
  emailDir: string,
  syncedImages: Record<string, SyncedImageInfo>,
): string {
  const references = findRelativeImageReferences(content);
  let processedContent = content;
  
  for (const ref of references) {
    const absolutePath = path.resolve(emailDir, ref.relativePath);
    const matchingImage = Object.values(syncedImages).find(
      (img) => img.localPath === absolutePath,
    );
    if (matchingImage) {
      processedContent = replaceImageReference(
        processedContent,
        ref.match,
        matchingImage.url,
        ref.altText,
      );
    }
  }
  
  return processedContent;
}

Resource Types

The CLI syncs different types of resources with different strategies.

Resource Interface

From src/sync/types.ts:19-24:
export type Resource<Model, SerializedModel> = {
  get(configuration: Configuration): Promise<Model | null>;
  set(value: Model, configuration: Configuration): Promise<OperationResult>;
  serialize: (r: Model) => SerializedModel;
  deserialize: (s: SerializedModel) => Model;
};

Available Resources

From src/sync/index.ts:40-53:
export const BASE_RESOURCES = [
  AUTOMATIONS_RESOURCE,
  NEWSLETTER_RESOURCE,
  SNIPPETS_RESOURCE,
];

export const RESOURCES = [
  AUTOMATIONS_RESOURCE,
  EMAILS_RESOURCE,
  IMAGES_RESOURCE,
  NEWSLETTER_RESOURCE,
  SNIPPETS_RESOURCE,
];
Base resources are synced without special handling. Emails and images require special path conversion logic.

Operation Results

Both pull and push operations return detailed statistics:
export type OperationResult = {
  updated: number;   // Items modified
  created: number;   // New items added
  deleted: number;   // Items removed
  failed: number;    // Operations that failed
};
Example output:
emails pushed: 3 updated, 1 created, 0 deleted, 0 failed
images pushed: 0 updated, 2 created, 0 deleted, 0 failed
automations pushed: 1 updated, 0 created, 0 deleted, 0 failed

Best Practices

1

Pull before push

Always pull latest changes before pushing to avoid conflicts.
buttondown pull
# Make your changes
buttondown push
2

Commit sync state

Consider committing .buttondown.json to version control if working in a team, but be aware it contains absolute paths.
3

Use consistent directories

Use the same --directory flag for all operations.
# Good
buttondown pull -d ./newsletter
buttondown push -d ./newsletter

# Bad - will cause issues
buttondown pull -d ./newsletter
buttondown push -d ./different-folder
4

Review changes before pushing

Check what’s changed using git diff or your editor before pushing.

Troubleshooting

Images not syncing correctly

If images aren’t converting properly:
  1. Check that images are in the media/ directory
  2. Verify .buttondown.json contains the image mapping
  3. Try pulling fresh to rebuild the state:
    rm .buttondown.json
    buttondown pull
    

“Email not found” errors

If push fails with email not found errors:
  1. The email may have been deleted on Buttondown
  2. Remove the id field from the frontmatter to create a new email
  3. Pull fresh to sync the latest state

State file corruption

If .buttondown.json gets corrupted:
# Delete the state file
rm .buttondown.json

# Pull fresh to rebuild
buttondown pull
Deleting .buttondown.json will cause the CLI to re-download all images and lose track of previous uploads. Use with caution.