// the console at a shell prompt
The same signer, scripted.
The sapwood CLI is the management console as a command: the same frame protocol, the same cable, the same security model, with no browser in sight. It runs on Linux, macOS and Windows, starts in about 30 milliseconds, and speaks JSON when you ask it to. Everything the web console promises holds here too: secrets never cross the cable, and destructive operations answer only to the physical button.
INSTALL
The CLI ships in the Sapwood repository and builds in under a second.
You need Node 20 or newer and a git clone; the only native dependency
is serialport, which npm compiles or fetches for your
platform during install.
$ git clone https://github.com/forgesworn/sapwood
$ cd sapwood
$ npm install && npm run build:cli
dist-cli/sapwood.mjs 36.3kb
$ npm link # puts `sapwood` on PATH; or run node dist-cli/sapwood.mjs
$ sapwood --version
0.14.0
That is the whole install. The bundle is around 36 kilobytes,
cold-starts in about 30 milliseconds, and works the same on Linux,
macOS and Windows. --help and --version never
touch the serial port, so they work with nothing plugged in.
FIRST CONTACT
Plug the signer in and ask what the machine can see.
ports lists serial ports that look like a signer, filtered
to the USB bridges these boards actually use; --all lifts
the filter and shows every serial port. device is the
health check: firmware, board, uptime and what the signer holds.
$ sapwood ports
/dev/cu.usbmodem3301 (Espressif)
$ sapwood device
✓ HEARTWOOD v0.13.7 · heltec-v4
uptime 33m 12s · last reset software restart
identities 2 · connected apps 4
With one signer connected, every command finds it on its own. With
several, or an unusual adapter, pass --port /dev/ttyACM0
(or COM3 on Windows) and the guessing stops. Older
firmware that predates the version query still answers:
✓ HEARTWOOD connected (firmware predates the version query).
IDENTITIES
identities lists every key the signer holds: its slot, its
npub, whether it is a master or a persona derived from one, and how
many apps hang off it.
$ sapwood identities
SLOT NAME NPUB TYPE APPS
0 daybreak npub16yza…3tcu master 3
1 workshop npub194r0…j8yh master 1
Derive a new identity on-device
Give derive a name and the signer derives a child key from
a master it already holds, on its own chip. The name is the derivation path: the same name always
produces the same key, and nothing secret ever crosses the cable. With
one master the parent is implied; with more, pick one with
--parent <slot>.
$ sapwood derive forge --parent 0
✓ derived 'forge' under 'daybreak'
npub1wm3fy3m02rkw98qtu7j6s4zvj5q49sx3u7tu6785u2fwva98v2wqxddum6
The same name always derives the same key. No secret left the device.
Remove an identity
Removal is deliberate. The CLI shows what is about to happen, then asks you to type the identity's name; anything else aborts with nothing sent. The signer journals the removal, reboots and renumbers the remaining slots to close the gap. The key itself is not destroyed knowledge: it remains derivable from its parent or its phrase.
$ sapwood identities remove 1
Removing 'workshop' (slot 1)
npub194r00xagm7fspd7eydgpsstdluzfme6eww5wrut508h7lqe5qmdqhsj8yh
1 connected app(s) will stop working.
The key itself remains derivable from its parent or phrase.
Type the identity's name to confirm: workshop
✓ removed 'workshop' (slot 1)
The signer is rebooting to reload its state. Remaining identities renumber to close the gap.
--yes skips the typed confirmation for scripts that have
already done their own asking. Use it the way you would use
rm -f.
APPS
apps lists every connected app across every identity: how
many methods it may call, which event kinds it may sign
(all means no kind restriction, a list or count means an
exact policy), whether it signs automatically, and whether the app has
actually paired yet.
$ sapwood apps
IDENTITY SLOT APP METHODS KINDS AUTO PAIRED
daybreak 0 gossip 6 all yes yes
daybreak 1 coracle 4 6 yes yes
daybreak 2 amethyst 2 1,7 no no
workshop 0 obsidian 6 all yes yes
$ sapwood apps revoke 2 --identity 0
✓ revoked app slot 2 on 'daybreak'
apps revoke cuts a single app's access immediately; the
identity and every other app are untouched. With one identity on the
signer, --identity is optional. Creating connections and
editing kind policies remain jobs for the
web console, where the QR and the
kind picker live.
LOGS
logs streams the device's own log until you stop it. This
is the firmware speaking, live: WiFi coming up, requests arriving,
policy decisions as they happen. Pipe it through your usual tools;
that is what it is for.
$ sapwood logs
Streaming the device log. Ctrl-C to stop.
I (184201) heartwood: wifi up: HOMENET, ip 192.168.1.44
I (198122) heartwood: relay: request from 9f21a3c1 (gossip)
sign_event signed: short text note (1) for gossip - gm from the workshop
sign_event denied: contact list (3) for amethyst
I (231008) heartwood: policy: kind 3 not allowed for slot 2
^C
$ sapwood logs | grep denied # or --json for one object per line
FIRMWARE
firmware update pushes a release image over USB. A
matching .sig file beside the image is picked up
automatically; --signature points at one elsewhere. The
device shows the update size on its screen and waits: the update only
begins when you hold its button for two seconds. The image's SHA-256
and its ed25519 release signature are verified on the device before a
byte is committed, and it rolls back on its own if anything is wrong.
$ sapwood firmware update heartwood-v4.bin
Waiting for approval. Hold the button on the device for two seconds.
Uploading 100% (409600/409600 bytes)
Verifying on the device.
✓ Firmware verified. The device is rebooting into it.
SCRIPTING WITH --JSON
Every command accepts --json and prints machine-readable
output on stdout, with progress and prompts kept on stderr where they
belong. Exit codes are honest: 0 for success, 1 for a runtime failure,
2 for a usage error. Cron jobs, monitoring and shell pipelines all
compose naturally.
$ sapwood device --json
{
"firmware": {
"version": "0.13.7",
"board": "heltec-v4",
"uptime_s": 2006,
"last_reset": "software restart"
},
"identities": 2,
"personas": 0,
"apps": 4
}
$ sapwood apps --json | jq -r '.[].apps[].label'
gossip
coracle
amethyst
$ sapwood device --json | jq -e '.firmware.version' >/dev/null \
|| echo "signer wants a firmware update"
THE SAME SECURITY MODEL
- No secrets on this side
- The cable carries public keys, policy metadata, log lines and firmware chunks. Master secrets never leave the ESP32; the CLI has no command that could ask for one, and the firmware has no frame that would answer.
- The button still outranks you
- A firmware update begins only after a two-second hold on the device's physical button. Root shell, no root shell: software asks, your thumb decides.
- Deliberate friction where it counts
- Removing an identity demands its name typed back, spelled exactly.
A script can pass
--yes, and owns that choice. - Derivation without disclosure
derivesends a name and receives an npub. The parent key, the child key and the derivation all stay on the chip.
WHEN IT SAYS NO
Errors go to stderr, prefixed sapwood:, and say what to do
next. The ones you will actually meet:
| Message | What it means |
|---|---|
no signer found |
Nothing on the cable looked like a signer. Plug it in, or pass
--port; sapwood ports --all lists every
serial port the machine has. |
several possible signers found |
More than one candidate port. The message lists them; pick one
with --port <path>. |
No response from the device. |
The port opened but nothing answered. Check the board runs Heartwood firmware and that no other program holds the port, including a Sapwood browser tab. |
Device disconnected |
The cable dropped mid-conversation. Reconnect and rerun; every read command is safe to repeat. |
names do not match; nothing removed |
The typed confirmation did not match the identity's name. The signer was never contacted. |
The device declined the update. Did you hold its button to approve? |
The firmware update's 60-second approval window passed without a two-second button hold. |
One port, one program. The signer serialises its conversations, so close the web console's USB connection before pointing the CLI at the same cable, and the other way round.
REFERENCE
| Command | Does |
|---|---|
sapwood ports | List signer serial ports; --all includes every serial port. |
sapwood device | Signer status: firmware, board, uptime, identities, apps. |
sapwood identities | List identities on the signer. |
sapwood identities remove <slot> | Remove an identity, behind a typed-name confirmation. The signer reboots and remaining slots renumber. |
sapwood derive <name> | Derive a new identity on-device from a master. Deterministic; no secret crosses the cable. |
sapwood apps | List connected apps and their permissions across identities. |
sapwood apps revoke <slot> | Revoke one connected app's slot. |
sapwood logs | Stream the device log until Ctrl-C. |
sapwood firmware update <file.bin> | Update firmware over USB. Button-approved, hash- and signature-verified; rolls back on failure. |
| Option | Meaning |
|---|---|
--port <path> | Serial port; auto-detected when one signer is present. |
--identity <slot> | Identity slot for app commands. Default: the only master. |
--parent <slot> | Parent identity slot for derive. Default: the only master. |
--signature <path> | ed25519 release signature for a firmware update; <file.bin>.sig is picked up automatically. |
--timeout <ms> | Round-trip timeout. Default 10000. |
--baud <rate> | Serial baud rate. Default 115200. |
--all | ports: include non-signer serial ports. |
--yes | identities remove: skip the typed-name confirmation. |
--json | Machine-readable output, on every command. |
-h, --help / --version | Help and version; neither touches the serial port. |
Exit codes: 0 success, 1 runtime failure, 2 usage error. JSON and tables on stdout; prompts, progress and errors on stderr.