@headroom-cms/cli
v0.3.1
Published
Command-line interface for managing Headroom CMS sites
Readme
@headroom-cms/cli
Command-line interface for managing Headroom CMS sites.
Install
The CLI is published to npm and can be invoked with npx without installing:
npx @headroom-cms/cli --helpOr install globally:
npm install -g @headroom-cms/cli
headroom --helpAuthenticate
headroom login accepts either a deployed admin UI URL or a Lambda function /
API URL. With an admin URL, the CLI auto-discovers the API URL by fetching
/.well-known/headroom.json published by the admin site.
# Recommended: paste the admin URL
headroom login https://headroom-admin.example.com
# Or the API URL directly (legacy form)
headroom login https://xxx.lambda-url.us-east-1.on.aws
# Set the active site at the same time
headroom login https://headroom-admin.example.com mysite.comA bare hostname is auto-prefixed with https:// (or http:// for
localhost / 127.0.0.1 / *.local).
Bootstrap your project
headroom bootstrap adds Headroom to an existing Astro or Next.js project.
Run it from the project root:
cd ./my-astro-site
npx @headroom-cms/cli bootstrapWhat it does:
- Detects the framework (
next/astro/generic) and package manager (npm/pnpm/yarn/bun). - Resolves the API + media URLs from your active login.
- Prompts you to pick (or create) the site to bootstrap against.
- Creates an API key on the site (label format
bootstrap-{framework}-{YYYYMMDD}-{shortid}). - Writes
HEADROOM_API_URL,HEADROOM_MEDIA_URL,HEADROOM_SITE,HEADROOM_API_KEYto.env(.env.localfor Next.js). - Adds the env file to
.gitignoreif missing. - Installs
@headroom-cms/apiwith your detected package manager. - Drops
HEADROOM_AGENTS.mdat the project root — an instruction sheet for AI agents working in the repo.
Idempotency
Re-running bootstrap is safe:
- The existing
HEADROOM_API_KEYis reused when its hash is still recognized by the server. No new key is created. HEADROOM_AGENTS.mdis never overwritten — delete it first if you want it regenerated.
To explicitly rotate the API key:
npx @headroom-cms/cli bootstrap --rotate-key --yesNon-interactive mode (CI)
# Use a specific site without prompts; create it if missing
npx @headroom-cms/cli bootstrap --site mysite.com --create-site --yesOther useful flags:
--framework next|astro|generic— override detection--env-file <path>— write to a custom env file--skip-install— leavepackage.jsonalone--skip-agents-md— don't writeHEADROOM_AGENTS.md
Admin commands
Super-admin maintenance commands live under headroom admin. These talk to
endpoints gated by the installation's super-admin allow-list — most users
will never need them.
restore-from-file — Restore a backup from a different installation
Use case: you downloaded an archive from installation A and want to recreate that site on installation B (a staging clone, a dev environment, a one-off test bed).
The target host MUST NOT already exist on this installation. To refresh an
existing site, delete it first or use the in-UI restore flow. Against a
fresh installation with no super-admin yet, the first caller of this command
becomes the new super-admin (bootstrap fallback — matches headroom sites create).
headroom admin restore-from-file ./prod-site-2026-04-01T120000Z.tar.gz \
--target-host staging-clone.localYou'll be prompted for confirmation showing the source host + record counts
- what's being copied. The caller becomes the sole admin of the new site.
Defaults:
--include-site-users(audience PII; default ON — appropriate for dev/test clones. Pass--no-site-usersto skip.)--include-api-keys(default OFF — keys from source installation are stripped. Pass--include-api-keysto preserve.)--include-audit(default OFF.)--yesskips the confirmation prompt.
After a successful restore, the CLI prints a headroom admin backups delete
command for the uploaded archive — it sits in the destination's S3 bucket
alongside native backups. Run that command once you no longer need it.
Resolved reads (--resolved / --public)
headroom content get <id> returns the admin projection: blocks live behind
a publishedBlockId reference, so fields.body looks empty. The output carries
a _hint reminding you how to see the runtime view.
Pass --resolved (alias --public) to read the content through the public
endpoint instead — the same shape the SDK sees at runtime, with resolved blocks
inlined at fields.body:
headroom content get <id> --resolved--resolved exercises the API-key-gated public read endpoint, so it needs a
per-site API key. The CLI resolves it from the HEADROOM_API_KEY environment
variable, or from HEADROOM_API_KEY= in your consumer project's .env (the
file headroom bootstrap writes). Without one, the command exits non-zero with
a NO_API_KEY error telling you to run headroom bootstrap or set
HEADROOM_API_KEY.
--resolved is the narrow precursor to the cdn command tree,
which exercises the full public CDN API (not just content-by-id) with the same
API-key auth. Reach for headroom cdn content get <id> when you want the public
projection as a first-class command.
CDN commands
headroom cdn … drives the public CDN API (/v1/{site}/..., gated by the
X-Headroom-Key header) — the same surface the published @headroom-cms/api
SDK uses at runtime. This is independent of the admin (Cognito) login: the CLI
works with either auth mode configured, or both.
- Admin commands (
sites,content,collections, …) need an admin login (headroom login, Cognito JWT). cdncommands need only a CDN API key — no admin login required. They are the right tool for read-only consumers, CI smoke tests, and exercising the exact path production sites depend on.
API-key precedence
The CDN API key is resolved with strict precedence (highest first):
| Rung | Source | Set by |
|------|--------|--------|
| 1 | --api-key <key> | per-command flag |
| 2 | HEADROOM_API_KEY | environment variable |
| 3 | .headroom/config.json apiKeys[<site>] | headroom cdn login |
| 4 | <consumer project>/.env HEADROOM_API_KEY=… | headroom bootstrap |
whoami reports which rung resolved (under cdn.apiKeySource) and doctor
probes the key against the live CDN — neither ever prints the key itself. CDN
auth is optional, so doctor skips (does not fail) the CDN check when no
key resolves.
The other CDN coordinates follow the same flag > env > config precedence:
apiUrl : --api-url > HEADROOM_API_URL > config.apiUrl
site : --site > HEADROOM_SITE > config.activeSite
mediaUrl : --media-url > HEADROOM_MEDIA_URL > config.mediaUrlmediaUrl is required even for media-free reads because the SDK constructor
enforces it.
Persist a key (optional)
For interactive use you can stash a per-site key in .headroom/config.json so
you don't re-export it every shell. Scripts that already set HEADROOM_* env
vars don't need this.
headroom cdn login --site mysite.com # prompts for the key
headroom cdn login --site mysite.com --api-key sk_…
headroom cdn logout --site mysite.com # removes the stored keyCommands
# Read published content
headroom cdn content list --collection <name> [--select <s>] [--limit <n>]
[--cursor <c>] [--sort <s>] [--before <ts>]
[--after <ts>] [--related-to <id>] [--rel-field <name>]
headroom cdn content get <contentId>
headroom cdn content by-slug <collection> <slug>
headroom cdn content singleton <collection>
headroom cdn content batch <id...> [--select <s>]
# Read schemas / metadata
headroom cdn collections list
headroom cdn collections get <name>
headroom cdn block-types list
headroom cdn version # site content version (raw)
headroom cdn openapi # the site's openapi.json
# Submission-collection content (write surface)
headroom cdn submissions list --collection <name> [--status approved|pending]
[--select <s>] [--limit <n>] [--cursor <c>] [--sort <s>]
headroom cdn submissions get <contentId> [--select <s>]
headroom cdn submissions batch <id...> [--select <s>]
headroom cdn submissions create --collection <name> --data <json> [--session <token>]
headroom cdn submissions update <contentId> --data <json> [--session <token>]
# Site-user OTP auth (email → session token)
headroom cdn auth request-otp <email>
headroom cdn auth verify-otp <email> <code> # prints { token, expiresAt, user }
headroom cdn auth session --session <token>
# Optional persistent key store
headroom cdn login [--site <host>] [--api-key <key>]
headroom cdn logout [--site <host>]All cdn read commands print JSON by default and honor --table / --csv /
-q, so jq over the output sees the SDK shape verbatim.
Singletons
A singleton collection holds exactly one content row. Resolve it in one command:
headroom content singleton get <collection>
headroom content singleton get <collection> --resolved # inline blocksResolution order:
- If the collection isn't a singleton, the command errors
(
COLLECTION_NOT_SINGLETON). - If the collection carries a
singletonContentId, that row is fetched directly. - Otherwise the command lists the collection's content and returns the
published row (falling back to the most recently edited row). If more
than one row exists — orphans left by re-running
collections createfor a singleton — it prints a warning to stderr and still returns the canonical row.
The canonical accessor for the singleton's id remains collections get
<collection>, whose output includes singletonContentId. (That field is
deliberately stripped from config export / config.json, which is
schema-only — it's server-managed, not schema-as-code.)
Cleaning up orphan rows
Re-running collections create for a singleton mints a fresh row, leaving the
old one behind as an unpublished orphan. To remove them in one pass — keeping
only the published row:
headroom content singleton cleanup <collection> --forceOnly unpublished rows are deleted; the published singleton row is protected
(the API returns 409 if you try to delete it directly). After a cleanup run,
content list --collection <collection> returns exactly one row.
See also the Field Types reference for the
collection field vocabulary and the params.body wire shape used by
content create / content draft.
Schema-as-code workflow
headroom config export pulls the live collections + block types into a
schema-only config.json; config diff <file> shows how a file differs from
live; config import <file> applies a file's changes back to the site. The
canonical edit-then-regenerate loop is:
1. Edit config.json
2. headroom config import # apply locally-authored changes
3. headroom config diff # confirm "no changes"
4. headroom config export # re-pull from live (or your wrapper
# script, e.g. pnpm headroom:types)Order matters. config export re-pulls the live schema, so running it
(or any wrapper such as pnpm headroom:types, which runs config export
first) before importing your local edits would overwrite them with the old
live schema — the new field never generates and you don't know why.
To prevent that silent revert, config export guards a dirty local file:
when --output <file> points at a non-empty config.json that differs from
the current live schema, the command aborts with a DIRTY_LOCAL_CONFIG error
telling you to config import first. Pass --overwrite-local to skip the
guard (back-compat for CI that drives codegen from live). A missing or empty
target file is the normal first-time bootstrap path and is never flagged.
Generated bindings (and config export output) sort collections and fields
alphabetically, so config.json insertion order is immaterial — see the
Field Types reference (bottom note) for
details. (singletonContentId is server-managed and deliberately stripped
from config export / config.json.)
Generate typed schemas (codegen)
headroom codegen writes a TypeScript file of Zod schemas for the site's
collections plus a discriminated Block union — one member per built-in
block type and per custom block type the site defines, keyed on type. Point
your content-loading code at the emitted file to get compile-time narrowing of
block.props (including props.data.* for custom blocks).
# Writes ./headroom.generated.ts for the active site
headroom codegen
# Explicit site + output path + plain `zod` import (default is "astro/zod")
headroom codegen --site mysite.com --out src/headroom.generated.ts --zod-import zod| Flag | Default | Effect |
|------|---------|--------|
| --site <host> | active site | Site to generate for. Resolves --site then config.activeSite; does not read HEADROOM_SITE (this is an admin-scoped command — see below). |
| --out <path> | headroom.generated.ts | Output file (relative to cwd). |
| --zod-import <src> | astro/zod | Import source for z — use zod for a plain Zod dependency. |
Auth. codegen resolves its site by the admin rule (--site >
config.activeSite), but the per-site block union it reads
(GET /v1/{site}/openapi.json) is API-key gated, so an API key must also be
resolvable (--api-key > HEADROOM_API_KEY > the cdn login key store >
the consumer project's .env, same precedence as the cdn commands). Run
headroom login (for the site/URL config) and have a key available. Unlike the
other cdn commands, codegen does not require a media URL — it reads only
text endpoints, so an admin who never ran headroom bootstrap can still run it.
Narrowing custom-block props. The emitted union appends an open
UnknownBlock escape-hatch member (so a stale cache or a newer block type
degrades gracefully instead of failing to type-check). Because
UnknownBlock.type is a bare string, a bare switch (block.type) guard
narrows to Member | UnknownBlock — and UnknownBlock has no typed
props.data. For deep custom-block prop access, type against the member
interface directly (e.g. CallToActionBlock["props"]["data"]["text"]) or use
the SDK's BlockComponentProps<Member> helper (@headroom-cms/api/react),
which reads block.props.data.* fully typed.
Concurrent-editing safety
content draft and content publish are unconditional by default — an agent
write during a live editing session silently clobbers (or is clobbered by) the
editor's next autosave. Two opt-in guards make sessionless writes concurrency-aware.
They are recommended for agents editing content a human might also edit; omit them
for bulk seeding where you are the only writer.
| Flag | Commands | Effect |
|------|----------|--------|
| --expected-updated-at <ms> | content draft | Conditions the write on the draft's updatedAt (Unix ms — the value content get returns as draft.updatedAt). Mismatch → 409 STALE_WRITE. |
| --respect-lock | content draft, content publish | Rejects the write if a live edit lock exists (a human has the content open in the admin UI) → 409 LOCK_HELD. |
The recommended agent flow is read-modify-write:
# 1. Read the current draft — note draft.updatedAt (the concurrency base stamp).
headroom content get <id>
# 2. Write with both guards.
headroom content draft <id> --data '…' \
--expected-updated-at <draft.updatedAt> --respect-lock
# 3. On STALE_WRITE: re-run step 1, merge, retry with the new stamp.
# On LOCK_HELD: a human is editing — back off and retry later.Neither flag set = today's unconditional write (existing behavior; safe for seed
scripts and migrations). Combining either guard with a sessionId/generation
(the admin UI's own machinery) is rejected with a 400 — the guards are
sessionless-only.
Conflict codes
On a 409 the CLI writes a machine-readable JSON envelope to stderr — code
plus the structured recovery field, and a human hint line beside it:
code: "STALE_WRITE"— the draft changed since your--expected-updated-atbase. The envelope carriescurrentUpdatedAtas a top-level field (the row's current stamp,0if no draft exists yet). Re-read withcontent get, merge, retry with the new stamp.code: "LOCK_HELD"— a live edit lock exists. The envelope carrieslockHolderas a top-level field ({ name?, email? }). Back off and retry later, or omit--respect-lockto override.
Configuration
The CLI keeps a per-repo config in .headroom/config.json (at the git root).
Refresh tokens live in .headroom/tokens/*.json with mode 0o600. The
bootstrap command also drops .headroom/.gitignore (*) so contents are
never committed.
Tips
- All commands output JSON by default (pipe to
jqfor filtering). - Use
--tablefor human-readable output. Nested objects/arrays render as JSON strings (no more[object Object]). - Use
--csvto emit CSV (list commands only; nested objects render as JSON strings). For single-object commands (e.g.headroom sites get),--csvfalls through to JSON silently. - The CLI auto-paginates when
--limitexceeds the per-endpoint cap (100 for admin endpoints). For example,headroom content list --limit 500makes five paginated requests under the hood and returns all 500 (or fewer if exhausted). Set--quietto suppress the "(more results available)" notice. - When editing content a human might also have open, guard your writes — see
Concurrent-editing safety for the
--expected-updated-at/--respect-lockflags and theSTALE_WRITE/LOCK_HELDconflict codes.
See also
headroom doctor— diagnose API reachability, auth, and API-key validityheadroom whoami— show the active sessionheadroom collections list --table— discover the schemasheadroom content list --collection posts— read content- Field Types reference — every field
type, its
options.*, the generated TS/Zod shape, and when to use each (and when not to reach forjson) when editing collection schemas
