Skip to content

zcms CLI reference

zcms is how you start, build and sign an extension. It has four commands — init, keygen, pack and verify — plus help. It does not upload packages — see Publish a package.

Terminal window
npm install -g @zcmsorg/cli@latest

The package is @zcmsorg/cli; the command it installs is zcms. It requires Node.js 22 or newer.

It has no dependencies. The signing code is bundled into the one file, so the bytes that sign your packages are the ones the Z-CMS repository builds — not whatever the registry resolved on the day you installed it. That matters more than it usually would: this tool lives on the machine that holds the private key behind everything you publish.

zcms init [<dir>] [--kind theme|plugin] [--id <reverse.dns.id>] [--name <name>]
[--description <text>] [--author <name>] [--author-url <url>]
[--version <semver>] [--yes]
zcms keygen [--out <dir>]
zcms pack <dir> --kind theme|plugin --key <private.pem> --pub <public.pem>
[--bump patch|minor|major] [--no-bump] [--set-version <semver>]
[--operator-key <private.pem>] [--out <file>]
zcms verify [<file.zcms>] [--marketplace-key <public.pem>]
zcms help [--lang en|ja|vi]

Run zcms without a command to print this usage information.

zcms help prints the usage above; so do zcms with no command, zcms -h, zcms -help and zcms --help. The help text is available in English, Japanese and Vietnamese:

Terminal window
zcms help --lang vi # Tiếng Việt
zcms help --lang ja # 日本語 (--lang JP and --lang VN are accepted too)

Without --lang, the language is read from ZCMS_LANG, then from your shell locale (LANG), and otherwise falls back to English. Only the help text is translated — build output, checksums and error details are identical in every language.

Terminal window
zcms init

init asks whether you are building a plugin or a theme, then writes a project that already builds, typechecks, tests, packs, signs and passes the Marketplace scanner with nothing changed. It will not write into a directory that already holds anything.

Non-interactive, for scripts and CI:

Terminal window
zcms init ./hello --yes --kind plugin \
--id com.acme.plugin.hello \
--name "Hello" \
--author "Acme"

A plugin comes out as:

hello/
├── plugin.json # the manifest — identity, permissions, settings form
├── package.json
├── build.mjs # esbuild -> ONE CommonJS file
├── tsconfig.json
├── src/index.ts # filters, actions, jobs, setup
├── test/plugin.test.ts
├── README.md
└── .gitignore # ignores *.pem — see below

A theme is the same shape, with theme.json, src/index.tsx (Layout, templates, blocks) and src/theme.css.

Two contracts are enforced at runtime, on a live site, not by your build. Guessing wrong on either produces an extension that builds, tests, packs, signs and installs — and then fails in front of a user. init writes a project that already satisfies both.

The contract What happens when you break it
Plugin One CommonJS file. The sandbox is a V8 isolate that provides exactly one module, @zcmsorg/plugin-sdk. There is no module resolver in there. A plugin compiled across two source files emits a relative require(), which the sandbox refuses — at activation time, on somebody’s site, long after your tests passed. This is why build.mjs bundles: split your source across as many files as you like.
Theme Entry is ESM, and the file is .mjs. React is external. A dist/index.js takes its module format from the nearest package.json "type" — and package.json ships inside the package. Guess wrong and site-runtime throws “Cannot use import statement outside a module”, catches it, and silently falls back to the default theme. Bundle a second copy of React and you get “invalid hook call” in production and nowhere else.
Terminal window
cd hello
pnpm install
pnpm build # plugin -> dist/index.js theme -> dist/index.mjs + dist/theme.css
pnpm typecheck
pnpm test
Terminal window
zcms keygen --out ./keys

The command creates:

  • publisher-private.pem, readable only by its owner (0600)
  • publisher-public.pem, which may be registered in the Developer Portal

The CLI refuses to overwrite an existing key file — private or public. Overwriting a private key orphans every package it has ever signed; rewriting only the public half would leave you with a mismatched pair. Point --out at an empty directory.

publisher-private.pem is your identity. Anyone who has it can sign a package as you, and once it leaks every package you ever signed has to be treated as forgeable. Back it up somewhere a repository is not. The scaffold’s .gitignore already excludes *.pem, and the packer never puts key material inside a package (see below) — but neither control can help you if you paste it into a chat or a CI log.

The manifest sits at the root of the directory you pack:

  • plugin.json for --kind plugin
  • theme.json for --kind theme

It must contain id, name, version, author and engine. entry points at the built file and must exist before packing — dist/index.js for a plugin, dist/index.mjs for a theme (see the table above for why the theme’s extension is not optional).

A scaffolded project can be packed as it stands: zcms pack .. The packer keeps out of the package everything that is not part of what runs —

  • key material: *.pem, *.key, *.p12, *.pfx, *.keystore, id_*, and .npmrc
  • secrets and VCS: .env*, .git, node_modules
  • dev-only: src, test/, build scripts, tsconfig*.json, tool configs, source maps

The key-material rule is not tidiness. keygen writes your private key into the project directory, because that is where you run it, and pack is then pointed at that same directory — so without that rule the key that signs the package would ship inside it: uploaded to Marketplace, then unpacked onto every site that installs your extension. It is excluded silently and unconditionally.

The first argument is the path to the theme or plugin you are packingpack archives that one directory and signs it. Use . when your shell is already inside it, or point at it explicitly:

Terminal window
# a plugin, by path
zcms pack ./plugins/seo-toolkit --kind plugin \
--key ./keys/publisher-private.pem \
--pub ./keys/publisher-public.pem \
--out ./release/seo-toolkit-1.0.0.zcms
# a theme, by path
zcms pack ./themes/corporate --kind theme \
--key ./keys/publisher-private.pem \
--pub ./keys/publisher-public.pem \
--out ./release/corporate-1.0.0.zcms

--kind must match the manifest at that path (plugin.json--kind plugin, theme.json--kind theme). If --out is omitted, the output filename is <manifest.id>-<manifest.version>.zcms in the current directory. See Publish a package for the full sign-pack-submit workflow.

The command prints the package id, version, file size and checksum. This artifact has a valid publisher signature, but it is not installable until Marketplace has reviewed and co-signed it.

You do not hand-edit the version between releases. pack ships the version your manifest currently declares, then advances that version and writes it back to theme.json/plugin.json — and to package.json when it is present, so the two never drift. A freshly scaffolded 0.1.0 therefore packs as 0.1.0, and the manifest is left at 0.1.1, ready for the next pack:

Terminal window
zcms pack ./themes/corporate --kind theme \
--key ./keys/publisher-private.pem --pub ./keys/publisher-public.pem
# version : packed 1.0.0; theme.json advanced to 1.0.1 for the next pack

Three flags override the default:

Flag Effect
(none) ship the current version, then advance the patch number
--bump minor / --bump major advance the minor or major number instead of the patch
--set-version <semver> ship an exact version (still advances afterward unless --no-bump is also given)
--no-bump ship the current version and leave the manifest unchanged

The <manifest.id>-<manifest.version>.zcms output name uses the version that was shipped, not the advanced one. If a pack fails, the version is rolled back — a failed pack never leaves a half-applied bump behind.

Marketplace publishers can skip this. If you run your own Z-CMS instance and want to install an extension without going through Marketplace, add --operator-key:

Terminal window
zcms pack . --kind theme \
--key ./op-private.pem --pub ./op-public.pem \
--operator-key ./op-private.pem

This stamps a second, operator signature. An instance whose runtimes pin the matching OPERATOR_PUBLIC_KEY — and, for themes, set ALLOW_THEME_SIDELOAD=true — will run the package once an admin uploads it under Admin → Appearance → Install from file and approves it, with no Marketplace involved. Use the same operator key pair for --key/--pub. A package signed this way is meant for sideloading, not for Marketplace submission.

Terminal window
zcms verify ./release/example-plugin-1.0.0.zcms

Called with no file, zcms verify picks the newest .zcms in the current directory — convenient because the packed filename moves each time the version advances, so you rarely need to type it out.

Without --marketplace-key, verify checks the payload checksum and publisher signature. A failed verification exits with a non-zero status.

After downloading an approved package, verify both signatures with a trusted Marketplace public key:

Terminal window
zcms verify ./downloaded-package.zcms \
--marketplace-key ./marketplace-public.pem

Only this mode confirms that the package carries a valid Marketplace co-signature and is installable.

There is no zcms publish command. Upload the exact .zcms file you verified through Developer Portal → Submit a package, or send it as the file field in an authenticated multipart/form-data request to POST /developer/submissions.

See Publish a package for the complete review workflow.