SAPWOOD_

// 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.

sapwood cli · usb
$ 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 $ sapwood derive forge derived 'forge' under 'daybreak' npub1wm3fy3m02rkw98qtu7j6s4zvj5q49sx3u7tu6785u2fwva98v2wqxddum6 The same name always derives the same key. No secret left the device. $
01

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.

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.

02

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.

status
$ 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).

03

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.

identities
$ 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>.

derive
$ 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.

identities remove
$ 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.

04

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.

apps
$ 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.

05

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.

logs
$ 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
06

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.

firmware update
$ 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.
No signature, no update, on firmware that enforces signing. Without a usable signature the CLI warns you up front: signature-enforcing firmware will refuse the image, keep its current version, and say so. Release images from the official firmware bundle always carry their signature.
07

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.

--json
$ 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"
08

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
derive sends a name and receives an npub. The parent key, the child key and the derivation all stay on the chip.
09

WHEN IT SAYS NO

Errors go to stderr, prefixed sapwood:, and say what to do next. The ones you will actually meet:

MessageWhat 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.

10

REFERENCE

CommandDoes
sapwood portsList signer serial ports; --all includes every serial port.
sapwood deviceSigner status: firmware, board, uptime, identities, apps.
sapwood identitiesList 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 appsList connected apps and their permissions across identities.
sapwood apps revoke <slot>Revoke one connected app's slot.
sapwood logsStream 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.
OptionMeaning
--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.
--allports: include non-signer serial ports.
--yesidentities remove: skip the typed-name confirmation.
--jsonMachine-readable output, on every command.
-h, --help / --versionHelp 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.