ghostoff
v0.1.0
Published
Export Ghost CMS posts to Offprint / Standard.site over AT Protocol
Downloads
158
Maintainers
Readme
GhostOff
Export public posts from a Ghost CMS and upload them to Offprint via the AT Protocol / Standard.site lexicons.
The live test publication is at https://ghostoff.offprint.app.
Installation
Install globally from npm:
npm install -g ghostoffOr run directly with npx:
npx ghostoff -- --helpWhat it does
- Paginates through Ghost's public Content API for posts.
- Downloads each post's images, resizes them to fit atproto's blob limits, and uploads them as blobs to a PDS.
- Converts Ghost HTML bodies into Offprint's block-based format (
app.offprint.content). - Writes a
site.standard.documentrecord per post and anapp.offprint.document.articlerecord that points back to it. - Tracks progress in a local state file so reruns update existing records instead of duplicating them.
Quick start
Copy .env.example to .env and fill in the secrets:
cp .env.example .envGHOST_URL=https://your-ghost-site.com
GHOST_API_KEY=your-ghost-content-api-key
ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/...
# Auth: OAuth is the default. Set only if you want password auth.
ATP_IDENTIFIER=your-handle-or-did
# ATP_APP_PASSWORD=your-app-passwordRun a dry first pass to inspect what would be uploaded:
npm install
npm run dev -- --dry-runWhen you're ready, run the real migration:
npm run devIf you haven't set ATP_APP_PASSWORD, the CLI will ask:
No app password found in .env or --atproto-app-password. Would you like to authenticate via OAuth [Y/n]?Answer Y and it will open a browser so you can authenticate with your PDS. If you set ATP_APP_PASSWORD, it uses password auth instead.
Rerunning the same command will putRecord existing records instead of creating duplicates.
CLI options
| Flag | Environment variable | Description |
|------|------------------------|-------------|
| --ghost-url | GHOST_URL | Ghost publication URL |
| --ghost-api-key | GHOST_API_KEY | Ghost Content API key |
| --atproto-identifier | ATP_IDENTIFIER | atproto handle or DID |
| --atproto-app-password | ATP_APP_PASSWORD | atproto app password |
| --atproto-service | ATP_SERVICE | PDS/service URL (password auth only) |
| --publication-at-uri | ATPUBLICATION_AT_URI | Existing site.standard.publication AT-URI |
| --oauth-client-id | GHOSTOFF_OAUTH_CLIENT_ID | OAuth client_id URL (dev default: http://localhost) |
| --oauth-scope | GHOSTOFF_OAUTH_SCOPE | OAuth scope (default: atproto transition:generic) |
| --oauth-session-file | — | OAuth session cache (default: ghostoff-oauth-session.json) |
| --oauth-state-file | — | OAuth transient state cache (default: ghostoff-oauth-state.json) |
| --dry-run | — | Build records without uploading |
| --export-dir | — | Local asset cache directory (default: ghostoff-export) |
| --state-file | — | Idempotency state file (default: ghostoff-state.json) |
| --session-file | — | Password session cache (default: ghostoff-session.json) |
| --verbose | — | Verbose logging |
Scripts
npm run dev # run the migration
npm run build # compile TypeScript to dist/
npm run typecheck # type-check without emittingProject layout
src/
├── index.ts # CLI orchestration
├── config.ts # env + argv loading
├── ghost.ts # Ghost Content API client
├── assets.ts # image download, cache, resize, blob upload
├── html-to-offprint.ts # Ghost HTML -> Offprint blocks
├── facets.ts # inline formatting -> richtext facets
├── atproto.ts # auth adapters (OAuth + password), record ops, validation
├── oauth.ts # loopback OAuth server and file-backed stores
├── state.ts # idempotency state file
├── rate-limit.ts # retry + rate-limit helpers
└── types.ts # shared type definitionsLearning more
PLAN.md— the original migration plan.docs/session-summary.md— a narrative of how the first version was built.docs/development.md— how the codebase is organized.docs/next-steps.md— ideas for what to add or improve next.
