feat: one-command install as a per-user service, release workflow (0.3.0) #3

Merged
dozken merged 10 commits from feat/install-as-service into main 2026-09-24 11:42:23 +02:00
Owner

Summary

This makes rkd-terminal a one-command install (RKD #1084, #968's open question 3), and releases 0.3.0.

curl -fsSL https://git.rkd.nanoya.biz/rkd/plugins/raw/branch/main/install.sh | sh
  • rkd-terminal install / uninstall register the plugin as a per-user background service: a launchd agent biz.nanoya.rkd-terminal on macOS, a systemd user unit on Linux.
    • Reinstalling upgrades in place (bootout + bootstrap, or restart).
    • The service gets the installing shell's absolute PATH, so presets such as claude in ~/.local/bin are found.
    • It refuses a binary in a temp or cargo build dir unless --force.
    • --no-start writes the file without starting anything.
    • status reports the service.
  • install.sh (POSIX sh):
    • Picks the release for this OS and arch, verifies the SHA-256 and fails closed.
    • Installs to ~/.local/bin, never with sudo, and refuses to run as root.
    • Registers the service, then prints the pairing step.
    • Everything runs from main(), so a truncated download does nothing.
    • scripts/test-install.sh runs 32 checks against a fake release; CI runs it under dash with shellcheck.
  • Release workflow on v* tags:
    • Builds static musl binaries for x86_64/aarch64 Linux (cargo-zigbuild, zig pinned by SHA-256) and aarch64 macOS on the Mac runner.
    • Attaches .tar.gz + .sha256 to a draft release, and publishes it only after every build is attached.
    • The tag must match the crate version.
  • Binaries are unsigned for now. Files fetched by curl carry no quarantine attribute, so macOS runs them without a Gatekeeper prompt. Intel Macs and Windows (#1085) are pointed at cargo install or unsupported.

Tests

  • cargo fmt --check, clippy -D warnings, cargo test --workspace (69 rkd-terminal lib tests).
  • The install.sh harness under macOS sh and dash, plus shellcheck.
  • 17 Rust and 12 install.sh hand-applied mutations, all caught.
  • Not tested locally: real launchctl bootstrap / systemctl --user calls (no background agent was registered on the dev Mac), and the release workflow itself: the zigbuild builds, the Mac runner, and whether the Actions token can write releases. If the first tag fails with 401/403, a RELEASE_TOKEN repo secret is needed.

https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV

## Summary This makes `rkd-terminal` a one-command install (RKD #1084, #968's open question 3), and releases 0.3.0. ```sh curl -fsSL https://git.rkd.nanoya.biz/rkd/plugins/raw/branch/main/install.sh | sh ``` - **`rkd-terminal install` / `uninstall`** register the plugin as a per-user background service: a launchd agent `biz.nanoya.rkd-terminal` on macOS, a systemd user unit on Linux. - Reinstalling upgrades in place (bootout + bootstrap, or restart). - The service gets the installing shell's absolute `PATH`, so presets such as `claude` in `~/.local/bin` are found. - It refuses a binary in a temp or cargo build dir unless `--force`. - `--no-start` writes the file without starting anything. - `status` reports the service. - **`install.sh`** (POSIX sh): - Picks the release for this OS and arch, verifies the SHA-256 and fails closed. - Installs to `~/.local/bin`, never with sudo, and refuses to run as root. - Registers the service, then prints the pairing step. - Everything runs from `main()`, so a truncated download does nothing. - `scripts/test-install.sh` runs 32 checks against a fake release; CI runs it under dash with shellcheck. - **Release workflow** on `v*` tags: - Builds static musl binaries for x86_64/aarch64 Linux (cargo-zigbuild, zig pinned by SHA-256) and aarch64 macOS on the Mac runner. - Attaches `.tar.gz` + `.sha256` to a draft release, and publishes it only after every build is attached. - The tag must match the crate version. - **Binaries are unsigned for now.** Files fetched by curl carry no quarantine attribute, so macOS runs them without a Gatekeeper prompt. Intel Macs and Windows (#1085) are pointed at `cargo install` or unsupported. ## Tests - `cargo fmt --check`, clippy `-D warnings`, `cargo test --workspace` (69 rkd-terminal lib tests). - The install.sh harness under macOS `sh` and dash, plus shellcheck. - 17 Rust and 12 install.sh hand-applied mutations, all caught. - **Not tested locally:** real `launchctl bootstrap` / `systemctl --user` calls (no background agent was registered on the dev Mac), and the release workflow itself: the zigbuild builds, the Mac runner, and whether the Actions token can write releases. If the first tag fails with 401/403, a `RELEASE_TOKEN` repo secret is needed. https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
`rkd-terminal install [--no-start] [--force]` writes a launchd agent
(~/Library/LaunchAgents/biz.nanoya.rkd-terminal.plist) or a systemd user
unit ($XDG_CONFIG_HOME/systemd/user/rkd-terminal.service) that runs this
binary's canonical path with `serve`, and loads it. Reinstalling boots
the old agent out first (retrying the bootstrap while launchd tears it
down) or restarts the unit, so an upgraded binary takes over at once.
`uninstall` stops it and removes the file; `status` says whether the
file exists.

A binary in a temp dir or a cargo build dir (target/{debug,release} or
any folder with cargo's .fingerprint) is refused unless --force. The
installing shell's absolute PATH entries go into the service so presets
resolve as they do by hand. Commands run as argv through an injectable
runner; the tests assert the exact launchctl/systemctl sequences.

Refs rkd#1084.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
`curl -fsSL .../install.sh | sh` picks the build for this OS and arch
(Linux x86_64/aarch64 musl, Apple-silicon macOS; anything else is
pointed at `cargo install`), downloads it and its .sha256 from the
latest release or RKD_TERMINAL_VERSION, fails closed on a mismatch or
without a SHA-256 tool, installs to ~/.local/bin (RKD_TERMINAL_BIN) by
rename, and runs `rkd-terminal install` unless RKD_TERMINAL_NO_SERVICE=1.
It refuses root and never uses sudo.

scripts/test-install.sh runs it against a fake release served through
file:// (RKD_TERMINAL_BASE_URL), with uname/id faked and a PATH of only
the tools it may use; CI runs it under dash with shellcheck required.

Refs rkd#1084.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
A tag builds static x86_64/aarch64 musl binaries with cargo-zigbuild
(zig pinned by version and SHA-256) and aarch64-apple-darwin on the
macos-arm64 runner, packages each as rkd-terminal-<tag>-<target>.tar.gz
plus .sha256 (scripts/package-release.sh) and attaches them to the
tag's Forgejo release through the API (scripts/release-upload.sh, which
replaces same-named assets on a re-run). No artifact hand-off: this
Forgejo rejects upload-artifact@v4.

The release is created as a draft and published only after every build
is attached, so releases/latest never points at a missing download. The
tag must match local/terminal's version. Token: the RELEASE_TOKEN repo
secret when present, else the automatic token.

Refs rkd#1084.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
chore: 0.3.0, and document the one-line install
Some checks failed
CI / install-script (pull_request) Failing after 8s
CI / rust (pull_request) Successful in 33s
b2109059ab
Both crates go to 0.3.0 for the first release with prebuilt binaries.
The READMEs give the install one-liner, what install.sh and
`rkd-terminal install` do on each OS, how to uninstall, how to build
from source, and that the binaries are not signed yet (curl sets no
quarantine attribute, so macOS runs them; a browser download would not).

Refs rkd#1084.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
Review fixes for the release workflow:

- Only commits of main are released: create-release fetches main (full
  history) and scripts/release-guard.sh requires the tagged commit to be
  an ancestor of origin/main, before anything is built.
- No ${{ }} of untrusted input inside run: scripts. The tag, target and
  release id reach scripts via env and quoted "$VARS"; the tag is checked
  (v<digit>, then only [A-Za-z0-9._-]) before it is used for anything.
- The Forgejo token never appears in curl's argv, where other users of
  the shared host-mode Mac runner could read it with ps: api_curl in
  scripts/release-lib.sh feeds the header as curl config on stdin.
- A release of the tag that is already published fails the run
  (release-find-or-create.sh), and release-upload.sh refuses a
  non-draft release before deleting or uploading anything. Re-runs and
  re-pushed tags only ever fill a draft.
- actions/checkout pinned to the v4.2.2 commit. Linux jobs install
  rustup from a pinned rustup-init checked against its SHA-256
  (scripts/install-rustup.sh) instead of curl | sh; the Mac job uses
  the runner's own rustup and fails with a clear message without it.
  ci.yml gets the same pin and rustup install.

scripts/test-release.sh (run in CI next to test-install.sh) covers the
scripts against a fake curl and a throwaway git repo: tag validation,
off-main commits, version mismatch, published vs draft releases, token
only on stdin, rustup-init checksum refusal, plus a lint of both
workflows (no ${{ }} in run:, actions pinned to a SHA, no sh.rustup.rs,
no Authorization header on a command line). Each guard was hand-broken
and the matching checks went red. Not testable here: the runner's own
git fetch of main and the Mac host probe; both are plain workflow steps.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
- `install` refuses, whatever --force says, a binary that another user
  could replace: the file or any folder above it writable by group or
  others (the sticky /tmp included), or owned by someone other than the
  user or root. The service would run whatever is there as the user.
- `status` probes the service instead of reporting the file alone:
  "installed, running" or "installed, not running (see <err log> /
  `journalctl --user -u rkd-terminal.service`)". Running means launchd
  prints `state = running` for the job, or the unit is active.
- Linux wording: a systemd user unit starts now and whenever you are
  logged in, not "at every login" and forever. install notes this and
  points at `loginctl enable-linger` for headless boxes; it never turns
  lingering on. USAGE, README and local/README say the same.

Tests via the injectable runner and a fake stat; each fix was
hand-broken (check dropped, group bit ignored, owner ignored, stat
errors trusted, status ignoring liveness, launchctl stdout ignored,
linger note removed) and a test went red every time.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
fix(install): https only, refuse a shared bin dir, start before pairing
Some checks failed
CI / rust (pull_request) Successful in 35s
CI / install-script (pull_request) Failing after 8s
18c0b1a701
- RKD_TERMINAL_BASE_URL no longer enables http:// or file://; only a
  separate RKD_TERMINAL_INSECURE_TEST=1 does (the harness sets it). A
  base URL other than the default prints a warning on stderr.
- The bin dir and every folder above it must be writable by nobody but
  the user and owned by the user or root, the same rule as
  `rkd-terminal install`; RKD_TERMINAL_BIN under /tmp is refused.
- With RKD_TERMINAL_NO_SERVICE=1 nothing is running, so the last line
  says to start it (`rkd-terminal serve` or `rkd-terminal install`)
  before pairing.

The harness now keeps its temp tree under $HOME (not /tmp, which the
new check refuses) and fakes `id` as the real uid. New checks for each
fix; each was hand-broken and its checks failed.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
fix(install): an explicit if for the archive check (SC2015)
Some checks failed
CI / install-script (pull_request) Failing after 11s
CI / rust (pull_request) Successful in 41s
ef89936007
CI's shellcheck 0.9.0 flags `A && B || C` as not an if-then-else;
spell it out.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
ci: run the install.sh harness as an unprivileged user
Some checks failed
CI / install-script (pull_request) Failing after 10s
CI / rust (pull_request) Successful in 33s
6864f3a140
The CI container runs as root and install.sh refuses root by design,
so every harness check failed there; locally it runs as the developer.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
fix(install): create the bin folder with umask 022
All checks were successful
CI / install-script (pull_request) Successful in 13s
CI / rust (pull_request) Successful in 32s
d8190c4fcd
A caller with Ubuntu's default umask 002 got a group-writable
~/.local/bin from the installer's own mkdir, which the new rule then
refused. Folders install.sh creates are never group- or
world-writable; only an existing loose folder is refused. The harness
pins umask 022 for its own fixtures and runs the installer under 002
to prove it.

Claude-Session: https://claude.ai/code/session_01P4hUduqyzHTYckP7dX5fXV
dozken merged commit 8093f5ccdb into main 2026-09-24 11:42:23 +02:00
dozken deleted branch feat/install-as-service 2026-09-24 11:42:24 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rkd/plugins!3
No description provided.