Pagedeck

The pagedeck command

pagedeck is run from a site directory — one holding a pagedeck.config.ts or a pagedeck.config.js. Paths inside the config are resolved against the config file, not against the working directory, so a CI step may run pagedeck from anywhere.

pagedeck build [--incremental]             render, bundle and write the whole site
pagedeck dev [--port <n>] [--host <addr>]  serve the site from the store, rendering on request
pagedeck sync [--incremental]              sync every configured collection
pagedeck sync --watch                      sync again every --interval seconds, until stopped
pagedeck store pull [<url>]                fetch the store snapshot from <url>
pagedeck store push [<url>]                upload the store snapshot to <url>
pagedeck diff <from> <to>                  compare two manifests, in upload order
pagedeck rollback <build-id>               restore a retained build, in upload order

pagedeck sync

Runs every collection's loader against the site's store and reports what moved, one line per collection: how many entries changed, how many were deleted, and the cursor now stored against the collection.

With --incremental, each collection syncs from its own stored cursor instead of syncing everything. There is no --since <cursor> flag, because a run syncs every configured collection and each keeps its own cursor — there is no single cursor a caller could pass. A collection that has never been synced fails rather than quietly falling back to a full sync.

A failing collection does not stop the others. Every failure is reported at the end, and the exit code still says the run failed.

pagedeck sync --watch

Keeps syncing until you stop it, so that content edits reach a running pagedeck dev without either being restarted. It is its own process, run beside the dev server:

pagedeck dev
pagedeck sync --watch          # in a second terminal

Nothing passes between the two. The dev server opens the store on every request, so a store the watch rewrote is read by the next page load, and the edit is there on reload.

--interval <seconds> sets how long the watch rests between syncs; the default is 5. It has to be a whole number above zero — the rest is what keeps the watch off the store the dev server is reading from — and at most 2147483, which is the longest delay a JavaScript timer holds. A longer one is not refused by the platform but clamped to a millisecond, so it would turn the flag on its head and give you the rest-free loop that zero is refused to prevent. The rest is measured between syncs rather than on a schedule, so a sync that takes a while pushes the next one out instead of overlapping it: there is never more than one sync running, and no backlog builds up behind a slow one.

--watch composes with --incremental and does not imply it. pagedeck sync --watch runs full syncs; pagedeck sync --incremental --watch runs incremental ones, and each collection has to have been synced once before that works.

Every tick reports what it moved, exactly as a single run does. A tick that fails is reported the same way and the watch keeps going: a CMS that is down comes back up, and a watch that exited would take your dev session's content with it. --interval on a run with no --watch is refused rather than ignored.

pagedeck dev

Serves the site from the store, rendering each page on the request.

It runs until you stop it. Every other verb in this file is a run that finishes and reports what it did; this one is a process that stays up, and the command does not return until the server has stopped. It installs no signal handler, so Ctrl-C ends it the way it ends pagedeck sync --watch — the platform's default disposition, on a process that never promised an orderly shutdown.

It reads the store and never a content source, so run pagedeck sync first. The store is opened and closed per request rather than held open for the life of the server, which is what "edits appear on reload" means mechanically: a pagedeck sync --watch in another terminal writes rows, and the next page load takes its snapshot after that.

Once it is listening it prints the config it loaded and the origin it bound:

serving /site/pagedeck.config.ts at http://127.0.0.1:5173

--port <n> sets the port, and the default is 5173. It has to be a whole number between 0 and 65535, and 0 means "whichever port is free": the kernel picks one, and the line above reports whichever it gave. A port outside that range is refused here rather than left to the platform, which reports a range error naming neither the flag nor the verb.

--host <addr> sets the interface to bind, and the default binds 127.0.0.1, this machine only. Exposure has to be typed because a dev server authenticates nothing and serves from the project root: binding every interface hands anything on the network the site's unpublished content and the tree around it. That is a reasonable thing to do on request — a phone on the same LAN is the ordinary reason to want it — and an unreasonable thing to inherit, which is why it is a flag rather than a config field. A flag is typed once per run, by the person on the network in question, and it is not committed.

The address is whatever the platform will bind. Nothing checks its format and nothing resolves a name, because a parse that resolved one would put a DNS query in the command line. What is refused is a --host that named no interface while saying it did: no value, an empty one, or the next flag taken as the value. A bracketed IPv6 literal is unwrapped before it is bound, so --host [::] binds what --host :: binds — the announcement is what put the brackets there, and binding [::] as typed would resolve it as a name and find nothing.

A bind beyond loopback adds lines, and they answer different questions:

serving /site/pagedeck.config.ts at http://0.0.0.0:5173
  reachable on this network at http://192.168.1.24:5173
  this server authenticates nothing and serves from the project root

The first line always names the address the socket was given, which for a wildcard bind is not an address anyone can open. So a wildcard — 0.0.0.0 or :: — adds the reachable line, naming the first non-internal IPv4 address this machine has, in the order the platform lists its interfaces. It is a hint rather than a ranking: which of a LAN address, a VPN and a container bridge you want is a fact about your network the process cannot see. A machine with no such address prints no reachable line rather than failing.

The exposure line is printed for every non-loopback bind, wildcard or not. What is on the network is the same server whether it was bound on one interface or on all of them, and the operator who typed the flag is who it is addressed to. A specific address needs no reachable line, because the first line is already something to paste into another device.

Loopback asked for by name prints the single line a bare pagedeck dev prints: 127.0.0.1, any other 127. address, localhost and ::1 bind what the default binds, so they expose what the default exposes, and a warning that fired on the safe case is one a reader would learn to skip.

A flag this verb does not know, and a --port or --host given no value, are refused before the config is loaded, with exit 2.

pagedeck build

Renders every page of the route table, bundles the client entries for whatever hydrates, writes the site to the configured outDir, and reports the number of pages and files written.

pagedeck build reads the store and never a content source, so a build does not depend on your CMS being up. Run pagedeck sync first.

A site that declares build.preview also gets the preview app emitted under the path it named, and one line under the summary saying so. That line is a caveat rather than a statistic: the app authenticates nothing and renders any draft posted to it, so whether it is safe where it landed is a question about your deployment. A site that declares no build.preview emits no app and prints no line.

With --incremental, the build reads the manifest of the build already in outDir, works out which pages the store's changes since it affect, and reports that plan under the summary — one line saying how many pages the plan renders, reuses and removes. It then acts on it: only those pages are rendered, the rest are read back off the tree at outDir, the write covers what the run composed, and any file the previous build wrote that this build's manifest no longer names is deleted. The tree it leaves is the tree a full build of the same store leaves, byte for byte.

An incremental build makes no external link probe requests, even where the site declares build.links.external; only a full build checks external links. See Link checking.

A page it reuses is verified before it is carried: the document on the tree has to hash to what the previous manifest recorded, or the build refuses rather than publishing bytes it never read.

A site that declares build.search builds incrementally too, and its index is the index a full build writes. An adapter with a patch method is handed the previous index, the pages this run rendered and the pages that left, and rewrites only what moved — @pagedeck/search rewrites the locale directories whose pages moved and leaves the others. An adapter without one is handed every page, so the run renders every page, and the summary line says why. So does a run whose previous build holds no index from the adapter — the site has just declared build.search, or renamed its adapter — because there is nothing to patch.

It refuses rather than quietly building everything: an outDir with no manifest in it, a manifest another version of pagedeck wrote, a store whose newest position is behind the one that manifest recorded — a restored snapshot, or a different store — and a search index the tree no longer holds as the previous build wrote it. Each refusal names pagedeck build as the fix.

pagedeck store pull / push

Move the store file itself — a single portable SQLite file — between machines. <url> is a file: or an https: URL; an S3-style target is a presigned https: URL. Redirects are not followed, because a redirect can move the transfer off https:.

Presigned URLs carry their credential in the query string, so every message and success line about a snapshot has its target redacted down to scheme, host and path.

PAGEDECK_SNAPSHOT_URL

Redaction covers what pagedeck prints, and nothing else. A URL passed as <url> is still an argument of the process: on stock Linux /proc/<pid>/cmdline is readable by every user on the machine, so it is visible to anything else running on a shared CI runner, and most CI providers echo the run: line — arguments included — into the log before executing the step.

So pagedeck store pull and pagedeck store push take the target from PAGEDECK_SNAPSHOT_URL when the command line names none:

- run: pagedeck store pull
  env:
    PAGEDECK_SNAPSHOT_URL: ${{ secrets.SNAPSHOT_URL }}

/proc/<pid>/environ is readable only by the user the process runs as, and the echoed run: line holds no URL.

Do not expand the variable into the command line. pagedeck store pull "$PAGEDECK_SNAPSHOT_URL" is no safer than typing the URL: the shell expands it before pagedeck starts, so the value is in the process arguments and in the echoed line again. The protection comes from pagedeck reading the variable itself, which means giving it no <url> at all.

This reduces the exposure; it does not remove it. A workflow that echoes the variable, or a step that dumps its environment, puts the credential back in the log.

An empty or whitespace-only PAGEDECK_SNAPSHOT_URL counts as unset — a CI expression for a secret that does not exist expands to the empty string, and that must not be mistaken for a target.

<url> still works, and is the right form for a file: target or any URL carrying no credential. Passing both <url> and PAGEDECK_SNAPSHOT_URL is refused rather than resolved by precedence: two targets in one invocation have no defensible winner, and picking one silently could upload the store to the wrong place.

pagedeck diff

Compares two build manifests and prints the upload order: what to add, what to replace and what to prune. --grace-seconds <n> sets how long a pruned file stays reachable after the deploy.

--force

pagedeck diff refuses a build that was not based on the build it is deploying over. Every build records the id of the newest manifest its retention store held when it started, as build.parent, so the pair of manifests a deploy holds is enough to say whether they are a chain or a race:

pagedeck: Manifest diff: build "9e1f4a02" was built on "3c77b1de" and is being deployed over build "51ad900c", so another deploy wrote this site after this build read it — re-run pagedeck build so it is based on what is live, or pass --force to overwrite that deploy

That is two deploys running at once: another one finished after this build read the store, and uploading this diff would overwrite files it never looked at while pruning files it never saw. The refusal is exit 2 — the manifests are intact, and the same command line fails the same way until it is edited.

A build that records no parent at all is refused with its own sentence, because it is a different fact. It is not evidence of a race; it is the absence of the evidence that would rule one out:

pagedeck: Manifest diff: build "9e1f4a02" records no parent, so nothing in it says it was built on build "51ad900c" — a build records the newest manifest its retention store held when it started, and one that ran before the store existed records none; re-run pagedeck build so it records this base, or pass --force to deploy it anyway

A build diffed against itself is exempt. When both manifests are the same build the document is empty and the deploy writes nothing, so there is no ordering to protect. Refusing it would teach the reader that --force is what you pass to make pagedeck diff work — and then it would be passed on the deploy that is really racing. The check earns its place only while it fires rarely.

pagedeck rollback takes no --force, and that is the design rather than an omission: the flag exists to get past this refusal, and a rollback is out of order by definition.

pagedeck rollback

Prints the upload order that puts a retained build back: pagedeck diff with both manifests found rather than named.

pagedeck rollback 3c77b1de

The <build-id> is the build.id of the build to restore, and a retained document is named after it — pagedeck build leaves them in .pagedeck/manifests beside your config. Both sides of the comparison come from the site's own config: the build being replaced is the manifest.json in outDir, and the build being restored is read out of the store. What you type is the one thing only you know, which is which build.

The document is the one pagedeck diff writes, in the same order, so a pipeline that already deploys a diff deploys a rollback with no change. --grace-seconds <n> works the same way here.

A build id the store no longer holds is refused with the ids it does hold:

pagedeck: Retained manifest "3c77b1de": is not in the store at "/site/.pagedeck/manifests" — the retained builds are "51ad900c", "9e1f4a02", so roll back to one of those, or raise build.retention.keep before the build you want is pruned

How far back you can go is build.retention.keep, and the whole recipe is in Deploy serialization and rollback.

Exit codes

CI branches on the number, and the number comes from the class of failure, not from the wording.

  • 0 — the run finished.
  • 1 — the run did not finish. A loader threw, content failed its schema, a page would not render, a snapshot host refused. It is not a promise that a retry will help; it says the fault is not the site's wiring.
  • 2 — the site's own wiring is wrong. A missing or malformed config, an unregistered component, a collection declaring no schema, a command line the CLI does not understand. Retrying never fixes one of these.