feat: secrets join + init --remote + verified onboarding (EGB-671)

Add second-machine onboarding as a first-class verb rather than a manual
clone + key-copy sequence:

- secrets join --remote <url> --key <path>: clone the vault, install the key
  at mode 600, then decrypt-test it before declaring success. An empty vault
  reports "nothing to verify yet" (never a false VERIFIED); a wrong key fails
  loudly. Reuses the audited core (resolve_store, get_pubkey, _verify_all) —
  no security logic re-implemented.
- secrets init --remote <url>: wire the remote and push the initial store so
  the upstream branch exists (fixes the commit_and_push_secrets pull --ff-only
  die against a brand-new empty remote). init also offers an interactive
  first-add of a project (default No; skipped under --yes / non-interactive).
- cmd_push first-manifest scaffold writes an explicit committed options.autoAdd
  value, asked once when interactive (EGB-677 contract #2).
- secrets pull now dies loudly when a blob fails to decrypt (all three decrypt
  paths) instead of warning and exiting 0 — a wrong key can't pass silently.
- Interactive prompts gate on stdin AND stdout being ttys, so bats/CI never hang.
- Dispatcher routes init/join args correctly; second-machine trap points at join.

Tests: 20 new (join, autoAdd, pty-no-hang regression); 2 trap tests updated.
This commit is contained in:
Brian Majewski 2026-06-08 16:23:50 -07:00
parent ec538d7ef0
commit 6319313ee4
4 changed files with 380 additions and 28 deletions

View file

@ -1484,9 +1484,10 @@ gradle_project() {
# ─── init second-machine guard + store .gitignore self-heal ────────────
@test "init with existing key but no repo dies with clone guidance" {
# Second-machine trap: user copies key.txt into ~/.secrets, then runs
# `secrets init` instead of cloning their secrets repo.
@test "init with existing key but no repo dies with join guidance" {
# Second-machine trap (EGB-671): user copies key.txt into ~/.secrets, then
# runs `secrets init` instead of joining their existing vault. The trap now
# points at `secrets join` (the real one-command path), not a manual clone.
mkdir -p "$SECRETS_DIR"
age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null
# Guard against a vacuous '' = '' comparison if age-keygen failed
@ -1496,7 +1497,7 @@ gradle_project() {
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"git clone"* ]] || false
[[ "$output" == *"secrets join"* ]] || false
# Must not leave a half-initialized store behind
[ ! -d "$SECRETS_DIR/.git" ]
# Key untouched
@ -1651,7 +1652,7 @@ gradle_project() {
[[ "$output" != *"key.txt"* ]] || false
}
@test "init guard renders the real clone URL when .secrets-store carries a remote" {
@test "init guard renders the real remote URL in join guidance when .secrets-store carries a remote" {
mkdir -p "$HOME/.secrets-work"
age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null
[ -s "$HOME/.secrets-work/key.txt" ]
@ -1660,7 +1661,7 @@ gradle_project() {
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]] || false
[[ "$output" == *"secrets join --remote git@example.com:me/secrets-work.git"* ]] || false
}
# ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ──