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

239
secrets
View file

@ -1235,6 +1235,25 @@ ensure_store_protections() {
cmd_init() { cmd_init() {
check_cmd age check_cmd age
check_cmd git check_cmd git
# EGB-671: flag parsing. --remote wires the encrypted-vault git remote and
# establishes an upstream branch (so the first project push won't hit the
# commit_and_push_secrets `pull --ff-only` die on a brand-new empty remote).
# --yes / non-interactive means init-only: skip the interactive first-add.
local remote="" assume_yes=false
while [ $# -gt 0 ]; do
case "$1" in
--remote) [ $# -ge 2 ] || die "--remote requires a URL"
case "$2" in --|-*) die "--remote value looks like a flag: $2" ;; esac
remote="$2"; shift 2 ;;
--remote=*) remote="${1#--remote=}"
[ -n "$remote" ] || die "--remote= requires a value"; shift ;;
--yes|-y) assume_yes=true; shift ;;
-*) die "Unknown init flag: $1. Usage: secrets init [--remote <url>] [--yes]" ;;
*) die "Unexpected argument to init: $1" ;;
esac
done
resolve_store resolve_store
if [ -d "$SECRETS_DIR/.git" ]; then if [ -d "$SECRETS_DIR/.git" ]; then
@ -1242,17 +1261,15 @@ cmd_init() {
fi fi
# Second-machine trap: a copied key.txt without a repo means the user # Second-machine trap: a copied key.txt without a repo means the user
# should clone their existing secrets repo, not init a fresh one. # should join their existing vault, not init a fresh one. Catch it BEFORE
# Catch it BEFORE git init so we don't leave a half-initialized store. # git init so we don't leave a half-initialized store.
if [ -f "$KEY_FILE" ]; then if [ -f "$KEY_FILE" ]; then
# Render a runnable clone command when .secrets-store carried a remote
# URL (already sanitized by resolve_store), mirroring check_initialized.
local clone_src="<your-secrets-remote>" local clone_src="<your-secrets-remote>"
[ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL" [ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL"
die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR. die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR.
If this is a second machine, don't run 'secrets init' — clone your existing secrets repo instead: If this is a second machine, don't run 'secrets init' — join your existing vault:
git clone $clone_src $SECRETS_DIR secrets join --remote $clone_src --key $KEY_FILE
Your key file has been left untouched." Your key file has been left untouched."
fi fi
@ -1268,9 +1285,7 @@ Your key file has been left untouched."
# Write .gitignore # Write .gitignore
write_store_gitignore write_store_gitignore
# Stamp the store format (EGB-703): a fresh store is born v2 — it has no # Stamp the store format (EGB-703): a fresh store is born v2.
# v1 blobs, so it is already in v2 shape. The marker is a committed,
# non-secret metadata file (NOT gitignored); the first push stages it.
printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"
# Install pre-commit hook # Install pre-commit hook
@ -1282,11 +1297,159 @@ Your key file has been left untouched."
info "Done! Your public key is:" info "Done! Your public key is:"
echo " $pubkey" echo " $pubkey"
if [ -n "$remote" ]; then
_init_wire_remote "$remote"
else
echo ""
echo "Next steps:"
echo " 1. Add a remote: secrets init --remote <url> (or: git -C $SECRETS_DIR remote add origin <url>)"
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
echo " 3. On the other machine: secrets join --remote <url> --key <path-to-key.txt>"
fi
# Interactive first-add (EGB-671): only when truly interactive AND not --yes.
# Require BOTH stdin and stdout to be ttys — bats/CI capture a command's
# stdout (so `[ -t 1 ]` is false under automation even when stdin is still
# the terminal), which is the reliable "don't prompt" signal. Default is No.
if [ "$assume_yes" != true ] && [ -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then
_init_first_add
fi
}
# EGB-671: wire the encrypted-vault remote and establish an upstream branch.
# Commits the born-v2 store (so .secrets-format etc. exist on the remote) and
# push -u, so a later `secrets push` pulls --ff-only against a real upstream
# instead of dying on a non-existent branch.
_init_wire_remote() {
local remote="$1"
git -C "$SECRETS_DIR" remote add origin "$remote"
ensure_store_protections
_stamp_writer_version
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "Initialize secrets store (format v2)" >/dev/null 2>&1 || true
local br
br=$(git -C "$SECRETS_DIR" symbolic-ref --short HEAD 2>/dev/null || echo main)
if git -C "$SECRETS_DIR" push -u origin "$br" >/dev/null 2>&1; then
info "Wired remote origin=$remote and pushed the initial store (upstream: origin/$br)."
else
info "Added remote origin=$remote, but the initial push failed."
echo " Create the PRIVATE repo first, then: git -C $SECRETS_DIR push -u origin $br" >&2
fi
echo "" echo ""
echo "Next steps:" echo "Next: copy $KEY_FILE to your other machine, then run there:"
echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin <url>" echo " secrets join --remote $remote --key <path-to-key.txt>"
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" }
echo " 3. Run 'secrets push <project>' from a project directory"
# EGB-671: interactive "add your first project" post-step. Reads from /dev/tty
# so it never collides with a piped stdin. Default No. Drives the existing
# push path (cmd_push is $PWD-bound) by cd'ing into the chosen project dir.
_init_first_add() {
printf "Add a project's secrets to the vault now? [y/N] " > /dev/tty 2>/dev/null || return 0
local ans=""
read -r ans < /dev/tty 2>/dev/null || return 0
case "$ans" in [Yy]*) ;; *) return 0 ;; esac
printf "Path to the project directory: " > /dev/tty 2>/dev/null || return 0
local dir=""
read -r dir < /dev/tty 2>/dev/null || return 0
[ -n "$dir" ] || return 0
case "$dir" in
"~") dir="$HOME" ;;
"~/"*) dir="$HOME/${dir#\~/}" ;;
esac
if [ ! -d "$dir" ]; then
echo "Not a directory: $dir — skipping first-add. Run 'secrets push' from a project later." > /dev/tty 2>/dev/null || true
return 0
fi
( cd "$dir" && cmd_push ) || true
return 0
}
# EGB-671: second-machine onboarding. Clone the vault, install the key, and
# decrypt-test it BEFORE declaring success — the one thing a hand-copied
# README sequence never did (a mis-copied key fails silently at first pull).
# Security logic (path rails on --key/--store, URL sanitization) lives in the
# audited core, reused — never re-implemented in a standalone install script.
cmd_join() {
check_cmd age
check_cmd git
local remote="" keyfile=""
while [ $# -gt 0 ]; do
case "$1" in
--remote) [ $# -ge 2 ] || die "--remote requires a URL"
case "$2" in --|-*) die "--remote value looks like a flag: $2" ;; esac
remote="$2"; shift 2 ;;
--remote=*) remote="${1#--remote=}"
[ -n "$remote" ] || die "--remote= requires a value"; shift ;;
--key) [ $# -ge 2 ] || die "--key requires a path"
case "$2" in --|-*) die "--key value looks like a flag: $2" ;; esac
keyfile="$2"; shift 2 ;;
--key=*) keyfile="${1#--key=}"
[ -n "$keyfile" ] || die "--key= requires a value"; shift ;;
-*) die "Unknown join flag: $1. Usage: secrets join --remote <url> --key <path>" ;;
*) die "Unexpected argument to join: $1" ;;
esac
done
resolve_store
[ -n "$remote" ] || die "secrets join requires --remote <url>
Usage: secrets join --remote <url> --key <path-to-key.txt>"
[ -n "$keyfile" ] || die "secrets join requires --key <path>
This is the age key (key.txt) from your first machine.
Usage: secrets join --remote <url> --key <path-to-key.txt>"
if [ -d "$keyfile" ]; then
die "--key must point to the key FILE, not a directory: $keyfile
Did you mean: --key $keyfile/key.txt ?"
fi
[ -e "$keyfile" ] || die "Key file not found: $keyfile
Copy key.txt from your first machine (AirDrop/scp/USB) and pass its path."
[ -r "$keyfile" ] || die "Key file not readable: $keyfile"
if [ -e "$SECRETS_DIR" ]; then
die "A store already exists at $SECRETS_DIR.
'secrets join' clones a fresh vault — it won't clobber an existing one.
If you meant to refresh it, run 'secrets pull' instead, or remove $SECRETS_DIR first."
fi
info "Joining vault: cloning $remote → $SECRETS_DIR"
if ! git clone "$remote" "$SECRETS_DIR" >/dev/null 2>&1; then
rm -rf "$SECRETS_DIR"
die "Failed to clone $remote
Check the URL and that you have access to the repo."
fi
# Install the key BEFORE anything that decrypts, at mode 600.
cp "$keyfile" "$SECRETS_DIR/key.txt"
chmod 600 "$SECRETS_DIR/key.txt"
KEY_FILE="$SECRETS_DIR/key.txt"
ensure_store_protections
if ! get_pubkey >/dev/null 2>&1; then
die "The file you passed to --key is not a valid age identity: $keyfile
Your store was cloned to $SECRETS_DIR; replace key.txt with a valid key and run 'secrets pull'."
fi
# Verify gate: decrypt-test every blob. An EMPTY store returns 0 from
# _verify_all ("nothing to check") — that proves nothing about the key, so
# join must NOT report VERIFIED in that case (EGB-671 / E-S1b).
local blob_count
blob_count=$(find "$SECRETS_DIR" -type f -name '*.age' 2>/dev/null | wc -l | tr -d ' ')
if [ "$blob_count" -eq 0 ]; then
info "Joined $SECRETS_DIR — the vault is empty, so there's nothing to verify yet."
echo "Next: run 'secrets pull' in a project once secrets have been pushed from another machine."
return 0
fi
if _verify_all >/dev/null 2>&1; then
info "VERIFIED — your key decrypts all $blob_count blob(s). You've joined the vault."
echo "Next: run 'secrets pull' in any project to restore its secrets."
return 0
fi
die "Your key does NOT decrypt this vault ($blob_count blob(s) failed).
This is almost always the wrong key.txt. The store is at $SECRETS_DIR;
replace key.txt with the correct key and run 'secrets pull', or remove
$SECRETS_DIR and re-run 'secrets join' with the right --key."
} }
# Encrypt env files from a source dir into a project path in the secrets repo. # Encrypt env files from a source dir into a project path in the secrets repo.
@ -1498,8 +1661,22 @@ cmd_push() {
'.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \ '.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \
| _write_manifest_canonical "$manifest" || die "Failed to update $manifest" | _write_manifest_canonical "$manifest" || die "Failed to update $manifest"
else else
jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" \ # EGB-671 / EGB-677 contract #2: scaffolding the project's FIRST manifest.
'{version: '"$MANIFEST_VERSION"', dotenv: $add} | if ($ext | length) > 0 then .external = $ext else . end' \ # Record an explicit, committed options.autoAdd value. Ask once when
# interactive (read from /dev/tty so a piped stdin never collides);
# otherwise write the tool default (ON) explicitly so the value is
# committed and team-shared rather than left implicit.
local autoadd_commit="true"
# Require BOTH stdin and stdout to be ttys (bats/CI capture stdout, so
# `[ -t 1 ]` is false under automation — never block a scripted push).
if [ -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then
printf "Auto-track new env files in this project as you add them? [Y/n] " > /dev/tty 2>/dev/null || true
local _aa=""
read -r _aa < /dev/tty 2>/dev/null || _aa=""
case "$_aa" in [Nn]*) autoadd_commit="false" ;; *) autoadd_commit="true" ;; esac
fi
jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" --argjson autoadd "$autoadd_commit" \
'{version: '"$MANIFEST_VERSION"', dotenv: $add, options: {autoAdd: $autoadd}} | if ($ext | length) > 0 then .external = $ext else . end' \
| _write_manifest_canonical "$manifest" || die "Failed to write $manifest" | _write_manifest_canonical "$manifest" || die "Failed to write $manifest"
fi fi
if [ "$write_adds" = true ]; then if [ "$write_adds" = true ]; then
@ -1616,9 +1793,14 @@ cmd_pull() {
continue continue
fi fi
case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac
age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob" # EGB-671 (DX-3): a wrong-but-structurally-valid key must NOT fail
# silently. Die loudly on decrypt failure instead of leaving a partial.
if ! age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"; then
die "Failed to decrypt '$rel' with the current key ($KEY_FILE).
Wrong key for this vault? Run 'secrets verify --all' to check the key."
fi
if [ ! -s "$target_dir/$rel" ]; then if [ ! -s "$target_dir/$rel" ]; then
echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$rel' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done <<< "$declared" done <<< "$declared"
@ -1642,10 +1824,14 @@ cmd_pull() {
local name local name
name=$(basename "$f" .age) name=$(basename "$f" .age)
local outfile="$target_dir/$name" local outfile="$target_dir/$name"
age -d -i "$KEY_FILE" -o "$outfile" "$f" # EGB-671 (DX-3): die loudly on decrypt failure (wrong key) — never silent.
if ! age -d -i "$KEY_FILE" -o "$outfile" "$f"; then
die "Failed to decrypt '$name' with the current key ($KEY_FILE).
Wrong key for this vault? Run 'secrets verify --all' to check the key."
fi
# Integrity check: verify non-empty # Integrity check: verify non-empty
if [ ! -s "$outfile" ]; then if [ ! -s "$outfile" ]; then
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done done
@ -1674,9 +1860,13 @@ pull_project_to_dir() {
local name local name
name=$(basename "$f" .age) name=$(basename "$f" .age)
local outfile="$target_dir/$name" local outfile="$target_dir/$name"
age -d -i "$KEY_FILE" -o "$outfile" "$f" # EGB-671 (DX-3): die loudly on decrypt failure (wrong key) — never silent.
if ! age -d -i "$KEY_FILE" -o "$outfile" "$f"; then
die "Failed to decrypt '$name' with the current key ($KEY_FILE).
Wrong key for this vault? Run 'secrets verify --all' to check the key."
fi
if [ ! -s "$outfile" ]; then if [ ! -s "$outfile" ]; then
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done done
@ -2523,6 +2713,10 @@ secrets — encrypted secret file sync between machines
Usage: Usage:
secrets init Initialize the secrets repo and generate an age key secrets init Initialize the secrets repo and generate an age key
secrets init --remote <url> Init, wire the remote, and push the initial store
secrets join --remote <url> --key <path>
Join an existing vault on a new machine: clone,
install the key, and verify it decrypts the store
secrets push [project] Encrypt secret files and push to the secrets repo secrets push [project] Encrypt secret files and push to the secrets repo
secrets push --frozen Sync only manifest-declared files (skip auto-add) secrets push --frozen Sync only manifest-declared files (skip auto-add)
secrets push --dry-run Show what would be added/synced; change nothing secrets push --dry-run Show what would be added/synced; change nothing
@ -2679,7 +2873,8 @@ else
fi fi
case "${1:-help}" in case "${1:-help}" in
init) cmd_init ;; init) shift; cmd_init "$@" ;;
join) shift; cmd_join "$@" ;;
push) push)
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
cmd_push_workspaces cmd_push_workspaces

145
test/join.bats Normal file
View file

@ -0,0 +1,145 @@
#!/usr/bin/env bats
# EGB-671: `secrets join` (second-machine onboarding) + `secrets init --remote`
# + day-2 silent-decrypt fix. Functional paths only — security-rail tests
# (path traversal on --key/--store, URL injection) are operator-local per
# .ship-policy.json and live in test/run-security.sh.
load test_helper
# Push a project to REMOTE_DIR and save the key, then remove the local store
# to simulate a fresh second machine. Leaves: REMOTE_DIR has blobs,
# $TEST_TMPDIR/saved-key.txt is the decrypting key, $SECRETS_DIR is gone.
_machine1_push_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
create_project_dir "joinproj"
"$SECRETS_BIN" push >/dev/null 2>&1
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# Like above but never pushes a project — remote has a store with zero blobs.
_machine1_empty_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# ─── secrets join ────────────────────────────────────────────────────────
@test "join without --remote fails with usage" {
run "$SECRETS_BIN" join
[ "$status" -ne 0 ]
[[ "$output" == *"--remote"* ]] || false
}
@test "join clones the store, installs the key at 600, verifies, and succeeds" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"VERIFIED"* ]] || false
[ -d "$SECRETS_DIR/.git" ]
[ -f "$SECRETS_DIR/key.txt" ]
# key installed at mode 600
local perms
perms=$(stat -f '%Lp' "$SECRETS_DIR/key.txt" 2>/dev/null || stat -c '%a' "$SECRETS_DIR/key.txt")
[ "$perms" = "600" ]
}
@test "join with the wrong key fails loudly and does not report VERIFIED" {
_machine1_push_then_wipe
age-keygen -o "$TEST_TMPDIR/wrong-key.txt" 2>/dev/null
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/wrong-key.txt"
[ "$status" -ne 0 ]
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join against an empty store reports nothing-to-verify, NOT VERIFIED" {
_machine1_empty_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"nothing to verify"* ]] || false
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join refuses when a store already exists at the target" {
"$SECRETS_BIN" init >/dev/null 2>&1
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"already"* ]] || false
}
@test "join fails clearly when the key file is missing" {
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/nope.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
@test "join detects a directory passed as --key" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
# ─── secrets init --remote ────────────────────────────────────────────────
@test "init --remote sets origin and establishes an upstream branch" {
run "$SECRETS_BIN" init --remote "$REMOTE_DIR"
[ "$status" -eq 0 ]
run git -C "$SECRETS_DIR" remote get-url origin
[ "$status" -eq 0 ]
[ "$output" = "$REMOTE_DIR" ]
# upstream branch exists on the remote (so a later push won't ff-only die)
run git -C "$SECRETS_DIR" rev-parse --abbrev-ref '@{u}'
[ "$status" -eq 0 ]
}
@test "init --remote then push does not die on the brand-new remote" {
"$SECRETS_BIN" init --remote "$REMOTE_DIR" >/dev/null 2>&1
create_project_dir "freshproj"
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" != *"Fast-forward pull failed"* ]] || false
}
@test "init with no flags still works (clean primitive)" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/key.txt" ]
}
@test "init does not hang on the first-add prompt when stdin is a tty but stdout is captured" {
# Regression: run-security.sh runs bats in a real terminal, so the command's
# stdin stays a tty while bats captures its stdout. The interactive first-add
# prompt must NOT fire in that shape (it gates on stdout being a tty too),
# or the whole suite hangs. Reproduce with a pty via `script`.
command -v script >/dev/null 2>&1 || skip "script (pty) not available"
# macOS/BSD syntax: `script -q <file> <cmd...>`. Skip on other syntaxes.
script -q /dev/null true >/dev/null 2>&1 || skip "unsupported script syntax"
local out="$TEST_TMPDIR/pty-initout"
run timeout 10 script -q /dev/null bash -c "'$SECRETS_BIN' init > '$out' 2>&1"
[ "$status" -ne 124 ] # 124 == timeout == it hung on a prompt
run grep -c "Add a project's secrets" "$out"
[ "$output" = "0" ]
}
# ─── day-2 silent-decrypt fix ─────────────────────────────────────────────
@test "pull dies loudly when a blob cannot be decrypted with the current key" {
init_with_remote
create_project_dir "decryptproj"
"$SECRETS_BIN" push >/dev/null 2>&1
# Swap in a different key so the stored blob no longer decrypts.
# (age-keygen refuses to overwrite, so generate elsewhere then copy.)
age-keygen -o "$TEST_TMPDIR/other-key.txt" 2>/dev/null
cp "$TEST_TMPDIR/other-key.txt" "$SECRETS_DIR/key.txt"
chmod 600 "$SECRETS_DIR/key.txt"
cd "$WORK_DIR/decryptproj"
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
[[ "$output" == *"decrypt"* ]] || false
}

View file

@ -167,6 +167,17 @@ load test_helper
[ "$output" = "2" ] [ "$output" = "2" ]
} }
@test "bootstrap: first push writes an explicit options.autoAdd value (EGB-677 contract #2)" {
init_with_remote
create_project_dir autoaddproj
# Non-interactive (bats has no tty): the prompt is skipped and the tool
# default (ON) is written explicitly so the value is committed + team-shared.
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
run jq -r '.options.autoAdd' .secrets.json
[ "$output" = "true" ]
}
@test "failed push leaves no bootstrap manifest behind" { @test "failed push leaves no bootstrap manifest behind" {
init_with_remote init_with_remote
mkdir -p "$WORK_DIR/emptyproj" mkdir -p "$WORK_DIR/emptyproj"

View file

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