npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@headroom-cms/cli

v0.9.0

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 --help

Or install globally:

npm install -g @headroom-cms/cli
headroom --help

Authenticate

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.com

A bare hostname is auto-prefixed with https:// (or http:// for localhost / 127.0.0.1 / *.local).

Sessions

headroom login creates a session — one revocable, labelled login for this machine (the label is the hostname). Sessions are not site-scoped; one session spans every site you administer.

headroom sessions list --table       # CURRENT=yes marks the session running this command
headroom sessions revoke <sessionId> # sign out one machine/browser
headroom sessions revoke-others      # sign out everything except this session

Session ids are bare 32-character lowercase hex, exactly as sessions list prints them — there is no prefix.

Revoking takes effect on the target's next token refresh, bounded by the access-token lifetime. revoke-others prompts unless you pass --force.

Scripted / unattended access

A session needs a human at a keyboard. For a nightly script, a CI job or an agent, mint an admin API token — a durable bearer credential you set as an environment variable.

# 1. Mint it. It asks for your own admin password to confirm it's you. The
#    token is printed to stdout and NOTHING else is, so this captures the
#    credential cleanly:
export HEADROOM_ADMIN_TOKEN=$(headroom tokens create --label "nightly-import" --expires-in-days 90)

# 1b. No terminal? Pipe the password in. Same command, same output contract —
#     `--password-stdin` replaces the PROMPT, not the password requirement:
export HEADROOM_ADMIN_TOKEN=$(echo "$ADMIN_PASSWORD" | \
  headroom tokens create --label ci --expires-in-days 90 --password-stdin)

# 2. Put that value in your secret manager (GitHub Actions secret, AWS Secrets
#    Manager, Vault…). It is shown ONCE and can never be retrieved again.

# 3. In the job, export it and run any admin command. No `headroom login`,
#    no `.headroom/` directory, no refresh requests:
export HEADROOM_ADMIN_TOKEN='hrt_…'
headroom sites list
headroom content list --site example.com --collection posts

You can also mint a token in the admin UI under Account settings → API tokens; it is the same credential.

headroom tokens list --table          # your tokens; EXPIRES reads NEVER for a permanent one
headroom tokens revoke <tokenId>      # ends it within 60 seconds
headroom tokens list --user <sub>     # another admin's tokens (super admin only)

What a token can do, and for how long

  • Full authority of the admin who minted it, across every site they can reach, without a two-factor prompt. If that admin is a super admin, the token can create and delete admin accounts and strip their MFA. A stored non-expiring token is the practical equivalent of that person's password.
  • --expires-in-days is optional, and omitting it means the token never expires. Only two things then end it: revoking it, and deleting the admin account it is bound to. create warns on stderr when you omit it.
  • Disabling the bound admin in Cognito suspends the token rather than destroying it — re-enabling the admin makes it work again. Revoke it to end it permanently.
  • Revocation takes effect within 60 seconds (an in-process cache bounds it). An expiry date is different — it is enforced exactly, with no grace.
  • A token cannot mint another token. That removes the cheapest way for a leaked token to renew itself; it is not containment (see below).
  • There is a limit of 20 live tokens per admin.

Why minting asks for your password, and why there is no --password

Minting requires confirming your own admin password. An ordinary API session is not enough: without that step, anything that could act as you for a moment — a stolen access token, script injection in the admin UI — could turn itself into a permanent, MFA-free, full-admin credential.

There is deliberately no --password flag and no password environment variable: either would put your password into shell history, into a process listing (ps shows every process's argv, to every user on the box) and into CI logs, which is the exposure this whole feature exists to remove.

A pipe has none of those problems, so that is the non-interactive route: --password-stdin reads the password from stdin and takes no value on the command line. It is the same flag, the same spelling and the same reader as headroom login --password-stdin. Everything else is unchanged — the password is still required, still verified by the server, and the token still goes to stdout alone.

echo "$ADMIN_PASSWORD" | headroom tokens create --label ci --password-stdin

Without a terminal and without that flag, tokens create refuses rather than minting with no confirmation.

tokens create will not run while HEADROOM_ADMIN_TOKEN is set, and says so before asking for anything. A token cannot mint another token, and the variable outranks your stored login for as long as it is exported — so headroom login cannot break the loop. unset HEADROOM_ADMIN_TOKEN first, or mint from the admin UI. (If you have just run step 1 above and want a second token, this is the case you are in.)

If a token leaks: containment

Revoking the token ends the token, not the compromise. A full-admin bearer can independently re-establish access before you revoke it, through routes that have nothing to do with tokens: inviting a new Cognito admin at an address it controls, rewriting a site's admin list or the super-admin list, minting a site API key (which also yields that site's image-signing secret), rotating webhook secrets, creating a new site, restoring a site from an uploaded archive, and stripping an admin's MFA. Two of those leave no audit row at all — inviting a Cognito admin (POST /v1/admin/sites/{host}/invite) and rewriting a site's admin list (PUT /v1/admin/sites/{host}/admins) — so that much of the persistence is invisible. (It was four until 2026-08-15: the super-admin rewrite and the MFA strip are audited from that date, as admin.super_admins_update and admin.mfa_disable. Both land in the global audit partition, which neither headroom audit list nor the admin UI's Audit page can reach — both are site-scoped. Read them from GET /v1/admin/audit directly, as in step 2 below.)

So, in order:

  1. Revoke the token — headroom tokens revoke <tokenId>, or from the admin UI. Effective within 60 seconds.

  2. Find what it did. Every audit row records which credential performed the action, in details.credentialId. There is no server-side filter for it — GET /v1/admin/audit accepts before, action and admin only, and headroom audit list exposes exactly those — so select on it client-side from the JSON output:

    TOKEN_ID=<the leaked token id>
    headroom audit list --site example.com --limit 1000 \
      | jq --arg id "$TOKEN_ID" '.items[] | select(.details.credentialId == $id)'

    Repeat per site; the global log (GET /v1/admin/audit, super admin only) carries the mint and revoke events themselves. In the admin UI, a row driven by an API token is badged “via API token” and the detail drawer names the credential id, but the list cannot be filtered on it there either — so for anything past a page or two, use the jq form above.

    This narrows the search; it does not close it. The two unaudited routes above emit nothing to filter, so steps 3–6 are not optional even if the log looks clean. And the jq form here is site-scoped: the global log carries admin.delete, admin.mfa_disable, admin.super_admins_update, apitoken.* and session.*, so check it as well as each site's.

  3. Rotate every site API key the token could have minted or read (headroom api-keys list/create/delete --site <host>). A leaked site key also exposes that site's image-signing secret via the public /version endpoint, so treat key rotation as covering both.

  4. Audit and remove the principals. Detection is not containment: an attacker-invited admin survives every other step in this list, and the runbook is not complete while one is still standing.

    headroom tokens list --table          # note the leaked token's CREATED date first
    headroom users list --table           # admin accounts, with creation dates

    Use --table on both: in JSON, tokens list emits Unix seconds and users list emits epoch milliseconds, so the raw numbers differ by 1000×. The table renders both as YYYY-MM-DD.

    For anything created since that date, or that you cannot account for:

    • Delete the Cognito admin — headroom users delete <sub>. The invite that created it is one of the two routes above that leave no audit row, and a Cognito admin is the longest-lived thing the token could have planted, so treat an unrecognised admin as hostile rather than as a colleague you have forgotten. (The deletion itself IS audited — admin.delete, global partition — as is stripping the account's MFA on the way out.)
    • Remove it from every site's admin list — admin UI → Site Settings → Admins (PUT /v1/admin/sites/{host}/admins; there is no CLI command). Deleting the Cognito account does not rewrite the site rows.
    • Demote it from the super-admin list — admin UI; the list lives in the installation's global config.
    • Re-enroll MFA for every admin whose factor the token may have stripped. Stripping MFA leaves no trace in the account's own state beyond the missing factor, so compare against your own roster rather than against the console. headroom users list --table shows an MFA column.
  5. Rotate webhook secrets on every site (headroom webhooks rotate-secret --site <host> <webhookId>).

  6. Account for new sites and for anything restored. Two of the eight re-establishment routes change the site inventory rather than a principal inside it, and step 4's per-site sweep silently assumes the inventory is the pre-incident one.

    headroom sites list --table           # CREATED is rendered as a date
    • A site created during the exposure window is attacker infrastructure until proven otherwise — its creator is its sole admin by construction, which is a self-bootstrap to full authority over it. Confirm each against your own records; headroom sites delete <host> removes one.
    • Restore-from-upload rewrites content and site configuration from an archive the uploader supplied. There is no integrity check to run after the fact: if a restore happened during the window (the audit log records it, when it records anything), treat that site's content and settings as untrusted and restore it again from a backup you know predates the leak.

Then re-mint a replacement token with an expiry.

Not on this list: the search index. It is derived data, fully rebuildable from content, and it grants nothing — a rebuild is a repair, not a containment step, so do not spend window time on it. See Search if content you restored in step 6 is missing from search results afterwards; the same --site rule stated there applies to every command in this runbook.

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 bootstrap

What it does:

  1. Detects the framework (next / astro / generic) and package manager (npm / pnpm / yarn / bun).
  2. Resolves the API + media URLs from your active login.
  3. Prompts you to pick (or create) the site to bootstrap against.
  4. Creates an API key on the site (label format bootstrap-{framework}-{YYYYMMDD}-{shortid}).
  5. Reads the site's frontend callback secret (see below).
  6. Writes HEADROOM_API_URL, HEADROOM_MEDIA_URL, HEADROOM_SITE, HEADROOM_API_KEY and HEADROOM_FRONTEND_SECRET to .env (.env.local for Next.js).
  7. Adds the env file to .gitignore if missing.
  8. Installs @headroom-cms/api with your detected package manager.
  9. Drops HEADROOM_AGENTS.md at the project root — an instruction sheet for AI agents working in the repo.

Idempotency

Re-running bootstrap is safe:

  • The existing HEADROOM_API_KEY is reused when its hash is still recognized by the server. No new key is created.
  • The frontend secret is read, never rotated — a re-run against a site whose frontend is already deployed hands back the same value.
  • HEADROOM_AGENTS.md is never overwritten — delete it first if you want it regenerated.

To explicitly rotate the API key:

npx @headroom-cms/cli bootstrap --rotate-key --yes

Non-interactive mode (CI)

# Use a specific site without prompts; create it if missing
npx @headroom-cms/cli bootstrap --site mysite.com --create-site --yes

Other useful flags:

  • --framework next|astro|generic — override detection
  • --env-file <path> — write to a custom env file
  • --skip-install — leave package.json alone
  • --skip-agents-md — don't write HEADROOM_AGENTS.md

The frontend callback secret

HEADROOM_FRONTEND_SECRET is the one HMAC identity behind every backend→frontend callback: live preview and newsletter rendering. The SDK's injected routes verify X-Headroom-Signature with it and fail closed — an unset secret answers 401, which reads like a broken integration rather than a missing setting.

It is per site, not per project: every environment and deployment of the same host verifies with the same value. So unlike an API key or an admin API token, it is readable as often as you need rather than shown once — the server keeps the plaintext because it signs with it.

# Read it (minted on first read; safe to repeat)
headroom sites frontend-secret mysite.com

# Capture straight into a frontend's env
export HEADROOM_FRONTEND_SECRET=$(headroom sites frontend-secret mysite.com --raw)

headroom bootstrap and create-headroom-site both do this for you. Reading requires the admin role on the site and is recorded in the audit log (site.frontend_secret_revealed); the Site Settings → Frontend secret card in the admin UI is the same operation.

Rotating it

headroom sites frontend-secret mysite.com --rotate

Rotation mints a new secret and moves the current one to previousSecret. Headroom signs callbacks with both for 24 hours (model.FrontendSecretGracePeriodSeconds), so the rollout is:

  1. Rotate, and put the new value in HEADROOM_FRONTEND_SECRET and the displaced one in HEADROOM_FRONTEND_SECRET_PREVIOUS.
  2. Redeploy every frontend of that site.
  3. Clear HEADROOM_FRONTEND_SECRET_PREVIOUS and redeploy again.

Step 3 is what actually ends the rotation: the frontend accepts a configured previous secret for as long as the variable is set — there is no expiry on that side, exactly like the image-signing master's OLD-key fallback. Skipping step 2 strands every deployment still holding the old value once the 24-hour window closes, which is why --rotate prompts for confirmation (use --force in CI).

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.local

You'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-users to skip.)
  • --include-api-keys (default OFF — keys from source installation are stripped. Pass --include-api-keys to preserve.)
  • --include-audit (default OFF.)
  • --yes skips 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).
  • cdn commands 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.mediaUrl

mediaUrl 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 key

Commands

# 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.

Search

Site search over the admin index, so results include drafts, scheduled and unpublished rows — the operator's view of the site, not a visitor's. Search is opt-in per site and off by default; until it is enabled (Site Settings → Search, or ?section=search on the settings page) all three commands answer 404.

headroom search <query> [--collection <name>] [--tag <tag>] [--status <status>]
                        [--limit <n>] [--offset <n>] [--site <host>]
headroom search status  [--site <host>]
headroom search reindex [--site <host>] [--force]
  • search <query> prints the whole envelope in JSON ({total, limit, offset, indexStatus, processed, totalEstimate, results}) — total is the pagination denominator and indexStatus is how a script tells "no matches" from "the index is still building". --table / --csv print just the rows, as TITLE / COLLECTION / STATUS / SLUG. Terms are ANDed and matched whole-word; accents and case fold. --status is repeatable (published|draft|unpublished|scheduled).
  • search status prints the index state as-is. totalEstimate: 0 means UNKNOWN, not zero — a first build has no denominator, so read processed as a count rather than dividing. lastError: "doc_cap_exceeded" means the index is truncated at its document cap and still serving, not that it failed.
  • search reindex rebuilds from scratch. One HTTP call is one bounded chunk of work, so the command loops until the rebuild completes, printing progress to stderr and a single JSON summary to stdout — headroom search reindex | jq works. --force skips the confirmation, and is required non-interactively. A 409 means another rebuild already holds the site's lease (a second operator, or the admin UI's own loop after someone ticked the Search box); the command reports it on stderr and stops rather than failing.

Scripting the 409 path: it is invisible to set -e and &&. On a lease collision the command writes its explanation to stderr, leaves stdout empty, and exits 0. That is deliberate — an empty stdout means jq emits nothing, so a script cannot mistakenly read a summary and conclude the index is current. But it also means a chain proceeds:

headroom search reindex --site X --force && ./smoke-test   # ./smoke-test RUNS

Usually that is fine — another rebuild genuinely is running — but this process did not finish one, and there is no signal short of parsing stderr. A caller that must wait for a rebuild should poll headroom search status until status is no longer building:

headroom search reindex --site X --force
until [ "$(headroom search status --site X | jq -r .status)" != "building" ]; do
  sleep 2
done

Reindex is also the headless remedy for a lost journal row — the case where the index has fallen behind in a way ordinary syncing cannot repair. It is the same operation as the admin UI's Rebuild button.

Site resolution here is the admin rule: --site <host>, else the active site from headroom site <host>. HEADROOM_SITE is NOT read — that variable belongs to the headroom cdn chain, and a script that exports it and expects these commands to follow will silently search whichever site is active. The same rule governs every command under Admin commands; it is restated here because search is the command most likely to be reached for from a shell that already has HEADROOM_SITE set for cdn reads.

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 blocks

Resolution order:

  1. If the collection isn't a singleton, the command errors (COLLECTION_NOT_SINGLETON).
  2. If the collection carries a singletonContentId, that row is fetched directly.
  3. 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 create for 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> --force

Only 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-at base. The envelope carries currentUpdatedAt as a top-level field (the row's current stamp, 0 if no draft exists yet). Re-read with content get, merge, retry with the new stamp.
  • code: "LOCK_HELD" — a live edit lock exists. The envelope carries lockHolder as a top-level field ({ name?, email? }). Back off and retry later, or omit --respect-lock to override.

Configuration

The CLI keeps a per-repo config in .headroom/config.json (at the git root). Session tokens live in .headroom/tokens/*.json with mode 0o600. The bootstrap command also drops .headroom/.gitignore (*) so contents are never committed.

A token file written by an older CLI (one that stored a Cognito refreshToken) is deleted on first read and the CLI asks you to run headroom login again — that credential cannot authenticate against the current server.

Tips

  • All commands output JSON by default (pipe to jq for filtering).
  • Use --table for human-readable output. Nested objects/arrays render as JSON strings (no more [object Object]).
  • Use --csv to emit CSV (list commands only; nested objects render as JSON strings). For single-object commands (e.g. headroom sites get), --csv falls through to JSON silently.
  • The CLI auto-paginates when --limit exceeds the per-endpoint cap (100 for admin endpoints). For example, headroom content list --limit 500 makes five paginated requests under the hood and returns all 500 (or fewer if exhausted). Set --quiet to 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-lock flags and the STALE_WRITE / LOCK_HELD conflict codes.

See also

  • headroom doctor — diagnose API reachability, auth, and API-key validity
  • headroom whoami — show the active session
  • headroom sessions list — list and revoke your logins
  • headroom collections list --table — discover the schemas
  • headroom 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 for json) when editing collection schemas