Skip to content

Latest commit

 

History

History
84 lines (60 loc) · 8.84 KB

File metadata and controls

84 lines (60 loc) · 8.84 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

paperplane is a CLI (plane) for testing and sending HTML emails. ESM TypeScript, Node 22+, compiled to dist/ and exposed via the plane bin.

Commands

  • npm run build compile TypeScript to dist/
  • npm run typecheck / npm run lint both run tsc --noEmit (no separate linter)
  • npm test run the full Vitest suite once
  • npx vitest run src/__tests__/send.test.ts run a single test file
  • npx vitest run -t "extracts subject" run tests matching a name pattern
  • After build: node dist/index.js <command> invokes the CLI locally without installing

You are always allowed to run npm run build after making source changes without asking for confirmation, so the user can immediately try the change via node dist/index.js or the installed plane bin.

Architecture

Entry point

src/index.ts builds a commander program inside createProgram() and only calls program.parse() when not under Vitest (process.env.VITEST). This is what makes the CLI testable: tests import createProgram and feed argv arrays without spawning a subprocess.

Each subcommand (init, config, send, test, sync, preview) lives in src/commands/<name>.ts and exports a top-level async function plus its options interface. index.ts is the only file that wires options to the implementations.

Configuration layering (src/config.ts)

Two JSON files, both named .paperplanerc.json:

  • Global: ~/.paperplanerc.json
  • Project: ./.paperplanerc.json (cwd)

loadEffectiveConfig() merges them per top-level key (project overrides global) and returns { config, sources } where sources records which file each key came from. Always go through loadEffectiveConfig() rather than reading either file directly. init without --global requires an existing global config first.

Config holds to, from, optional subject, transport (smtp | postmark), legacy smtp/postmark credentials, an optional providers map (named providers, see resolveProvider) plus defaultProvider, an optional ftp block (protocol ftp/sftp, localDirs, baseUrl), an optional s3 block (bucket/region/credentials/prefix/baseUrl, supports custom endpoint for non-AWS S3-compatible services), inline, and a preflight block (test, sendOnFail). Files contain plaintext credentials; credentials are intended to live in the global config only, with project configs holding overrides.

Send pipeline (src/commands/send.ts)

The send command is the core flow. Other commands reuse pieces of it:

  1. Resolve the HTML file: explicit arg, else priority names (mail.html, index.html), else single match, else interactive prompt via inquirer (findHtmlFiles + selectHtmlFile).
  2. (Optional, preflight.test or --test) run testCommand() and abort unless preflight.sendOnFail (or --send-on-fail) is set.
  3. (Optional, --sync) run syncCommand() to upload assets first; abort on any failure.
  4. Rewrite local asset URLs to s3.baseUrl (or ftp.baseUrl as fallback) via rewriteAssetUrls when a base URL is configured and --no-rewrite is not set. The LOCAL_PATH_PATTERN regex is what distinguishes local paths from http(s)://, data:, #, mailto:, tel:.
  5. Inline CSS with juice (inlineCss) unless --no-inline or config.inline === false.
  6. Resolve the provider via resolveProvider(config, options.provider): named provider (from providers), then defaultProvider, then legacy transport/smtp/postmark. Send via either nodemailer (SMTP, secure when port is 465) or a direct fetch to the Postmark REST API. There is no abstraction layer between the two.

extractSubject pulls <title>, then falls back to config.subject, then the filename. --persistent writes the --to override back to the project config.

Preview (src/commands/preview.ts)

A node:http server that serves the processed HTML (reusing rewriteAssetUrls/inlineCss from send.ts via processHtml), proxies sibling files as static assets with a hardcoded MIME map, and pushes event: reload over an SSE endpoint at /__sse. An injected client script (SSE_CLIENT_SCRIPT) reconnects on error. fs.watch on the file's directory triggers the reload broadcast. --raw disables URL rewriting and inlining.

Sync (src/commands/sync.ts)

Walks ftp.localDirs or s3.localDirs (default ["./images", "./assets"]), compares size + mtime against .paperplane-sync.json manifest, uploads only changed files (or everything with --force). FTP uses basic-ftp, SFTP uses ssh2-sftp-client (key file or password), S3 uses @aws-sdk/client-s3 (custom endpoint is honored for non-AWS S3-compatible services; credentials default to the AWS credential chain when not provided). When both s3 and ftp are configured, s3 wins. Manifest is updated only on successful, non-dry-run uploads. Returns UploadResult[] so send --sync can detect failures.

Test command (src/commands/test.ts)

Static HTML linting: extractLinks / extractImages use simple regex (no HTML parser), then validateLinks and validateImages HEAD-then-GET remote URLs with a 5s timeout and check local paths against the filesystem. checkAccessibility flags missing lang, small font sizes, and color-without-background-color pairs. Errors set process.exitCode = 1 in index.ts.

Testing patterns

Tests live in src/__tests__/*.test.ts and run under Vitest with globals: true. Common patterns to follow:

  • CLI-level tests build the program with createProgram() and call program.parseAsync(["node", "plane", ...args])index.ts skips auto-parse under Vitest so this is safe.
  • Command unit tests call the exported async function directly (e.g. sendCommand(file, options)).
  • Filesystem-touching tests use os.tmpdir() + a unique subdir, process.chdir() into it, and restore cwd in afterEach. Many tests also stub os.homedir() to redirect the global config path.
  • Network is stubbed by replacing globalThis.fetch with a vi.fn(); SMTP is stubbed by mocking nodemailer.createTransport.

Conventions

  • ESM only: imports must use the .js extension even when the source is .ts (Node16 module resolution). Example: import { sendCommand } from "./commands/send.js";.
  • User-facing output uses chalk (red for errors, yellow for warnings, green for success, cyan/gray for progress). Keep this consistent in new commands.
  • Errors in command handlers are reported via console.log(chalk.red(...)) and either return early or set process.exitCode = 1 from index.ts. Avoid throwing out of top-level commands.
  • New CLI subcommands should be registered inside createProgram() with both a .description() and an .addHelpText("after", ...) block of examples, matching existing commands.

UX (CLI interaction)

This CLI is interactive-first. Always favor a wizard feel over flag-only flows. UX must be top-tier.

  • Default to interactive prompts via @clack/prompts (text, password, select, multiselect, confirm, intro/outro, note) for any command that needs input. Flags are escape hatches for scripting, not the primary path. Do not use inquirer.
  • Always handle cancellation: check isCancel(result) after every prompt and call cancel("…") then return — don't continue with a Symbol value.
  • Use select/multiselect for choices instead of asking the user to type a value when the set is known (providers, transports, protocols, file matches, existing config keys, etc.).
  • Pre-fill prompts with sensible defaults: existing config values via initialValue/placeholder, masked secrets shown as •••• placeholders, last-used values when available. Never re-ask for something already known unless explicitly editing.
  • Wrap multi-step flows in intro() / outro() and group steps with note() headings. Use chalk (cyan for step titles, gray for hints) for any output between prompts. Show progress like Step 2/4 · Provider.
  • Confirm before destructive or irreversible actions (overwriting config, sending email, force sync) with a confirm prompt and a one-line summary of what will happen.
  • After a wizard completes, print a concise summary of what was saved/sent and the next suggested command via outro().
  • Validate inline (clack validate callback returning a string error) instead of failing after submission. Friendly messages, never stack traces.
  • When a command can run non-interactively (all required flags provided) skip prompts; when partial, prompt only for what's missing. Detect !process.stdin.isTTY and fall back to flag-only mode with a clear error listing missing inputs.
  • Always end command output with a single trailing blank line (an extra console.log() after the last line) so the next shell prompt does not sit flush against the output. This applies to all commands, including non-wizard commands like provider list.