Every pagevault command. This is the canonical list — the README shows the
few you need to get started, and the agent runbook drives these same commands;
both defer here for the full surface.
pagevault is the installed product. npm install -g pagevault stands PageVault up on your own
Cloudflare account and gives you every command below. Running from a repo checkout? The same verbs
exist as make targets (make deploy, make verify, …) — one engine, two front doors.
- Node 22+ for the commands that build or deploy (
init,upgrade) — they bundle the Worker. The document commands (publish,list, …) run on Node 18+. - macOS, Linux, or Windows. Linux is covered by CI; Windows is verified by hand. The one
behavioral difference is noted under
exportbelow. See prerequisites. - Color is dropped automatically when output is not a terminal, so redirected and piped output is
clean.
NO_COLORforces it off,FORCE_COLORforces it on. - On success,
publish/mint/rotateprint only the URL to stdout; every status line, prompt, and warning goes to stderr, sopagevault publish report.html | pbcopydoes the obvious thing. --jsonis available on the read and diagnostic commands, for scripting and for an agent to consume.pagevault <command> --helpprints that command's own flags and what they do — the same text its usage guard throws, so the two cannot drift.pagevault helpalone is the one-line summary.
Stand PageVault up on your own Cloudflare account — no repo clone. Walks you through the Cloudflare
API token, the tier, the owner email, and the account, writes state to ~/.pagevault/, deploys the
bundled Worker, and writes the login config for you so publish works immediately (no separate
login). Re-run it to climb a tier: it shows your current choices and asks only for what's new, and
your documents carry across keeping their ids and filenames.
| Flag | Effect |
|---|---|
--tier public|secured |
the tier, unasked. Public = links anyone can open; Secured = named people, via Cloudflare Access |
--host pagevault.you.com |
the hostname. Required for Secured; optional (but implied) for Public |
--email you@example.com |
the owner — the identity that can always see everything |
--rung 1|2|3 |
the escape hatch: 1 = Public on workers.dev, 2 = Public on your domain, 3 = Secured |
--yes |
never prompt. Flags and environment supply every answer |
--yes on a first deployment also needs a bearer. Non-interactively there is nobody to show
a freshly minted PAGEVAULT_API_TOKEN to, so init refuses rather than deploying a Worker you have
no way to authenticate to — before it creates anything. Either run it interactively once (it mints
and saves one), or provide your own:
export PAGEVAULT_API_TOKEN=$(openssl rand -hex 32)
pagevault init --yes --tier public --email you@example.comRe-running --yes against a deployment that already has a bearer is fine — it reuses it, and never
rotates a live one.
Redeploy the Worker bundle that shipped with your installed package — after npm update -g pagevault.
Keeps your KV, config, and secrets.
Point the CLI at a deployment: writes ~/.pagevault/config.json (mode 600 — it holds a bearer) and
verifies the connection. The flags are optional — it falls back to PAGEVAULT_URL /
PAGEVAULT_API_TOKEN from the environment, so pagevault login alone persists the config you already
have exported. init already does this for the deployment it stood up; reach for login only for a
second machine, or someone else's deployment.
--as <name> registers the deployment by name in ~/.pagevault/deployments.json instead, so one
machine can hold several. Without it, nothing changes: one deployment, one config.json.
One operator, more than one deployment — a production instance deployed by CI and a test one you deploy from a checkout. Each named deployment carries its own bearer, so a command can never pair one deployment's URL with another's credential.
CLOUDFLARE_API_TOKEN is deliberately not in the registry. It stays in per-clone .env.local,
because that placement is what keeps the production credential off your laptop entirely — a
wrong-clone make deploy cannot touch production by construction rather than by discipline.
Everything this machine can reach, with * marking the default. The login config is listed too, as
the implicit deployment it has always been.
PROVISIONED means the build record is on this machine, so upgrade, destroy and backup can
run. Its absence is the normal state for a CI-deployed instance — a fact about the deployment, not a
fault.
The answer comes from a path the entry recorded, so it is the same wherever you run it from — a
listing that said no in your home directory and yes in a checkout would be describing your
cd history rather than your deployments. It is read fresh each time rather than cached, so a
checkout you delete, move, or re-provision against something else goes back to no, which is then
the true answer.
If a build record is sitting in the directory you are standing in and no deployment has recorded it,
the listing says so and names the command that fixes it — pagevault login --as <name>, which
amends the entry without asking you to retype the URL or token.
Make a registered deployment the default. Writes the registry and nothing else; no file in a working tree is touched, and no bearer is ever written into a repository.
| Rung | Source |
|---|---|
| 1 | --deployment <name> |
| 2 | PAGEVAULT_DEPLOYMENT (direnv, CI, a one-off export) |
| 3 | the checkout you are standing in — .pagevault.json, found by walking up |
| 4 | the default, * in deployments, set by use |
| 5 | the login config |
Rung 3 is the guardrail: inside a checkout you get that checkout's deployment whether or not you
remember to say so, the same way git and npm find theirs. Every command prints which one it chose
and why — on stderr, so pagevault publish report.html | pbcopy still carries only the URL.
pagevault login --as prod --protected # amends an entry already registeredOn a protected deployment the destructive document commands — rm, revoke, rotate — require an
explicit --yes. Publishing, editing and sharing are unaffected: a confirmation on the operation you
perform most gets answered reflexively within a day. It is a refusal rather than a prompt, so it
means the same thing in a terminal and in a script. --no-protected clears it.
Re-running login --as <name> on a deployment already registered amends that entry, so credentials
need not be retyped to change a flag.
Upload a file and print its URL. A document's identity is its filename (ADR-017): re-publishing
the same file updates it in place at the same URL; a differently-named file is a new document, even
with the same title. The display title comes from the HTML <title> (or a markdown # H1, or the
filename) unless you pass --title.
| Flag | Effect |
|---|---|
--portal <slug> |
publish into a client portal (default portal otherwise) |
--name <filename> |
override the identity/update key (default: the file's basename) |
--title <t> · --summary <s> · --tags a,b |
metadata (--title is display only, not the key) |
--emails a@b,c@d |
grant these people (email-gated) — additive, never revokes |
--public |
also mint a no-login /p/ link and print that (zero Access seats) |
--owner-only |
a draft only you can see |
--source-kind html|markdown |
override the extension-based guess |
--confirm |
required to replace an existing document with the same filename in place |
Your documents, newest first.
Fix a published document's filename, title, summary or tags. Not its contents — republish the file
for those. Only the flags you pass change; --summary "" and --tags "" clear those fields.
--name is the document's identity (ADR-017), so renaming moves the document to a new URL.
The old URL redirects for a year, and any /p/ public link keeps working unchanged — its token was
never derived from the id. Changing only the title (or only the case of the filename) moves
nothing. See ADR-020.
Renaming onto a filename another document already uses is refused outright — there is no
--confirm here, because finishing a rename by destroying a different deliverable is never what
was meant. To replace a document deliberately, use publish <file> --name <that-filename> --confirm.
The new URL is printed to stdout, so pagevault edit <id> --name q3.md | pbcopy hands back the
link that now works.
Print a document's shareable URL to stdout, and nothing else — so pagevault link <id> | pbcopy
just works. A public document hands back its /p/ capability link; otherwise the portal viewer URL,
which requires a login. Warns on stderr if the document is an owner-only draft, since that link
opens for nobody yet.
The public-link lifecycle. mint creates a /p/ capability link; revoke kills it (keeps the
document); rotate replaces it with a fresh one (the old link dies). Minting and rotating are
widening actions — anyone with the link can open the document, no login.
Your portals: slug, kind, name, created. One API call — document counts are deliberately not
fetched, because that is a KV list() per portal against a separate 1000/day quota. Use
pagevault list --portal <slug> when you want the documents.
Open a new client boundary. The slug is the URL segment and the handle every other command takes.
| Flag | Effect |
|---|---|
--name "Acme Corp" |
display name (defaults to the slug) |
--kind restricted |
a client portal — its members see everything in it |
--kind private |
yours only (the default) |
--kind public |
anyone with the link, no login, and it burns no Access seat |
--description "…" |
shown at the top of the portal index |
Prints the slug to stdout, so it pipes into a publish.
Grant or revoke access to a whole portal — one write covers every document in it. Permissions live on the portal, not the document, so adding someone to a client's team is one call, not fourteen.
--remove stops KV authorizing them immediately, but Cloudflare Access keeps admitting them — and
keeps charging a seat — until sync-access --reap
reconciles. The command says so; it is not a silent half-revocation.
Delete a document. There is no undo. Interactive confirm unless --yes.
Write everything you own to a browsable folder — index.html, an ACCESS.md naming who can see what,
one folder per portal. A walk-away copy, not a backup (ids and public tokens are omitted). The final
path prints to stdout.
--zip shells out to zip, which Windows does not have. There it writes the folder, prints a
note saying it could not compress, and exits cleanly — you get the export either way, just not
zipped. Compress it yourself with Compress-Archive if you want a single file.
Read a document's metadata. --source prints the stored body (the original .md or HTML) to stdout,
byte-for-byte — pagevault read <id> --source > report.md round-trips.
Search one portal's documents. The portal is required on purpose: a cross-client search is how one client's material ends up in another's answer.
These auto-target your deployment from ~/.pagevault/ — no arguments, no login.
What this install is configured for — tier, account, host, versions. Local only, no network.
It leads with the deployment it would act on and what chose it, whether or not this machine
provisioned it, and says so when that deployment is protected. Below that, a deployment provisioned
from here reports its build record; one deployed elsewhere says so instead of reporting fields it
does not have.
.pagevault.json records the
intent you gave init; nothing in status asks the Worker whether that is still true. It will
happily print Tier Secured for a deployment that was redeployed without Access, or name a host and
a KV namespace that a destroy removed. --json carries "source": "local" so an agent can tell.
To confirm the deployment agrees, run pagevault health.
The post-deploy smoke test: the Worker is live and ours, the root behaves for the tier, the /mcp
surface answers a real publish → rename → read → revoke round-trip (the rename leg asserts the
document's id actually moved, which is what a rename means — ADR-020), OAuth discovery, and a sample publish
that hands back an openable link. --json emits a per-check verdict; the exit code (0/1) drives an
agent.
Assert the live /health reports the exact build you shipped (<version>+<sha>) and that /mcp
answers. Non-zero exit on a mismatch or an unreachable deployment — this is what production CI runs.
It also reports how much view history is about to become unrecoverable. Views reach Analytics
Engine on their own, but only pagevault sync-views makes them durable, and Analytics Engine keeps
about 90 days — so a window that ages out uncovered is gone, silently. This is the thing that says so
before the loss instead of after:
! 71 days of view history become unrecoverable in 20 days.
Captured through 2026-05-28. Fix it with pagevault sync-views.
It reports risk, not age — "synced 40 days ago" leaves you to do the arithmetic. The warning is loud but never changes the exit code: a deployment that is up with an unsynced summary is still up, and failing a production deploy over it would punish the wrong thing.
This lives here rather than in status because status is deliberately
offline — it prints your saved answers and says so. It is not on the /health endpoint itself
because that endpoint is unauthenticated, and when you last synced is a fact about how you work.
Reconcile the Cloudflare Access viewer group with what KV authorizes. --reap also removes people KV
no longer authorizes (reclaiming seats) — it confirms first.
Which documents your clients actually opened, and where the traffic came from. Reads Analytics
Engine directly rather than going through /api, so it needs a Cloudflare token in the
environment — as backup and restore do, and for the same reason: the Worker deliberately holds
no credential that wide.
Below the table, a traffic sources block: the hosts that linked to your documents, or direct
where the browser sent no referrer. Only the linking host is ever recorded — never the page it
linked from, which is someone else's private context
(ADR-023 §5). It is skipped under --doc, because
sources are aggregated per portal and printing a portal's traffic under one document's filter
would be a wrong answer rather than a narrower one.
A (portal index) row is someone landing on a collection page without opening anything. Those are
counted on their own line rather than folded into document views, and they record no viewer on
any surface — including /v/, where Access knows exactly who it was (§6).
views blends the old with the new and presents all of
it as current. Rows may name documents and portals that no longer exist. Cross-check against
pagevault list when it matters.
Records are kept for three months and then age out on their own; destroy cannot clear them,
because the Worker deliberately holds no credential that can read or delete analytics
(ADR-015 §5–6).
Move view counts out of Analytics Engine and into your deployment, where they last — and where an agent can see them.
It is a separate command rather than a flag on views because it does a different kind of thing:
views looks at a 90-day window, sync-views rescues that window before it ages out permanently. As
views --sync the consequential act looked like an option on the harmless one. views --sync
still works and prints a note pointing here — it is in docs, muscle memory and possibly a crontab,
and a scheduled sync that starts failing silently is exactly what ADR-023 §9 exists to prevent. read_document
and list_documents then report views, lastViewedAt, and which door readers came through —
as of the sync, never live.
The stored summary accumulates. Each sync adds the window it could see and never removes what an
earlier one contributed, so your history outlives Analytics Engine's three-month retention
(ADR-023 §1). Before this, every sync re-derived from
a rolling 90-day query — so a document opened 43 times in January reported views: 3 by June, with a
newer viewsSyncedAt making it look fresher at the moment it became less true.
Your own views are counted apart from the client's, where the deployment can tell — the split is computed on your machine from an address that never leaves it. Where this machine does not hold the deployment's build record the split is absent rather than guessed.
--reset throws the stored history away and rebuilds from the current window alone. It is the one
destructive option here, and it asks for the deployment URL before doing it: anything older than 90
days is not in Analytics Engine any more and does not come back.
Backups matter more than they did. The summary lives in KV, so backup carries it and destroy
ends it. A backup is now the difference between keeping and losing history.
The query still runs here, on your machine, with your Cloudflare token; only the aggregate travels (ADR-019). Counts and surfaces only — viewer emails never leave this machine.
- Whole-deployment by design.
--portaland--docare refused: a summary covering one client would make every document outside it report a measured zero — "they never opened it" about documents nobody measured. - 90-day window by default, where the table defaults to 30. "Have they ever opened it" is a lifetime question, and Analytics Engine retains about three months.
- Documents that no longer exist are dropped, and the count of them is reported — the dataset outlives the deployment, so a rebuild leaves rows pointing at ids that no longer resolve.
- Costs one KV write, so schedule it. Daily is the sensible cadence. The Worker cannot run
this for you — its Analytics Engine binding is write-only, so it cannot read its own metrics at
any schedule (ADR-019 §1). That is a fact
about the Worker rather than advice against scheduling: an operator-side schedule is exactly what
keeps history from ageing out uncovered, and
pagevault healthtells you how long you have.
An agent sees nothing at all until the first sync, and nothing for a document published since the last one. That is deliberate — absent means "not measured", and only a real zero means nobody opened it.
Same-host disaster recovery. backup snapshots the whole KV namespace — documents, portals,
members, public-link tokens — to one JSON file; restore replays it. Keys are preserved
byte-for-byte, so document ids and every /p/ link you have already shared survive.
Both talk to Cloudflare directly with your provisioning token rather than to /api, because
listings render from KV key metadata and no PageVault endpoint exposes it.
A restore is a bulk write, never a wipe: it puts back every key in the backup and deletes
nothing. So it asks what is in the namespace that the backup will not replace — those keys survive
and mix in with the restored data — and stops to name them. --force proceeds anyway; it suppresses
the refusal, not the facts, and still lists what is being kept.
--kv <id> targets a namespace other than the one this install deployed. The full story, including
why the format carries key metadata and what a restore does not bring back, is in
Backup & restore.
Tear the deployment down — Worker, DNS, Access apps, group, and KV data. Irreversible, and it asks:
it verifies the token reaches the pinned account, then makes you type the target hostname to confirm.
--keep-data leaves the KV namespace and its documents.
| Variable | What it does |
|---|---|
PAGEVAULT_URL / PAGEVAULT_API_TOKEN |
the publish target; override config.json per command |
CLOUDFLARE_API_TOKEN |
the provisioning credential (init/upgrade/destroy/views/backup/restore) |
CF_RUNTIME_TOKEN |
Secured only. The narrowly scoped token init puts in the Worker as CF_API_TOKEN, so it can keep the Access viewer group in step with portal membership (ADR-002). Absent, the deploy warns and email grants stop reaching Access |
PAGEVAULT_HOME |
relocate all state — config.json, .pagevault.json, .env.local — so one machine can hold several deployments |
State lives in ~/.pagevault/ for an install (or the repo directory when running from source). To
target several deployments from one machine, give each its own PAGEVAULT_HOME, or pass
PAGEVAULT_URL/PAGEVAULT_API_TOKEN per command.
Client-facing pages carry a muted Powered by PageVault mark — in the viewer's control row, after the Download/PDF/Share buttons, and in the portal index footer. Never above the fold, never beside your client's own title.
To remove it, add "branding": false to .pagevault.json and redeploy:
pagevault upgrade # or: make deployIt is on by default because PageVault is free and spreads by being seen. It is one flag rather than a patch you maintain, because it is MIT and a deployment is yours — ADR-002's posture applied to a smaller question. The mark is removed entirely, not hidden with CSS: nothing is left in the page source of a document you hand a client.
pagevault help prints the short version of all of this.