@decocms/vtex-cms
v0.1.1
Published
CLI for VTEX Content Platform (the CMS behind FastStore v4): pull, diff and push entries as branch commits, with dry-run by default, backups, and a read-back from the data plane.
Downloads
337
Readme
vtex-cms
Read and write VTEX Content Platform entries from the terminal — the CMS behind FastStore v4.
(Not VTEX's legacy Portal CMS, and not the vtex cms:sync toolbelt plugin.)
A migration used to end here: the code ships, and someone retypes the merchant's pages into the
Admin. vtex-cms closes that last mile, and it does it on the platform's own safety model
rather than a bespoke one. It started life as parity cms, which still works and runs this same
command tree.
Setup
npm i -g @decocms/vtex-cms # no VTEX toolbelt needed — two runtime deps, chalk and commander
vtex-cms login <account> # SSO in the browser, then remembers the account and its store idlogin is VTEX ID's own toolbelt login flow, done here: a loopback server on 127.0.0.1, the
Admin login page in the browser, a one-time token back (see src/auth.ts). It keeps the refresh
token the flow returns, so when the session expires (about a day) the next command renews it
silently — a browser is needed once, not every morning. Sessions are stored per account in
~/.config/vtex-cms/sessions.json (mode 0600).
After signing in, login asks the account for its stores (the list the Admin's store
picker reads; entries are the fallback). With more than one, it lists them and asks for
--store <id> rather than guessing: a wrong store id answers 200 [], not an error. Account and store are saved to
~/.config/vtex-cms/config.json.
Environment variables override what login saved, for CI or a second account:
| Variable | |
| --- | --- |
| VTEX_CMS_ACCOUNT | the VTEX account |
| VTEX_CMS_STORE | the store id inside it — not the account |
| VTEX_CMS_TOKEN | a token to use as-is, skipping sessions (CI has no browser) |
The old PARITY_CMS_* names are still read, after the VTEX_CMS_* ones. Without a token env, the
credential is the session login saved for the account — or, if you already use the vtex
toolbelt, its session in ~/.config/configstore/vtex.json (read, never written; it cannot be
renewed from here).
When it is not logged in
A session expires in about a day — renewed automatically when login stored a refresh token,
not when the only session is the toolbelt's. Being logged into the wrong account answers 401
exactly like an expired one. Every command tells you which of those it is before making a
request, because a raw 401 sends people looking in the wrong place:
$ vtex-cms ls --branches
Not logged into VTEX.
vtex-cms login mystore
(opens a browser for SSO — a human has to complete it)Logged into "acme", but this run targets "mystore". Wrong-account requests answer
401, which looks like a broken token.
vtex-cms login mystoreAn agent cannot log in on its own — login needs a human in a browser, once. Surface the
message and let the human run it; after that, expiry is handled without them.
vtex-cms doctor prints who you are and how long the session has left:
✓ [email protected] on mystore · expires in 23hThe model: content is git
Saving is a commit on a branch, carrying the baseHash the content was read at:
POST .../branches/<branchId>/commits { data, baseHash, entryId, contentTypeId, ... }That single fact is what makes automating this safe. A concurrent edit makes the commit fail
instead of winning. main is never the default. Rollback is one call. The same endpoint creates
and updates, so a brand-new route is a commit with an entry id nobody has used yet.
Commands
| Command | What it does |
|---|---|
| vtex-cms login <account> | SSO in the browser (no toolbelt), plus the store id — saved, and the session renews itself |
| vtex-cms whoami | Account, store and session the next command will use, and where each came from. No request; exit 1 if a human has to log in |
| vtex-cms export --branch <id> | Every entry of the store to a directory, one pull-shaped file each — the snapshot to take before writing anything |
| vtex-cms import <dir> --branch <id> | push every file in a directory, skipping unchanged ones — the way back from export |
| vtex-cms branch ls\|create\|rm\|validate | Branches, with the checks the Admin applies before it calls |
| vtex-cms open --entry <id> | The Admin URL for an entry (opened in a browser at a terminal) |
| vtex-cms rm --entry <id> [--branch <id>] | Delete — through review with --branch, everywhere now without |
| vtex-cms stores | The account's stores, marking the one in use |
| vtex-cms status --branch <id> | What merging the branch would change — new, changed, marked for deletion |
| vtex-cms log --entry <id> | The entry's version history on the platform; ids feed pull --version-id |
| vtex-cms locales | The store's locales, default and fallbacks — the keys of every per-locale field |
| vtex-cms schema ls | Published schema versions, with the one the store resolves to marked |
| vtex-cms doctor | Sections the repo declares vs. sections published on the account |
| vtex-cms schema push | Upload cms/faststore/schema.json to the registry as a new version |
| vtex-cms ls | Entries of the store in use — --name, --since YYYY-MM-DD, --branch, --content-type, --all-stores |
| vtex-cms pull | One entry to a local JSON file |
| vtex-cms diff | A pulled file against the branch it came from |
| vtex-cms push | Commit it back — dry run unless --yes |
| vtex-cms restore | Re-commit a backup file against the entry's current head — the real rollback |
| vtex-cms undo | Deprecated. Deletes via the platform's undo endpoint — not a revert. Use rm or restore |
doctor — run this first
A section only renders after its schema is uploaded. A repo can be a release ahead: the component exists in code, the account has never seen it. Committing content that uses it succeeds and renders nothing — the worst failure available, because it is silent.
$ vtex-cms doctor --repo ../electrolux-poc
✗ landingPage: RichText — in the repo, not on the account. Upload the schema (`faststore cms-sync`) or it renders nothing.Exits 1 when the repo is ahead. push runs the same check and refuses.
It also compares one level down: an existing component that gained a prop is invisible to the
section-key check above, because the key list didn't change. Both sides already have the full
shape on hand — the repo's cms/faststore/components/cms_component__*.jsonc, the account's
anyOf items on the published content type — so doctor diffs properties too:
✗ landingPage: BannerFull — property overlay in the repo, not on the account. Upload the schema or it renders nothing.
! landingPage: ElectroluxServices — variant now required locally but optional on the account. Entries authored before this change may not have it.The first line is the same failure as a missing section, just one level deeper, and fails doctor.
The second is the opposite direction — content already on the account was authored assuming the
prop was optional — and is a warning, not a failure: it does not change the exit code.
It also checks that the vtex content plugin itself loads — an environment problem, not an account
one, that otherwise looks like a corrupt CLI install:
$ vtex content generate-schema cms/faststore/components cms/faststore/pages -o cms/faststore/schema.json
Error: Cannot find module 'vtex'That happens when the oclif plugin tree (~/.local/share/vtex/node_modules/@vtex/cli-plugin-content)
and the CLI itself (wherever nvm put the global install) live in different roots — the plugin's own
require('vtex') cannot see it. doctor prints the fix rather than let this surface mid-migration:
! `vtex content` cannot load — the content plugin resolves `vtex` from the wrong node_modules root (breaks under nvm-managed installs).
ln -sfn "$(npm root -g)/vtex" ~/.local/share/vtex/node_modules/vtexThis warning does not affect doctor's exit code — it is a local-environment note, not a finding
about the account.
It also validates VTEX_CMS_STORE against what the account actually uses. There is no error for
a wrong store id — schema and entry lookups both answer 200 with empty results, which reads as "no
content yet" — so doctor cross-references every entry's own storeId (an account-wide lookup,
no store in the path) and fails loudly on a mismatch:
✗ The store is "faststore", but entries on this account use "electrolux". A wrong store id
answers 200 with empty results instead of an error.And it prints the published schema's $id, which is the only place a version shows up — there is
no endpoint that lists registry versions, so this is a starting point for the next --version to
vtex content upload-schema, not a full history.
A full round trip
vtex-cms ls --branches
# 4e3779e5-4065-423d-939f-aaa8675e83cb test
vtex-cms pull --content-type home --entry 50434e7b-… --branch 4e3779e5-… --out home.json
# ✓ home.json
# HeroSwiper (slides=12)
# CategoryBlocks (categories=8)
# …
# edit home.json, then:
vtex-cms diff --file home.json
# remote → local (what push would write)
# HeroSwiper (slides=12) → HeroSwiper (slides=13)
vtex-cms push --file home.json # dry run
vtex-cms push --file home.json --yes # commits, prints a restore hint and a backup path
# if it went wrong:
vtex-cms restore --file vtex-cms-output/backups/50434e7b-…-<hash>.json --branch 4e3779e5-… --yesThe pulled file carries entryId, contentType, branchId and baseHash, so push and diff
need no flags beyond the file.
Guardrails
They live in the command, not in the caller. This is meant to be driven by an agent, and an agent
that has to remember --dry-run eventually will not.
- Dry run by default. Writes only with
--yes. mainrefused without--allow-main.- Branch names are refused. Only ids address a branch — see below.
- Stale
baseHashrefused. Pull again rather than clobber someone's edit. - Unpublished sections refused, with the
doctormessage. - The remote is backed up to
vtex-cms-output/backups/<entryId>-<hash>.jsonbefore writing —pushbefore committing,undobefore deleting.
Rollback from the platform's own history
Local backups only exist for writes made from this machine. The platform keeps every version:
vtex-cms log --entry <id> # versionIds, newest first
vtex-cms pull --entry <id> --content-type <ct> --branch <id> --version-id <v> --out old.json
vtex-cms diff --file old.json # what reverting would change
vtex-cms push --file old.json --yes # commit it, through every checkThe file is the current head with that version's data, not the raw version. Read live, a
past version carries no baseHash, no searchKeywords and no identifierKeys; committing it as-is
would drop the entry's search keywords — where its route comes from — and the platform accepts that
without complaint. Keeping the head's metadata makes the revert an ordinary edit.
Handing a branch over
vtex-cms status --branch <id> # what merging it would change
vtex-cms branch validate <id> # the Admin's pre-publish check
vtex-cms open --branch <id> --print # where the human publishes itstatus refuses to say "nothing to merge" when the branch view came back with no entries at all —
that is a wrong store or an empty one, not a clean branch.
export — snapshot before the first write
push backs up the one entry it is about to overwrite. A migration overwrites many, and the only
record of what they said before is whatever someone thought to pull first. export pulls all of
them:
vtex-cms export --branch main # → vtex-cms-output/export/main-<timestamp>/Each file is exactly what pull writes, so any one of them is a restore --file input. The
listing is account-wide, so entries of other stores are skipped; if that leaves nothing, it fails
and names the stores it did see — an empty export is almost always a wrong store id, not an empty
store. One entry failing does not stop the rest, but it does make the exit code 1, and
index.json says which.
Branches, and why the checks are client-side
The branch calls (branch create, branch rm, rm --branch) are the ones the Admin's own
Content Platform UI sends, same paths and same bodies. What that UI also does, and the API does
not, is decide whether a call makes sense: the platform accepts it and leaves whatever state
results for the next merge to find. So every rule the Admin applies before calling is applied here
too, and every case it would not offer is refused rather than sent:
branch create: name non-empty, notmain, not already used; description at most 120 characters. Always a content branch (isDev: false) — dev branches pin a schema version and are the Admin's to manage.branch rm: nevermain, and the dry run points atexportfirst — it drops every unmerged change.rm --branch: reads the entry's versions first, as the Admin does, and refuses when there is no version onmain(nothing to delete from main), when it is already marked, or when the branch has unmerged changes to it (discard those first — a state the Admin never produces).branch validateprints the Admin's pre-publish check as it comes back.
Merging is not here, on purpose: publishing to the live store is the step all of this exists to
leave with a human. vtex-cms open --branch <id> gets them to it.
import
A loop over push, not a second write path: each file gets the stale check, the render check, the
backup and the data-plane read-back that a single push gets. What it adds is skipping files whose
content already matches the branch, and one summary line. An entry that moved since the file was
read fails as stale — restore --file is how to put that one back regardless.
Rollback: restore, not undo
vtex-cms undo calls the platform's own undo endpoint, and on an entry with a single version —
every entry create makes — dropping "the changes" and destroying the entry are the same operation.
It requires --allow-delete on top of --allow-main for exactly that reason, and it is a delete,
not a revert: expect it to remove the entry from vtex-cms ls entirely.
The actual rollback is vtex-cms restore --file <backup>, which re-commits a backup written by
push or undo against the entry's current head — a normal, additive commit, so it works even
if the entry moved on since the backup was taken.
Three things that cost real time to find out
commitType is only for restores. Sending "update" on a normal save answers 500 from the
INSERT into the commits table — it looks like an outage and is a bad request. The client never
sends the field.
A branch name is not an address. The Admin's own URL /branches/test/... redirects to a
different branch. Only uuids resolve; main is the one name that works. push refuses anything
else rather than write somewhere surprising.
The authoring shape is not the delivery shape. /data/... (what the storefront reads) hands out
flat arrays and plain values. Authoring wraps collections as { $fnType, values: { "<id>": … } }
and every leaf as a per-locale switch:
{ "$fnType": "switch", "varyByKeys": ["locale"], "cases": null,
"defaultCase": "https://…png", "configurationSourceType": "contexts" }Pulling from delivery and committing that back does not work. pull always speaks authoring.
Committing the {values} dict silently renders nothing, on an entry create made. The
data-plane parser the storefront reads does Array.isArray(rawSections) ? … : [] — an entry that
predates the CLI happens to already be "list" shape there and keeps working either way, but an
entry create produced is not, so push used to commit fine, read back byte-identical, and render
zero sections. push/create now convert sections (and any collection nested inside a section)
to a positional array before committing — cms/authoring.ts's toDataPlaneShape. The one confirmed
exception is globalSections, which already stores collections as {$fnType, values} and the data
plane converts that itself; it is excluded by content type in src/commands.ts.
push now verifies its own work. After a successful commit to main, it reads the entry back
from the delivery plane — the one the storefront actually renders from — and fails if the content
did not land:
✗ /cuida came back from the data plane with sections as object, not an array — the storefront will
render it as zero sections (#349)
The commit succeeded — this content is live and renders as nothing. Restore with:
vtex-cms restore --file vtex-cms-output/backups/… --branch main --allow-main --yesThe route is GET https://{account}.vtexcommercestable.com.br/api/content-platform/data/{account}/{store}/{contentType}/entries/slug/{slug}
— a third host, unauthenticated (it serves the public storefront), confirmed from @vtex/client-cp's
own constants.js and by a live request.
Three cases are reported as not verified rather than failed, because a check that could not run
must not masquerade as a verdict: a branch other than main (the data plane only serves published
content, so the entry is supposed to be absent), an entry with no slug to resolve by (create
makes those deliberately), and a failed request.
Note that neither curl on the page nor /api/preview substitutes for this: FastStore mounts
sections lazily on scroll, so the HTML looks empty either way, and previewEntryById reads the
control plane, which always returns the {values} shape — it shows sections: [] even when the
published page is fine.
Driving it from a migration
The orchestrator does not run these commands — it dispatches cms-writer, one entry per call.
That keeps the CMS procedure out of the main skill's context, and it puts the stop conditions
(login, stale hash, unpublished section) in an agent whose whole job is to report them rather than
work around them. See agents/cms-writer.md.
Creating a page
vtex-cms create --content-type landingPage --slug /cuida/preguntas-frecuentes \
--name "Preguntas Frecuentes" --branch <id> --yesThat makes the route and nothing else — no sections. pull then finds it and the normal edit loop
applies. The split is deliberate: adding a page to a live store is irreversible in a way that
editing one is not, so it is its own command with its own dry run.
Why it works by copying
The Content Platform has no create-entry endpoint. Duplicating an existing entry is the only
call that brings one into existence, and the Admin's own create screen is a Next.js form that
crashes before it can save (React #185, a render loop) — so this is not a workaround for a broken
UI, it is the entire API. The probes that look like they should work all answer 400 Missing account
name in URL parameters, which on this API is a routing miss dressed as a bad request:
POST …/entries POST …/{contentType}/entries PUT …/entries/{id}What does exist:
POST …/{account}/{store}/entries/{id}/duplicate → 200, empty body, copy named "<source> - Copy"
PUT …/{account}/{store}/entries/{id}/rename → the Admin listing name
DELETE …/{account}/{store}/entries/{id} → destroys it, every version, every branchThree consequences the command has to absorb, and you should know about:
- The copy is born on
main, whatever--branchsays. Only its content is branched. There is no branch in the duplicate URL and no header carrying one. - The duplicate answers with an empty body, so the new id is found by diffing the entry list before and after. If that diff is not exactly one new entry the command stops rather than guess.
- It will only copy an entry with no slug. A copy inherits the source's slug on
main, so duplicating a live page would put a second entry on that same route — andcreatecannot undo that, because its own commit lands on the branch. Leave one routeless entry of each type around for this to copy from; the platform's default new-page entry (slug: "", a placeholderBannerText) is exactly that, and the first commit overwrites its content anyway. - A store with zero entries of the type cannot bootstrap one — there is nothing to copy. That first entry has to come from somewhere else.
Anything that fails after the copy exists deletes it again, because a half-made page on main is
worse than a failed command.
The new page's data is derived from the copy, not synthesized (#400)
create used to build data from scratch: every leaf wrapped in a locale switch, and an seo with
exactly slug/title/description. That is the convention cms/authoring.ts documents — and it is
not universal. On an account whose entries store plain scalars, the commit was rejected with a
bare VALIDATION_ERROR:
| | the account's entries | what create sent |
|---|---|---|
| slug | "" | {"$fnType":"switch","defaultCase":"/x",…} |
| seo | {slug, title, canonical, description, $componentTitle} | {slug, title, description} |
So create now starts from the entry it copied — by definition a valid document for that content
type — and changes only the route: slug, seo.slug, seo.title, and empties sections. Leaves
keep whatever envelope the source used, so an account that does use locale switches still gets
them. Fields like canonical and $componentTitle survive instead of being dropped.
Two things this is deliberately not doing:
- It does not send a
schemaBinding. #400 read the copyable draft'sschemaBinding: nullas the cause, which is a convincing lead — an Admin-authored page on that account reads{schemaId: "…@1.11.0", …}. It is not the cause. A create with no binding succeeds, and the platform then writes the published version (@1.12.0) onto the new entry itself. - It does not apply
toDataPlaneShape. That conversion is forpush, where real content's rendering depends on it (#349). Here the collection is empty, so it only turns a valid{values:{}}into a[].
When a commit is rejected anyway, the rejected body is written to
vtex-cms-output/backups/<entryId>-rejected.json and the path named in the error. A
VALIDATION_ERROR carries no field, no path and no offending value, so the payload is the only
evidence there is — and it is otherwise invisible from outside the process. That dump is what
diagnosed this one.
author has to be an email
The commit endpoint rejects any author that is not an email address, and it does so as
400 VALIDATION_ERROR "Invalid request data" — naming no field. That reads like a malformed data
payload and sends you auditing the content, which is why push and create default the author to
the logged-in VTEX user and refuse a non-email --author before going near the network.
RichText next to another section can answer VALIDATION_ERROR
On at least one account, a commit whose sections are RichText plus anything else — one sibling or
five — rejects with 400 VALIDATION_ERROR "Data is not valid, please check your input.", naming no
field. RichText alone commits fine; the same siblings without RichText commit fine. The published
schema shows no constraint that would explain it, and the root cause is still unknown on the platform
side — see #348 for the bisect.
push cannot fix the platform's validator, but it does two things instead of leaving the error
opaque:
- Prints a preflight warning when
sectionsmixesRichTextwith another component, before committing — a known risk, not a certain failure, so it does not block the push. - If the commit does answer
VALIDATION_ERROR, appends a note pointing at this exact pattern as the one cause on record, since the API's own error body gives nothing to go on.
schema push — the registry lives on a different host
vtex content upload-schema is the interactive CLI for this, and under a non-TTY it silently takes
its defaults and exits Done having uploaded nothing — the exact failure mode doctor exists to
prevent, since doctor can name the missing schema but not fix it. vtex-cms schema push closes
that gap by talking to the same registry directly:
vtex content generate-schema cms/faststore/components cms/faststore/pages -o cms/faststore/schema.json
vtex-cms schema push --repo . # dry run: prints the target version and the drift
vtex-cms schema push --repo . --yes # publishesThree things worth knowing:
- The registry is not
{account}.myvtex.com. Every other call in this file hits that host; the schema registry ishttps://api.vtexcommercestable.com.br/api/content-platform/schemas, a fact confirmed by reading@vtex/cli-plugin-content's ownregistry.ts— the literal module behindvtex content upload-schema— not by probing. - The version defaults to the next minor bump, or
1.0.0if nothing is published yet — same rule the interactive CLI suggests.--versionoverrides it. - The local
schema.jsonis never deleted. The interactive CLI's last prompt does, defaulting to yes, which is a footgun the moment the upload needs to be re-run or diffed later.
Not here on purpose
Merging to main. The API supports it; this CLI does not expose it. Promoting content to the
live site stays a human action in the Admin, where the diff is reviewable by whoever owns the
content.
Media
Content migrated from another site keeps that site's image URLs. They work on the day of the migration and stop working the day that host rotates a key or a signed URL expires — a recipe page shipped with Azure blob URLs carrying a SAS token, and every image on it went 403 once the key rotated. Nobody notices until someone opens the page.
vtex-cms media scan --file <pulled.json> # what is off-account, and what it answers now
vtex-cms media rehost --file <pulled.json> --yes # move it here, then diff + pushrehost downloads each off-account image, puts it on this account, registers it in the Admin's
media gallery so it is selectable there like any other asset, and rewrites the pulled file. An
image the source no longer serves is reported and left at its original URL — a half-rewritten entry
is worse than one that still points at a dead URL and says so. push prints the same warning
whenever it sees an off-account image, whatever produced the JSON.
An earlier version of this document claimed there is no API for the Media Gallery, on the strength of seven endpoints that all answered 400/404. That was wrong: the probe was looking for one endpoint, and there are two.
Uploading is two calls, and they do not share an auth scheme.
uploadFileon[email protected]stores the bytes and returns a permanent public…vtexassets.com/assets/vtex.file-manager-graphql/images/<uuid>___<hash>.<ext>.createContenton[email protected]registers that URL as a gallery item. Skip it and the file is reachable but invisible in the Admin's picker — the picker listscontents(filters: {builderId: "cms"})from this app, not the File Manager. This is the step the original probe never found.
Both go to the IO GraphQL gateway, https://{account}.myvtex.com/_v/private/graphql/v1?workspace=master,
routed with @context(provider: "<app>"). Step 1 is multipart per the GraphQL upload spec
(operations, map, the file under key 0).
Auth is the expensive part, and it is the opposite of the Content Platform REST API above,
which takes Authorization: Bearer and rejects the cookie:
| header | step 1 | step 2 |
|---|---|---|
| X-VTEX-API-AppKey + X-VTEX-API-AppToken | ok | 401 No admin or user token was provided |
| Authorization: Bearer <token> | 401 | 401 |
| VtexIdclientAutCookie: <token> (as a header) | 401 | 401 |
| Cookie: VtexIdclientAutCookie=<token> | ok | ok |
So src/media.ts sends the cookie plus the appKey pair when it is set, and lets each app take
what it understands. A 401 means the session expired and could not be renewed — vtex-cms login.
rehost checks the gallery before uploading — same filename, same byte count means the file is
already here and its URL is reused. A recipe listing and its 16 detail pages share the same photos,
so without that check one job would put every picture in the gallery 17 times. The lookup has one
trap: filters must travel as a typed $filters: ContentsFiltersInput variable. Inlining the
filter literal and passing just the name as $name: String! answers GraphQL validation failed,
which reads like a broken query and is really the wrong variable shape.
Deleting a gallery item, for when one slips through: deleteContent(input: {contentId}), and it
needs a selection set — DeleteContentPayload is an object, so a bare deleteContent(input: $i)
is another GraphQL validation failed.
The Admin itself reaches step 1 through a proxy (media-gallery-beta.admin.vtex.com/api/proxy/file-manager,
PUT with the raw bytes and the name in a filename header). Going straight to the gateway skips
the proxy and behaves the same.
