plum-dev CLI
Last updated 2026-09-21
plum-dev is the developer CLI: it makes your publisher key, pairs with your box, packages and
signs a .plu, installs it, follows its logs, and uploads to the store. Node 18 or newer; no
runtime dependencies.
Install
npm i -g @plumbox/dev
plum-dev --version
Node 18 or newer. To run an unreleased change, build it from the repository instead:
git clone https://github.com/plum-networks/plum-sdk
cd plum-sdk/packages/cli
npm i
npm run build
npm link # puts `plum-dev` on your PATH
Configuration
| Path | Mode | What |
|---|---|---|
~/.config/plum-dev/credentials.json |
0600 | box URL, box token, kid, namespace, store URL, publisher token, emulator session |
~/.config/plum-dev/publisher.key |
0600 | your Ed25519 signing key (plum-key-v1 + base64url seed‖pub) |
~/.config/plum-dev/publisher.key.recovery |
0600 | the recovery key — move it offline |
~/.config/plum-dev/recovery.pub |
0644 | the recovery public key, to paste into the console |
credentials.json fields: box, token, kid, namespace, paired_at, store,
publisher_token, emulator {box, token, logged_in_at}.
Environment variables:
| Variable | Effect |
|---|---|
PLUM_DEV_HOME |
config directory (default ~/.config/plum-dev, or $XDG_CONFIG_HOME/plum-dev) |
PLUM_DEV_KEY |
path to the publisher key file |
PLUM_DEV_TARGET |
box or emulator — the default for --target |
PLUM_PUBLISHER_TOKEN |
store token for publish / testers |
PLUM_STORE_URL |
store base URL (default https://store.plum.im) |
PLUM_ESCROW_PASSPHRASE |
passphrase for escrow seal / escrow open (otherwise prompted) |
PLUM_DEV_EMULATOR_MODE |
docker or native — skips the Docker probe in emulator |
PLUM_DEV_CACHE |
downloaded emulator cores (default ~/.cache/plum-dev/emulator, or $XDG_CACHE_HOME) |
PLUM_DEV_DATA |
emulator data, log and pidfile (default ~/.local/share/plum-dev/emulator, or $XDG_DATA_HOME) |
.plumignore in the app directory lists paths to leave out of the bundle, one per line; #
comments and blank lines are skipped. node_modules, .git, META and any dot-file are always
skipped.
Commands
Keys and pairing
plum-dev keygen [--force] [--print-recovery]
Creates the signing key and the recovery key, prints both public keys and their kid
(pub-<16 hex>). An existing key is an error unless --force.
plum-dev pair <box-url> [--name N] [--namespace dev.me.] [--code 123456] [--insecure]
Asks the box to trust your key inside a namespace. The box shows a 6-digit code; type it in (or
pass --code). --name defaults to your username, --namespace to dev.<username>..
--insecure disables TLS verification (a box on your LAN with a self-signed certificate).
A box URL without a scheme gets https://.
plum-dev login --box <url> --token <plum_pat_…>
plum-dev login --publisher-token <plum_pub_…> [--store <url>]
Saves credentials without pairing. The box token needs the apps:install and apps:dev scopes.
The publisher token comes from the console (CLI tokens) and must start with plum_pub_.
plum-dev whoami
Prints the config directory, your public key and kid, the paired box and namespace, the emulator session, and the store URL with a truncated token.
Building
plum-dev init <name> [--template panel|server-go] [--id <app id>] [--dir <path>]
Scaffolds an app that already passes validate. Two templates:
panel—manifest.json(permissions: ["user:profile"]),index.htmlthat loads/apps/runtime/plum-sdk.js,.plumignore,README.md.server-go— the above plusserver/go.mod,server/main.go(a unix-socket HTTP service),server/dev.go(a//go:build devTCP variant forplum-dev serve --service), and an executablebuild.shthat runsCGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o ../svc .. Its manifest declaresserver.bin: "svc",healthPath: "/healthz"andlimits {memory: "128M", cpu: 50, pids: 32}.
--id defaults to your namespace plus a slug of the name.
plum-dev validate [dir|.plu] [--allow-host-arch]
Runs the box's manifest and bundle rules locally: id pattern, required fields, permission
vocabulary, entry/icon/server.bin present, the 32 KiB manifest cap, the 50 MiB bundle cap,
2000 files, 32 MiB per file, and the arm64 ELF check on server.bin. --allow-host-arch turns the
architecture error into a warning (for the emulator). For a .plu it also verifies the signature.
Exits 1 when there are errors.
plum-dev package [dir] [-o out.plu] [--rotation rotation.json] [--no-recovery] [--allow-host-arch]
plum-dev sign <in.plu> [-o out.plu]
plum-dev inspect <file.plu> [--json]
package writes a deterministic signed bundle (sorted entries, fixed timestamps) and prints its
size and sha256; the default name is <id>-<version>.plu. sign replaces the META/ block of an
existing bundle — with no -o it overwrites the input. inspect prints the publisher and recovery
keys, whether the signature verifies, and the covered file list.
plum-dev build [dir] [--template go|rust|zig|dockerfile] [--dockerfile <path>] [--no-docker]
Cross-builds the service to the path manifest.server.bin names, for the only architecture a box
runs: linux/arm64.
- Go (a
go.modin<dir>/serveror<dir>) —CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w'. No Docker. The scaffoldedbuild.shdoes the same thing; the command means you do not have to remember it. - Anything else —
docker buildx build --platform linux/arm64with a small generated Dockerfile (Cargo.toml→ rust on musl,build.zig→ zig). Your own Dockerfile is used instead when you pass--dockerfile <path>or keep aDockerfile.plumbeside the sources; its final stage must hold the binary at/svc. This is the one place in the CLI where Docker is genuinely required — without it the command says so and names the path where it expected the binary.
Whatever produced it, the result goes through the same gate validate applies and is refused
unless it is a static arm64 ELF — the message names what it found instead (the wrong ELF
machine, or the dynamic loader the binary asks for).
push runs the build for you when the manifest declares a server.bin that is not there yet;
--build forces a rebuild, --no-build skips it.
Your box
plum-dev push [dir|.plu] [--app-id <id>] [--logs] [--watch] [--build|--no-build] [--target box|emulator]
plum-dev logs <app-id> [-f|--follow] [--target …]
plum-dev status <app-id> [--target …]
plum-dev restart <app-id> [--target …]
plum-dev uninstall <app-id> [--target …]
push validates, signs in memory and installs on the paired box. --watch re-pushes on change
(it polls the directory once a second) and needs a directory, not a .plu. --logs follows the
service log afterwards, and says so when the manifest has no server. --target accepts box
(default), emulator or emu.
plum-dev serve [dir] [--port 4040] [--service http://127.0.0.1:8080]
Serves the panel at http://127.0.0.1:<port>/apps/<app-id>/ with the mock SDK injected, binding
loopback only. With --service it proxies /apps/<id>/svc/* to your locally running backend and
injects fake identity headers (X-Plum-User-Id: dev-user, X-Plum-Username: dev,
X-Plum-App-Id, X-Plum-Perms), stripping anything the page sent. No box involved.
Emulator
plum-dev emulator up [--native|--docker] [--core-version <x.y.z>] [--port 8080] [--pull]
plum-dev emulator down [--volumes]
plum-dev emulator logs [-f|--follow]
plum-dev emulator token
plum-dev emulator login [--token <plum_pat_…>] [--box <url>]
plum-dev emulator status # the default when no subcommand is given
Docker is optional. up uses Docker only when a daemon actually answers; otherwise it
downloads the prebuilt core for your platform and runs it as a plain child process. --native and
--docker force one. Every other subcommand follows the mode up recorded, so stopping the
emulator never waits on a daemon. See Emulator.
Store
plum-dev publish [dir|.plu] [--store <url>] [--token <plum_pub_…>] [--channel public|beta]
[--allow-host-arch] [--no-recovery] [--rotation rotation.json]
plum-dev testers <app-id> [list | add <box-serial> [--note …] | rm <box-serial>]
publish signs and uploads to POST /v1/publisher/versions; --channel beta skips human review
and reaches only your registered beta boxes. testers manages those boxes by serial.
Key loss and rotation
plum-dev rotate --app-id <id> (--old <old.key> | --recovery <recovery.key> --old-pub <ed25519:…>)
[--new <key>] [-o rotation.json]
plum-dev escrow seal [--recovery-key] [--passphrase <p>] [-o blob.txt]
plum-dev escrow open <blob-file> [--passphrase <p>] [-o key-file] [--force]
rotate writes a rotation record signed by the old key or by the recovery key; ship it in the next
release with plum-dev package --rotation rotation.json. escrow seal encrypts a key file with a
passphrase (scrypt + AES-256-GCM, minimum 8 characters) so you can paste the blob into the console;
the store cannot open it. See Signing and trust.
Exit codes
plum-dev exits 1 when validate finds errors and 1 when any command throws; everything else
exits 0. Errors print as plum-dev: box said <status> <code>: <message> for box errors,
usage: plum-dev <message> for usage errors, and plum-dev: <message> otherwise.
Flags the built-in help does not list
keygen --print-recovery, init --dir, login --publisher-token / --store,
escrow --passphrase / --force, rotate --new, emulator --container, and the signing flags
(--allow-host-arch, --no-recovery, --rotation) that push and publish also accept.