diff --git a/CHANGELOG.md b/CHANGELOG.md index eab155e..8a1a60d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [0.7.0] ### Changed -- `vars history ` renamed to `vars log `. It now also shows local date+time, works for removed keys (git keeps their history), and needs no SSH key (reads git metadata only). +- `vars history ` renamed to `vars log `. It lists the key's committed states, each tagged with the `~N` you pass to `vars get ~N` (`~0` = latest, `~1` = before) instead of a commit hash, with local date+time. A commit that removed the key shows as `(removed)` (a state with no value); it needs no SSH key (reads git metadata only). +- `vars get ~N` is the key's state `N` commits back in its own history, not global git history (it always was per-key; the docs wrongly implied git's `HEAD~N`). If state `N` was a removal it has no value: nothing is printed and the command exits non-zero (a removed key's last value is then `~1`). - The `[n]ew name` conflict option in `set`/`import` asks for a full key (scopes allowed, e.g. `prod/K`) instead of appending a `_`. +- Key-free commands (`ls`, `scope`, `mv`, `rm`) no longer require the SSH key; it's resolved lazily, only when a command actually encrypts or decrypts. ## [0.6.0] (unreleased) diff --git a/README.md b/README.md index faab69b..1f11063 100644 --- a/README.md +++ b/README.md @@ -206,9 +206,9 @@ cat .env | vars resolve --partial --origin # annotate where each value came from When the store is a git repo, every change is committed automatically. ```sh -vars log RPC_URL # this key's change log (newest first, local time) -vars get RPC_URL~1 # the previous value -vars get RPC_URL~2 # two versions ago (git's HEAD~N convention) +vars log RPC_URL # this key's committed states, newest first, tagged ~0, ~1, … +vars get RPC_URL~1 # the previous value of this key +vars get RPC_URL~2 # two states back (counts only commits to this key) vars git remote add origin git@github.com:me/store.git vars git log # run any git command in the store dir vars sync # pull --rebase, then push diff --git a/cmd/02_set.go b/cmd/02_set.go index 5185d79..58c4c24 100644 --- a/cmd/02_set.go +++ b/cmd/02_set.go @@ -75,11 +75,11 @@ shell history). break // new key — no conflict } if string(existing) == value { - fmt.Fprintln(os.Stderr, "Already set, nothing to do.") + fmt.Fprintln(os.Stderr, "Already set") return nil } if setSkip { - fmt.Fprintln(os.Stderr, "Skipped.") + fmt.Fprintln(os.Stderr, "Skipped") return nil } if setReplace { @@ -101,18 +101,23 @@ shell history). key = newKey continue // the new key may also exist (or be invalid): re-check it default: // actionSkip - fmt.Fprintln(os.Stderr, "Skipped.") + fmt.Fprintln(os.Stderr, "Skipped") return nil } break } + // A first write is silent; an update names the key. Has is decrypt-free, + // so this holds even when the loop above couldn't read the old value. + existed := v.Has(key) if err := v.Set(key, []byte(value)); err != nil { return UserError(err.Error()) } printManifestHint(key) - fmt.Fprintln(os.Stderr, "Saved.") + if existed { + fmt.Fprintf(os.Stderr, "%s updated\n", key) + } hintSync(storeDir()) return nil }, diff --git a/cmd/03_get.go b/cmd/03_get.go index 7384fcf..2e66af1 100644 --- a/cmd/03_get.go +++ b/cmd/03_get.go @@ -18,8 +18,11 @@ var getCmd = &cobra.Command{ Short: "Get a key from the store", Long: `Print one value to stdout with no trailing newline. Pipes cleanly. -KEY~N retrieves the value N versions ago from git history (like git's HEAD~N): -KEY~1 is the previous value, KEY~2 the one before that.`, +KEY~N is this key's state N commits back in its own history (not a global commit): +KEY~0 = latest committed state, KEY~1 the one before, and so on. The ~N matches the +tags shown by ` + "`vars log `" + `. If that state was a removal, it has no value: +nothing is printed and the command exits non-zero (a removed key's last value is +then KEY~1). The ~ borrows git's syntax, but counts only commits to this key.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { v, err := openVault() diff --git a/cmd/06_mv.go b/cmd/06_mv.go index b3f74ee..87d6351 100644 --- a/cmd/06_mv.go +++ b/cmd/06_mv.go @@ -36,7 +36,7 @@ var mvCmd = &cobra.Command{ return UserError(err.Error()) } if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "y") { - fmt.Fprintln(os.Stderr, "Aborted.") + fmt.Fprintln(os.Stderr, "Aborted") return nil } } @@ -45,7 +45,7 @@ var mvCmd = &cobra.Command{ return UserError(err.Error()) } - fmt.Fprintf(os.Stderr, "Renamed %s → %s\n", args[0], args[1]) + fmt.Fprintf(os.Stderr, "Renamed: %s → %s\n", args[0], args[1]) hintSync(storeDir()) return nil }, diff --git a/cmd/07_rm.go b/cmd/07_rm.go index 6a25b7b..177cfcf 100644 --- a/cmd/07_rm.go +++ b/cmd/07_rm.go @@ -38,7 +38,7 @@ var rmCmd = &cobra.Command{ if !rmForce { if len(args) == 1 { - fmt.Fprintf(os.Stderr, "Removing %s.\n", args[0]) + fmt.Fprintf(os.Stderr, "Removing %s\n", args[0]) } else { fmt.Fprintf(os.Stderr, "Removing %d keys:\n", len(args)) for _, key := range args { @@ -53,7 +53,7 @@ var rmCmd = &cobra.Command{ return UserError(err.Error()) } if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "y") { - fmt.Fprintln(os.Stderr, "Aborted.") + fmt.Fprintln(os.Stderr, "Aborted") return nil } } @@ -69,9 +69,9 @@ var rmCmd = &cobra.Command{ } if len(args) == 1 { - fmt.Fprintln(os.Stderr, "Removed.") + fmt.Fprintf(os.Stderr, "%s removed\n", args[0]) } else { - fmt.Fprintf(os.Stderr, "Removed %d keys.\n", len(args)) + fmt.Fprintf(os.Stderr, "%d keys removed\n", len(args)) } hintSync(storeDir()) return nil diff --git a/cmd/07b_log.go b/cmd/07b_log.go index 4f13697..ea4c615 100644 --- a/cmd/07b_log.go +++ b/cmd/07b_log.go @@ -17,9 +17,10 @@ func init() { var logCmd = &cobra.Command{ Use: "log ", Short: "Show a key's change history (newest first)", - Long: `List the git commits that touched a key, newest first, with local time. -Removed keys still show their history (git keeps it). Like ` + "`git log `" + `. -Requires the store to be a git repo.`, + Long: `List a key's committed states, newest first, with local time. Each line is +tagged with the ~N you pass to ` + "`vars get ~N`" + ` (~0 = latest state, ~1 = before). +A commit that removed the key shows as "(removed)", a state with no value; every +other line is a stored value. Requires the store to be a git repo.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Reads git metadata only (no decryption), so it needs neither the SSH key @@ -36,11 +37,13 @@ Requires the store to be a git repo.`, return InternalError(err.Error()) } if len(lines) == 0 { - fmt.Fprintf(os.Stderr, "No history for %q.\n", args[0]) + fmt.Fprintf(os.Stderr, "No history for %q\n", args[0]) return nil } - for _, l := range lines { - fmt.Fprintln(os.Stdout, l) + // Tag each line with the ~N that retrieves it (`vars get ~N`): the + // list is newest-first, so its index is exactly that N. + for i, l := range lines { + fmt.Fprintf(os.Stdout, "~%d %s\n", i, l) } return nil }, diff --git a/cmd/08_import.go b/cmd/08_import.go index 1db1af5..0a26b8d 100644 --- a/cmd/08_import.go +++ b/cmd/08_import.go @@ -54,7 +54,7 @@ With a scope, keys are prefixed: vars import prod .env → prod/KEY.`, return UserError(fmt.Sprintf("parsing file: %v", err)) } if len(entries) == 0 { - fmt.Fprintln(os.Stderr, "No entries found.") + fmt.Fprintln(os.Stderr, "No entries found") return nil } if scope != "" { @@ -133,7 +133,7 @@ With a scope, keys are prefixed: vars import prod .env → prod/KEY.`, } } - fmt.Fprintf(os.Stderr, "Imported %d, replaced %d, skipped %d.\n", imported, replaced, skipped) + fmt.Fprintf(os.Stderr, "Imported %d, replaced %d, skipped %d\n", imported, replaced, skipped) hintSync(storeDir()) return nil }, diff --git a/cmd/12_git.go b/cmd/12_git.go index bd36025..0e5f54f 100644 --- a/cmd/12_git.go +++ b/cmd/12_git.go @@ -55,7 +55,7 @@ var syncCmd = &cobra.Command{ if err := git.New(dir).Sync(); err != nil { return UserError(err.Error()) } - fmt.Fprintln(os.Stderr, "Synced.") + fmt.Fprintln(os.Stderr, "Synced") return nil }, } diff --git a/cmd/root.go b/cmd/root.go index a53e0bb..65a97b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,10 +19,10 @@ var Version = "dev" var rootCmd = &cobra.Command{ Use: "vars", - Short: "A central vault for environment variables", - Long: `vars is a single encrypted store for environment variables, -shared across multiple projects. It replaces scattered .env files with -a single age-encrypted store.`, + Short: "An encrypted store for your environment variables", + Long: `vars keeps your project secrets in one encrypted store, unlocked by the +SSH key you already have. Each value is a separate age-encrypted file in an optional +git repo, so history and cross-machine sync are just git.`, SilenceUsage: true, SilenceErrors: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { diff --git a/internal/git/git.go b/internal/git/git.go index f0b5cb3..16a8728 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -118,10 +118,14 @@ func (r *Repo) Sync() error { return nil } -// Log returns commit lines (newest first) touching relpath, formatted -// " ()". Empty when relpath has no history. +// Log returns a key's committed states (newest first), one line each, formatted +// " " for a stored value or " +// (removed)" for a commit that deleted the key. The caller numbers them, so each +// line's index is the N for `vars get ~N` (a "(removed)" line is a state +// with no value). Empty when relpath has no history. func (r *Repo) Log(relpath string) ([]string, error) { - out, err := r.run("log", "--date=format-local:%Y-%m-%d %H:%M", "--format=%h %s (%cd)", "--", relpath) + const us = "\x1f" // unit separator: a field delimiter that can't occur in the data + out, err := r.run("log", "--date=format-local:%Y-%m-%d %H:%M", "--format=%H"+us+"%cd"+us+"%s", "--", relpath) if err != nil { return nil, fmt.Errorf("git log: %w: %s", err, out) } @@ -129,12 +133,38 @@ func (r *Repo) Log(relpath string) ([]string, error) { if out == "" { return nil, nil } - return strings.Split(out, "\n"), nil + // Which of those commits stored a value (vs removed the key): AMR keeps + // add/modify/rename, so any commit NOT listed here deleted the key. + valOut, err := r.run("log", "--diff-filter=AMR", "--format=%H", "--", relpath) + if err != nil { + return nil, fmt.Errorf("git log: %w: %s", err, valOut) + } + hasValue := map[string]bool{} + for _, h := range strings.Fields(valOut) { + hasValue[h] = true + } + var lines []string + for _, row := range strings.Split(out, "\n") { + f := strings.SplitN(row, us, 3) + if len(f) != 3 { + continue + } + hash, when, subject := f[0], f[1], f[2] + if hasValue[hash] { + lines = append(lines, when+" "+subject) + } else { + lines = append(lines, when+" (removed)") + } + } + return lines, nil } -// VersionContent returns the raw stored bytes of relpath as of n commits back -// that touched it (n=0 = current, n=1 = previous, …), via git. The caller -// decrypts. .age files are binary, so git emits them unmodified. +// VersionContent returns relpath's content at its n-th most recent committed +// state (n=0 = latest commit touching it, n=1 = the one before, …). Every commit +// that touched the key is a state, including removals: if state n was a removal, +// it has no value and a clear error is returned (cat-file -e tests blob existence +// by exit code, locale-safe). The caller decrypts; .age files are binary, so git +// emits them unmodified. func (r *Repo) VersionContent(relpath string, n int) ([]byte, error) { logOut, err := r.run("log", "--format=%H", "--", relpath) if err != nil { @@ -145,7 +175,10 @@ func (r *Repo) VersionContent(relpath string, n int) ([]byte, error) { return nil, fmt.Errorf("%q has no history", relpath) } if n < 0 || n >= len(commits) { - return nil, fmt.Errorf("only %d previous version(s) exist", len(commits)-1) + return nil, fmt.Errorf("~%d is out of range; history goes back to ~%d", n, len(commits)-1) + } + if _, err := r.run("cat-file", "-e", commits[n]+":"+relpath); err != nil { + return nil, fmt.Errorf("~%d has no value (the key was removed at that point)", n) } out, err := r.run("show", commits[n]+":"+relpath) if err != nil { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 2f2de87..4f3f981 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -49,7 +49,7 @@ func (f *fakeGit) run(args ...string) (string, error) { return f.remotes, nil case cmd == "config user.email": return f.configEmail, nil - case strings.HasPrefix(cmd, "log --format=%H"): + case strings.Contains(cmd, "--format=%H"): return f.logOutput, nil case strings.HasPrefix(cmd, "show "): return f.showOutput, nil @@ -213,6 +213,16 @@ func TestVersionContent_OutOfBounds(t *testing.T) { } } +func TestVersionContent_RemovalHasNoValue(t *testing.T) { + // commits[0] is the commit that removed the key: cat-file -e fails, so the + // version reports "no value" rather than leaking a raw git show failure. + f := &fakeGit{logOutput: "h0\nh1\n", failOn: "cat-file"} + _, err := repoWith(f).VersionContent("K.age", 0) + if err == nil || !strings.Contains(err.Error(), "no value") { + t.Fatalf("expected a clear no-value error, got %v", err) + } +} + func TestVersionContent_NoHistory(t *testing.T) { f := &fakeGit{logOutput: ""} if _, err := repoWith(f).VersionContent("K.age", 1); err == nil { @@ -220,22 +230,46 @@ func TestVersionContent_NoHistory(t *testing.T) { } } -func TestLog_ParsesLines(t *testing.T) { - f := &fakeGit{} - // Override run to return canned log output. +func TestLog_RendersValuesAndRemovals(t *testing.T) { + var calls []string + // Full history h0..h2 where h1 was a removal (absent from the AMR value set). r := &Repo{dir: "/store", run: func(args ...string) (string, error) { - f.calls = append(f.calls, strings.Join(args, " ")) - return "abc123 set RPC_URL (2026-06-13)\ndef456 mv RPC_URL R2 (2026-06-12)\n", nil + cmd := strings.Join(args, " ") + calls = append(calls, cmd) + switch { + case strings.Contains(cmd, "--diff-filter=AMR"): + return "h0\nh2\n", nil // value-bearing commits only + case strings.Contains(cmd, "--format=%H"): + return "h0\x1f2026-06-21 09:10\x1fset RPC_URL\n" + + "h1\x1f2026-06-21 09:05\x1frm RPC_URL\n" + + "h2\x1f2026-06-21 09:01\x1fset RPC_URL\n", nil + } + return "", nil }} lines, err := r.Log("RPC_URL.age") if err != nil { t.Fatalf("log: %v", err) } - if len(lines) != 2 || !strings.Contains(lines[0], "set RPC_URL") { - t.Fatalf("log lines = %v", lines) + if len(lines) != 3 { + t.Fatalf("want 3 state lines, got %v", lines) + } + if !strings.Contains(lines[0], "set RPC_URL") { + t.Fatalf("line 0 should be a value: %q", lines[0]) + } + if !strings.Contains(lines[1], "(removed)") || strings.Contains(lines[1], "rm RPC_URL") { + t.Fatalf("line 1 should render the removal as a no-value state, got %q", lines[1]) + } + if !strings.Contains(lines[2], "set RPC_URL") { + t.Fatalf("line 2 should be a value: %q", lines[2]) + } + scoped := false + for _, c := range calls { + if strings.Contains(c, "-- RPC_URL.age") { + scoped = true + } } - if !f.issued("-- RPC_URL.age") { - t.Fatalf("log should scope to the file, calls = %v", f.calls) + if !scoped { + t.Fatalf("log should scope to the file, calls = %v", calls) } } diff --git a/internal/session/session.go b/internal/session/session.go index 0f9eaa4..c196843 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -7,7 +7,9 @@ import ( "fmt" "os" "path/filepath" + "sync" + "github.com/vars-cli/vars/internal/crypto" "github.com/vars-cli/vars/internal/crypto/sshderive" "github.com/vars-cli/vars/internal/git" "github.com/vars-cli/vars/internal/vault" @@ -16,26 +18,77 @@ import ( // Scheme is the only store scheme this build understands. const Scheme = "ssh-v1" -// Open returns a ready vault for an existing store at dir, resolving the SSH -// key it requires and attaching git versioning when dir is a repo. +// Open returns a ready vault for an existing store at dir, attaching git +// versioning when dir is a repo. The SSH key is resolved lazily (see +// lazyBackend), so key-free commands (ls, scope, mv, rm) never require it; the +// key is demanded only when a command actually encrypts or decrypts. func Open(dir string) (*vault.Vault, error) { if !vault.Exists(dir) { return nil, fmt.Errorf("no vars store at %s: run `vars` to create one", dir) } - signer, err := ResolveSigner(dir) + meta, err := loadMeta(dir) // cheap, no key: validates the store is one we understand if err != nil { return nil, err } - return vaultWith(dir, signer), nil -} - -// vaultWith builds a vault, attaching a git Committer only when dir is a repo. -func vaultWith(dir string, signer *sshderive.Signer) *vault.Vault { var committer vault.Committer if git.Available() && git.IsRepo(dir) { committer = gitCommitter{git.New(dir)} } - return vault.New(dir, sshderive.NewBackend(signer), committer) + return vault.New(dir, newLazyBackend(meta.KeyFingerprint), committer), nil +} + +// loadMeta reads the store descriptor and verifies this build understands it. +func loadMeta(dir string) (vault.Meta, error) { + meta, err := vault.ReadMeta(dir) + if err != nil { + return meta, fmt.Errorf("reading store metadata: %w", err) + } + if meta.Scheme != Scheme { + return meta, fmt.Errorf("unsupported store scheme %q (this vars supports %q)", meta.Scheme, Scheme) + } + return meta, nil +} + +// lazyBackend defers SSH key resolution until the first encrypt/decrypt, so a +// command that only reads or moves files (ls, scope, mv, rm) never touches the +// key. The key (matched to the store's fingerprint) is resolved at most once. +type lazyBackend struct { + fingerprint string + once sync.Once + backend crypto.Backend + err error +} + +var _ crypto.Backend = (*lazyBackend)(nil) + +func newLazyBackend(fingerprint string) *lazyBackend { return &lazyBackend{fingerprint: fingerprint} } + +func (l *lazyBackend) resolve() (crypto.Backend, error) { + l.once.Do(func() { + signer, err := signerForFingerprint(l.fingerprint) + if err != nil { + l.err = err + return + } + l.backend = sshderive.NewBackend(signer) + }) + return l.backend, l.err +} + +func (l *lazyBackend) Encrypt(plaintext []byte) ([]byte, error) { + b, err := l.resolve() + if err != nil { + return nil, err + } + return b.Encrypt(plaintext) +} + +func (l *lazyBackend) Decrypt(ciphertext []byte) ([]byte, error) { + b, err := l.resolve() + if err != nil { + return nil, err + } + return b.Decrypt(ciphertext) } // gitCommitter adapts *git.Repo to vault.Committer. git is a soft dependency: @@ -56,18 +109,6 @@ func (g gitCommitter) VersionContent(relpath string, n int) ([]byte, error) { return g.repo.VersionContent(relpath, n) } -// ResolveSigner finds the key an existing store needs, by its recorded fingerprint. -func ResolveSigner(dir string) (*sshderive.Signer, error) { - meta, err := vault.ReadMeta(dir) - if err != nil { - return nil, fmt.Errorf("reading store metadata: %w", err) - } - if meta.Scheme != Scheme { - return nil, fmt.Errorf("unsupported store scheme %q (this vars supports %q)", meta.Scheme, Scheme) - } - return signerForFingerprint(meta.KeyFingerprint) -} - // signerForFingerprint resolves a signer matching fp: VARS_SSH_KEY file, then // ssh-agent, then the default key file. func signerForFingerprint(fp string) (*sshderive.Signer, error) { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index dd234ad..8b15f82 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -34,15 +34,10 @@ func writeKey(t *testing.T) (path, fingerprint string) { return path, ssh.FingerprintSHA256(ss.PublicKey()) } -func TestResolveSigner_ViaEnvKey(t *testing.T) { +func TestSignerForFingerprint_ViaEnvKey(t *testing.T) { keyPath, fp := writeKey(t) - dir := t.TempDir() - if err := vault.Init(dir, vault.Meta{Scheme: Scheme, KeyFingerprint: fp}); err != nil { - t.Fatalf("init: %v", err) - } t.Setenv("VARS_SSH_KEY", keyPath) - - s, err := ResolveSigner(dir) + s, err := signerForFingerprint(fp) if err != nil { t.Fatalf("resolve: %v", err) } @@ -51,25 +46,60 @@ func TestResolveSigner_ViaEnvKey(t *testing.T) { } } -func TestResolveSigner_FingerprintMismatch(t *testing.T) { - _, fpA := writeKey(t) // the store's key - keyB, _ := writeKey(t) // a different key +func TestOpen_UnsupportedScheme(t *testing.T) { + keyPath, fp := writeKey(t) dir := t.TempDir() - vault.Init(dir, vault.Meta{Scheme: Scheme, KeyFingerprint: fpA}) - t.Setenv("VARS_SSH_KEY", keyB) - - if _, err := ResolveSigner(dir); err == nil { - t.Fatal("expected a fingerprint-mismatch error") + vault.Init(dir, vault.Meta{Scheme: "ssh-v999", KeyFingerprint: fp}) + t.Setenv("VARS_SSH_KEY", keyPath) + if _, err := Open(dir); err == nil { + t.Fatal("Open should reject an unsupported scheme (no key needed for this check)") } } -func TestResolveSigner_UnsupportedScheme(t *testing.T) { +// Open resolves the key lazily: metadata operations work without it; only +// encrypt/decrypt require it. Guards against the regression where `vars ls` +// demanded ssh-add despite decrypting nothing. +func TestOpen_LazyKey(t *testing.T) { keyPath, fp := writeKey(t) dir := t.TempDir() - vault.Init(dir, vault.Meta{Scheme: "ssh-v999", KeyFingerprint: fp}) + vault.Init(dir, vault.Meta{Scheme: Scheme, KeyFingerprint: fp}) + t.Setenv("VARS_SSH_KEY", keyPath) - if _, err := ResolveSigner(dir); err == nil { - t.Fatal("expected unsupported-scheme error") + v, err := Open(dir) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := v.Set("K", []byte("v")); err != nil { + t.Fatalf("set: %v", err) + } + + // Reopen with no usable key. + t.Setenv("VARS_SSH_KEY", filepath.Join(t.TempDir(), "absent")) + v2, err := Open(dir) + if err != nil { + t.Fatalf("Open must not require the key: %v", err) + } + if keys, err := v2.List(); err != nil || len(keys) != 1 { + t.Fatalf("List should work without the key: keys=%v err=%v", keys, err) + } + if _, err := v2.Get("K"); err == nil { + t.Fatal("Get should fail when no usable key is available") + } +} + +func TestOpen_FingerprintMismatchSurfacesOnUse(t *testing.T) { + _, fpA := writeKey(t) // the store's key + keyB, _ := writeKey(t) // a different, valid key + dir := t.TempDir() + vault.Init(dir, vault.Meta{Scheme: Scheme, KeyFingerprint: fpA}) + t.Setenv("VARS_SSH_KEY", keyB) + + v, err := Open(dir) // lazy: Open succeeds even though the key won't match + if err != nil { + t.Fatalf("open: %v", err) + } + if err := v.Set("K", []byte("v")); err == nil { + t.Fatal("Set should fail: the available key does not match the store fingerprint") } } diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 3f0a0fb..b23f61e 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # End-to-end smoke test for the ssh-v1 (v0.6) vars. Uses a dedicated SSH key so # it's deterministic and needs no ssh-agent. git versioning is best-effort: the -# history check is skipped where git isn't functional. +# log/version check is skipped where the store isn't a git repo. set -euo pipefail BIN="${1:-./vars}" @@ -101,14 +101,20 @@ contains "$($BIN dump --dotenv 2>/dev/null)" "ETHERSCAN_API=" echo "--- version ---" contains "$($BIN --version)" "vars" -echo "--- history (git; skipped where git is unavailable) ---" +echo "--- set from stdin (multi-line) ---" +printf 'line1\nline2' | $BIN set MULTILINE - +test "$($BIN get MULTILINE)" = "$(printf 'line1\nline2')" + +echo "--- log + version retrieval (git; skipped where the store isn't a git repo) ---" $BIN set RPC_URL https://rpc-v2.example.com --replace >/dev/null 2>&1 -HIST=$($BIN history RPC_URL 2>/dev/null || true) -if [ -n "$HIST" ]; then - contains "$HIST" "RPC_URL" - echo " history OK" +if [ -d "$VARS_STORE_DIR/.git" ]; then + LOG=$($BIN log RPC_URL) + contains "$LOG" "~0" # versions are tagged with the ~N that retrieves them + contains "$LOG" "~1" + test "$($BIN get RPC_URL~1)" = "https://rpc.example.com" # previous version + echo " log + ~N retrieval OK" else - echo " (no git history available here — skipped)" + echo " (store is not a git repo here; skipped)" fi echo "" diff --git a/test/e2e/integration_test.go b/test/e2e/integration_test.go index 339ea10..6567cc2 100644 --- a/test/e2e/integration_test.go +++ b/test/e2e/integration_test.go @@ -389,6 +389,35 @@ func TestRm(t *testing.T) { r.mustFail("rm", "GHOST", "--force") } +// Success feedback: a first write is silent; updates/removes name the key; mv uses an arrow. +func TestMutationFeedback(t *testing.T) { + r := newRunner(t) + r.mustRun("set", "SEED", "x") // first command: absorbs the one-time store-creation output + + // New key in an existing store: nothing on stderr. + if _, se, err := r.run("set", "NEW", "v1"); err != nil || strings.TrimSpace(se) != "" { + t.Fatalf("a first write should be silent; err=%v stderr=%q", err, se) + } + // Update names the key. + if _, se, _ := r.run("set", "NEW", "v2", "--replace"); !strings.Contains(se, "NEW updated") { + t.Fatalf("update should say 'NEW updated', got %q", se) + } + // Remove (single) names the key; (multiple) gives a count. + r.mustRun("set", "A", "1") + r.mustRun("set", "B", "2") + if _, se, _ := r.run("rm", "NEW", "--force"); !strings.Contains(se, "NEW removed") { + t.Fatalf("rm should say 'NEW removed', got %q", se) + } + if _, se, _ := r.run("rm", "A", "B", "--force"); !strings.Contains(se, "2 keys removed") { + t.Fatalf("multi-rm should say '2 keys removed', got %q", se) + } + // Rename uses the arrow. + r.mustRun("set", "OLD", "v") + if _, se, _ := r.run("mv", "OLD", "NEWNAME", "--force"); !strings.Contains(se, "Renamed: OLD → NEWNAME") { + t.Fatalf("mv should say 'Renamed: OLD → NEWNAME', got %q", se) + } +} + func TestDump(t *testing.T) { r := newRunner(t) r.mustRun("set", "A", "1") @@ -409,13 +438,17 @@ func TestLog(t *testing.T) { t.Skip("git history unavailable in this environment") } has(t, so, "set RPC_URL") - // Each line carries a local date+time: " (YYYY-MM-DD HH:MM)". - if !regexp.MustCompile(`\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\)`).MatchString(so) { - t.Fatalf("log line should show local date+time, got:\n%s", so) + // Lines are "~N YYYY-MM-DD HH:MM ": ~0 = current, ~1 = previous, + // the same N you pass to `vars get RPC_URL~N`. + if !regexp.MustCompile(`(?m)^~0 \d{4}-\d{2}-\d{2} \d{2}:\d{2} set RPC_URL$`).MatchString(so) { + t.Fatalf("log line should be '~0 ', got:\n%s", so) + } + if !strings.Contains(so, "~1 ") { + t.Fatalf("expected a ~1 entry for the previous version, got:\n%s", so) } } -func TestLogRemovedKeyStillShown(t *testing.T) { +func TestLogAndGetRemovalIsANoValueState(t *testing.T) { r := newRunner(t) r.mustRun("set", "GONE", "v1") r.mustRun("set", "--replace", "GONE", "v2") @@ -424,9 +457,49 @@ func TestLogRemovedKeyStillShown(t *testing.T) { if err != nil || strings.TrimSpace(so) == "" { t.Skip("git history unavailable in this environment") } - // A removed key keeps its history: the rm and the two sets are all visible. - has(t, so, "rm GONE") - has(t, so, "set GONE") + // The removal is a committed state: it occupies ~0 and renders as "(removed)", + // not as the "rm GONE" action. The stored values follow at ~1, ~2. + if !regexp.MustCompile(`(?m)^~0 \d{4}-\d{2}-\d{2} \d{2}:\d{2} \(removed\)$`).MatchString(so) { + t.Fatalf("~0 should be a (removed) state line, got:\n%s", so) + } + if strings.Contains(so, "rm GONE") { + t.Fatalf("the rm action label should not appear, only the (removed) state:\n%s", so) + } + has(t, so, "~1 ") + // get at the removal state (~0) has no value: non-zero exit, nothing on stdout. + out, se := r.mustFail("get", "GONE~0") + if out != "" { + t.Fatalf("a no-value version must print nothing to stdout, got %q", out) + } + has(t, se, "no value") + // The last stored value is at ~1. + if got := r.mustRun("get", "GONE~1"); got != "v2" { + t.Fatalf("GONE~1 = %q, want last stored value v2", got) + } +} + +func TestGetMidHistoryRemoval(t *testing.T) { + r := newRunner(t) + r.mustRun("set", "K", "v1") + r.mustRun("set", "--replace", "K", "v2") + r.mustRun("rm", "--force", "K") + r.mustRun("set", "K", "v3") // re-added; history newest-first: [v3, rm, v2, v1] + if _, _, err := r.run("log", "K"); err != nil { + t.Skip("git history unavailable in this environment") + } + if got := r.mustRun("get", "K~0"); got != "v3" { + t.Fatalf("K~0 = %q, want v3", got) + } + out, _ := r.mustFail("get", "K~1") // the removal in the middle + if out != "" { + t.Fatalf("K~1 (a removal state) must print nothing, got %q", out) + } + if got := r.mustRun("get", "K~2"); got != "v2" { + t.Fatalf("K~2 = %q, want v2", got) + } + if got := r.mustRun("get", "K~3"); got != "v1" { + t.Fatalf("K~3 = %q, want v1", got) + } } func TestLogNoHistory(t *testing.T) {