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.html that loads /apps/runtime/plum-sdk.js, .plumignore, README.md.
  • server-go — the above plus server/go.mod, server/main.go (a unix-socket HTTP service), server/dev.go (a //go:build dev TCP variant for plum-dev serve --service), and an executable build.sh that runs CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o ../svc .. Its manifest declares server.bin: "svc", healthPath: "/healthz" and limits {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.mod in <dir>/server or <dir>) — CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w'. No Docker. The scaffolded build.sh does the same thing; the command means you do not have to remember it.
  • Anything else — docker buildx build --platform linux/arm64 with 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 a Dockerfile.plum beside 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.