From f611aafb8c05ffa94e8ff46317b7a8800be29b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Mon, 22 Jun 2026 11:39:40 +0000 Subject: [PATCH 1/6] Vars dump: with confirmation --- CHANGELOG.md | 6 ++ README.md | 39 +++++++++++- cmd/09_dump.go | 44 +++++++++++-- internal/crypto/sshderive/signer.go | 15 +++++ internal/session/session.go | 97 +++++++++++++++++++++++++++-- internal/session/session_test.go | 86 +++++++++++++++++++++++++ scripts/smoke.sh | 2 +- test/e2e/integration_test.go | 16 ++++- 8 files changed, 288 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edc4c95..5802c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - `vars clone ` clones an existing store from a git remote into your local store directory (e.g. to set up from a store you already pushed), instead of creating a fresh, divergent one. It replaces an empty local store but refuses to overwrite one that holds secrets, locks the store directory to `0700`, and reports whether the SSH key the store is encrypted to is available (that key may differ from the one that authenticated the clone). `git clone` sets `origin`, so `vars sync` works immediately. ### Changed +- The SSH key is found by **fingerprint** across `~/.ssh`, not just the conventional `id_ed25519`/`id_rsa` names. So a dedicated decryption key under any filename (e.g. `~/.ssh/id_vars`) is picked up automatically, no `VARS_SSH_KEY` needed. When the matching key is found but can't be loaded (passphrase-protected and not in the agent), the error names the exact file: `load it with ssh-add `. +- When a command needs the key and it isn't loaded, vars runs `ssh-add` on **that specific key** (prompting for its passphrase) and proceeds in one go, but only when there's a terminal to prompt on and an agent to load into. Non-interactive runs get a clean error instead of hanging, and an explicit `VARS_SSH_KEY` is left strict (not auto-loaded). Use `ssh -t host vars …` to get a prompt over SSH. - Writing to a store self-heals its static scaffolding: a missing `README.md`, `.gitignore`, or `.gitattributes` is recreated on the next write (a deleted `.gitignore` re-arms the default-deny allowlist before secrets are committed). Existing files are never overwritten. The scaffold now has a single source of truth in the `vault` package, shared by store creation and writes. - `vars ls ` accepts only a scope. - Concurrent mutations are serialized by an advisory file lock (`flock` on a gitignored `.vars.lock`), so two simultaneous writes no longer race the git index, and a rename can't clobber a concurrently created key. The lock auto-releases if the process dies; it's a no-op on platforms without `flock` (vars targets Unix). - Key names are restricted to `[A-Za-z0-9_-]` segments separated by `/`. This rejects accents and other non-ASCII (which collide across machines under Unicode normalization), control characters, and path-traversal, keeping keys portable and predictable. +### Changed +- `vars dump` confirms before printing every secret in plaintext (the deliberate exception to the store's purpose). `--force`/`-f` skips the prompt and is required for non-interactive use, so a stray script can't mass-export every secret. `vars resolve` remains the command for feeding secrets into a process/pipe. + ### Fixed - `vars set` treated an existing key whose value can't be decrypted (corrupt or foreign file, or the wrong key loaded) as a brand-new key: `--skip` would overwrite it and a plain `set` replaced it silently. It now surfaces the read failure instead, matching `vars import`. +- `vars dump` fails once with a single message when the store's SSH key isn't available, instead of warning per key. The per-key skip+warn remains for individual unreadable files. ## [0.7.0] diff --git a/README.md b/README.md index f5f2b09..64f9676 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,41 @@ fingerprint in `store.json`. To force a specific key (a non-standard path or dis export VARS_SSH_KEY=~/.ssh/id_work ``` +### Using a dedicated decryption key + +You don't have to reuse your everyday login key. A common setup: a **separate key +just for vars**, kept alongside your others. + +```sh +ssh-keygen -t ed25519 -f ~/.ssh/id_vars # a key only for decrypting the store +``` + +- **Discovery is automatic.** vars scans `~/.ssh` and uses whichever key matches + the store's fingerprint, `~/.ssh/id_vars` (or any name) is found on its own. +- **It won't interfere with logging into servers.** A non-default filename like + `id_vars` is *not* offered by `ssh` or loaded by a bare `ssh-add`, so your login + key stays the one used for servers. +- **Keep it passphrase-protected.** When you run a command that needs it in a + terminal, vars runs `ssh-add ~/.ssh/id_vars` for you (prompting once) and proceeds; + after that it's cached in the agent for the session. + +Once `id_vars` is in the agent alongside your login key, it can be offered to +servers. To keep logins on your real key(s) only, set `IdentitiesOnly` globally and +**list each login key** (the `IdentityFile` lines accumulate into an allowlist; +anything not listed, including `id_vars`, is never offered): + +``` +# ~/.ssh/config +Host * + IdentitiesOnly yes + IdentityFile ~/.ssh/id_ed25519 # list every key you log in with + IdentityFile ~/.ssh/id_rsa + +Host github.com + IdentitiesOnly yes + IdentityFile ~/.ssh/id_github +``` + --- ## Security @@ -293,7 +328,7 @@ vars mv # rename a key (-f to skip the prompt) vars rm ... # delete keys (-f to skip the prompt) vars log # a key's change history (newest first) vars import [scope] # import key=value pairs from a .env file -vars dump # print all keys and values +vars dump # print all keys and values (-f to skip the confirm) vars init # scaffold .vars.yaml in the current directory vars resolve [flags] # resolve manifest keys as shell exports vars git # run git in the store directory @@ -302,7 +337,7 @@ vars clone # clone the store from a remote repo ``` `resolve` flags: `-f/--file`, `-p/--profile`, `--dotenv`, `--fish`, `--partial`, -`--origin`. `set`/`import` take `--replace`/`--skip`; `mv`/`rm` take `-f/--force`. +`--origin`. `set`/`import` take `--replace`/`--skip`; `mv`/`rm`/`dump` take `-f/--force`. --- diff --git a/cmd/09_dump.go b/cmd/09_dump.go index cf61253..2240cb5 100644 --- a/cmd/09_dump.go +++ b/cmd/09_dump.go @@ -3,28 +3,36 @@ package cmd import ( "fmt" "os" + "strings" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/vars-cli/vars/internal/format" + "github.com/vars-cli/vars/internal/session" + "github.com/vars-cli/vars/internal/vault" ) var ( dumpFish bool dumpDotenv bool + dumpForce bool ) func init() { dumpCmd.Flags().BoolVar(&dumpDotenv, "dotenv", false, "Output as KEY=value (for docker --env-file etc.)") dumpCmd.Flags().BoolVar(&dumpFish, "fish", false, "Output in fish shell format (set -x KEY value)") + dumpCmd.Flags().BoolVarP(&dumpForce, "force", "f", false, "Skip the confirmation prompt (for non-interactive use)") rootCmd.AddCommand(dumpCmd) } var dumpCmd = &cobra.Command{ Use: "dump", Short: "Dump all variables from the store", - Long: `Print all key/value pairs from the store. No manifest involved. -Intended for debugging and migration only.`, + Long: `Print every key and value from the store, in plaintext. No manifest involved. +Use it for migrating or debugging. For loading secrets into a process, use 'vars resolve' instead. + +Prompts for confirmation unless --force is given.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { formatter := format.Posix @@ -34,16 +42,42 @@ Intended for debugging and migration only.`, formatter = format.Dotenv } - fmt.Fprintln(os.Stderr, "vars: dumping all variables from the store") - v, err := openVault() if err != nil { return err } - keys, err := v.List() + keys, err := v.List() // no key needed; gives the count for the confirmation if err != nil { return InternalError(err.Error()) } + + // dump prints every secret in plaintext — the deliberate exception to the + // store's whole purpose. Confirm first (before unlocking, so a cancel is + // free), unless --force; refuse non-interactively so a stray script can't + // mass-export every secret without explicit intent. + if !dumpForce && len(keys) > 0 { + fmt.Fprintf(os.Stderr, "This prints all %d secret(s) in plaintext.\n", len(keys)) + if !term.IsTerminal(int(os.Stdin.Fd())) { + return UserError("dumping all secrets in plaintext requires confirmation; use --force for non-interactive use") + } + answer, err := stdinPrompter().Line("Continue? [y/N] ") + if err != nil { + return UserError(err.Error()) + } + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "y") { + fmt.Fprintln(os.Stderr, "Aborted") + return nil + } + } + + // Unlock the key (auto ssh-add when there's a terminal). Fail once here if + // it can't be resolved, rather than warning per key; the per-file skip + // below is only for individual unreadable files. + if meta, merr := vault.ReadMeta(storeDir()); merr == nil { + if err := session.EnsureKey(meta.KeyFingerprint); err != nil { + return UserError(err.Error()) + } + } // Recovery/migration path: dump everything we can, warn on what we can't, // and exit non-zero if any key failed — one bad file must not hide the rest. failed := false diff --git a/internal/crypto/sshderive/signer.go b/internal/crypto/sshderive/signer.go index ffaaead..db47c02 100644 --- a/internal/crypto/sshderive/signer.go +++ b/internal/crypto/sshderive/signer.go @@ -97,6 +97,21 @@ func sshString(b []byte) []byte { return out } +// FingerprintOfPubFile returns the SHA256 fingerprint of an OpenSSH public-key +// file (e.g. ~/.ssh/id_vars.pub). It needs no passphrase, so it's usable to find +// which key file matches a store, even when that key is passphrase-protected. +func FingerprintOfPubFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + pub, _, _, _, err := ssh.ParseAuthorizedKey(data) + if err != nil { + return "", err + } + return ssh.FingerprintSHA256(pub), nil +} + // supportedKeyType returns nil for deterministic key types and a clear, // actionable error otherwise. func supportedKeyType(pub ssh.PublicKey) error { diff --git a/internal/session/session.go b/internal/session/session.go index e0bac4a..68ac5c6 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -6,9 +6,13 @@ package session import ( "fmt" "os" + "os/exec" "path/filepath" + "strings" "sync" + "golang.org/x/term" + "github.com/vars-cli/vars/internal/crypto" "github.com/vars-cli/vars/internal/crypto/sshderive" "github.com/vars-cli/vars/internal/git" @@ -65,7 +69,7 @@ func newLazyBackend(fingerprint string) *lazyBackend { return &lazyBackend{finge func (l *lazyBackend) resolve() (crypto.Backend, error) { l.once.Do(func() { - signer, err := signerForFingerprint(l.fingerprint) + signer, err := ensureSigner(l.fingerprint) if err != nil { l.err = err return @@ -130,23 +134,104 @@ func signerForFingerprint(fp string) (*sshderive.Signer, error) { } conn.Close() // agent lacks the key — release before trying the key file } + // Find the key file whose fingerprint matches the store, anywhere in ~/.ssh + // (by its .pub, so the name needn't be id_ed25519 and the key may be + // passphrase-protected). If found but not directly loadable, name it so the + // user knows exactly which key to `ssh-add`. + if path := keyFileForFingerprint(fp); path != "" { + if s, err := sshderive.FromFile(path); err == nil && s.Fingerprint() == fp { + return s, nil + } + return nil, fmt.Errorf("this store's key is %s.\nLoad it with `ssh-add %s`", path, path) + } if path := defaultKeyPath(); path != "" { if s, err := sshderive.FromFile(path); err == nil && (fp == "" || s.Fingerprint() == fp) { return s, nil } } - return nil, fmt.Errorf("could not find the SSH key this store needs (%s); load it with `ssh-add`, or point VARS_SSH_KEY at its file", fp) + return nil, fmt.Errorf("could not find the SSH key this store needs (%s).\nLoad it with `ssh-add`, or point VARS_SSH_KEY at its file.", fp) } -// KeyAvailable reports whether the SSH key matching fingerprint can be resolved -// right now (VARS_SSH_KEY / ssh-agent / default file). Used by `vars clone` to -// tell the user if they're ready to decrypt; this key may differ from the one -// that authenticated the git clone. +// keyFileForFingerprint scans ~/.ssh for the private key whose public half +// matches fp, returning its path (the matched .pub without the suffix) or "". +// Matching by fingerprint means a dedicated decryption key needs no special name +// and no VARS_SSH_KEY: drop it in ~/.ssh and vars finds it. +func keyFileForFingerprint(fp string) string { + if fp == "" { + return "" + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + pubs, _ := filepath.Glob(filepath.Join(home, ".ssh", "*.pub")) + for _, pub := range pubs { + if f, err := sshderive.FingerprintOfPubFile(pub); err == nil && f == fp { + return strings.TrimSuffix(pub, ".pub") + } + } + return "" +} + +// EnsureKey resolves the store's key, auto-loading it via ssh-add when possible +// (see ensureSigner), and returns nil on success or the resolution error. Used by +// `vars dump` to unlock-and-fail-once up front instead of warning per key. +func EnsureKey(fingerprint string) error { + _, err := ensureSigner(fingerprint) + return err +} + +// KeyAvailable reports whether the SSH key matching fingerprint resolves right +// now, without prompting. Used by `vars clone` to tell the user if they're ready +// to decrypt; this key may differ from the one that authenticated the clone. +// Stays non-interactive on purpose (a readiness check must never prompt). func KeyAvailable(fingerprint string) bool { _, err := signerForFingerprint(fingerprint) return err == nil } +// ensureSigner resolves the store's key and, if it isn't loaded yet, loads the +// matching key file into ssh-agent with `ssh-add` (which prompts for its +// passphrase), then retries, so a single command unlocks and runs in one go. It +// only does this when there's an agent to load into and a terminal to prompt on +// (otherwise it would hang), and only for the key vars discovered in ~/.ssh, not +// an explicit VARS_SSH_KEY (that's a strict, deliberate choice) and not the whole +// default set, so it touches exactly the one key this store needs. +func ensureSigner(fingerprint string) (*sshderive.Signer, error) { + signer, err := signerForFingerprint(fingerprint) + if err == nil || !canPromptForKey() || os.Getenv("VARS_SSH_KEY") != "" { + return signer, err + } + path := keyFileForFingerprint(fingerprint) + if path == "" { + return signer, err // don't know which file to load; keep the original error + } + fmt.Fprintf(os.Stderr, "vars: loading %s into ssh-agent...\n", path) + if addErr := runSSHAdd(path); addErr != nil { + return nil, err // keep the original, actionable error + } + return signerForFingerprint(fingerprint) +} + +// canPromptForKey reports whether ssh-add could succeed: an agent to add the key +// to, and a terminal to prompt the passphrase on. A real TTY is required (not +// SSH_ASKPASS) so non-interactive contexts get a clean error instead of hanging +// on a passphrase that can never arrive. Use `ssh -t host vars …` to get a TTY. +func canPromptForKey() bool { + if os.Getenv("SSH_AUTH_SOCK") == "" { + return false + } + return term.IsTerminal(int(os.Stdin.Fd())) || term.IsTerminal(int(os.Stderr.Fd())) +} + +// runSSHAdd loads a specific key file into the agent. ssh-add prompts on the +// terminal; its stdout goes to stderr so it never pollutes command output. +func runSSHAdd(path string) error { + cmd := exec.Command("ssh-add", path) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stderr, os.Stderr + return cmd.Run() +} + // UsableInitSigners returns candidate keys for creating a new store: the // VARS_SSH_KEY file if set, otherwise every usable key in ssh-agent, otherwise // the default key file. Callers pick one (auto when there is exactly one). diff --git a/internal/session/session_test.go b/internal/session/session_test.go index c84c20f..520de24 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -34,6 +34,92 @@ func writeKey(t *testing.T) (path, fingerprint string) { return path, ssh.FingerprintSHA256(ss.PublicKey()) } +// writeKeyInDir writes a key (and its .pub) at sshDir/name, optionally +// passphrase-protected, and returns its SHA256 fingerprint. +func writeKeyInDir(t *testing.T, sshDir, name, passphrase string) (fingerprint string) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + var block *pem.Block + if passphrase == "" { + block, err = ssh.MarshalPrivateKey(priv, "") + } else { + block, err = ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte(passphrase)) + } + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sshDir, name), pem.EncodeToMemory(block), 0o600); err != nil { + t.Fatal(err) + } + ss, _ := ssh.NewSignerFromKey(priv) + if err := os.WriteFile(filepath.Join(sshDir, name+".pub"), ssh.MarshalAuthorizedKey(ss.PublicKey()), 0o644); err != nil { + t.Fatal(err) + } + return ssh.FingerprintSHA256(ss.PublicKey()) +} + +// canPromptForKey must be false without an agent, so ensureSigner never shells +// out to ssh-add (which would block) when there's nothing to load the key into. +func TestCanPromptForKey_NoAgent(t *testing.T) { + t.Setenv("SSH_AUTH_SOCK", "") + if canPromptForKey() { + t.Fatal("canPromptForKey must be false with no agent") + } +} + +// ensureSigner returns a directly-loadable key as-is, without attempting ssh-add. +func TestEnsureSigner_ReturnsAvailableKey(t *testing.T) { + keyPath, fp := writeKey(t) + t.Setenv("VARS_SSH_KEY", keyPath) + s, err := ensureSigner(fp) + if err != nil { + t.Fatalf("ensureSigner: %v", err) + } + if s.Fingerprint() != fp { + t.Fatalf("fingerprint = %s, want %s", s.Fingerprint(), fp) + } +} + +// vars finds the store's key by fingerprint anywhere in ~/.ssh, even with a +// non-default filename and no VARS_SSH_KEY / agent. +func TestSignerForFingerprint_DiscoversByFingerprint(t *testing.T) { + home := t.TempDir() + fp := writeKeyInDir(t, filepath.Join(home, ".ssh"), "id_vars", "") + t.Setenv("VARS_SSH_KEY", "") + t.Setenv("SSH_AUTH_SOCK", "") + t.Setenv("HOME", home) + s, err := signerForFingerprint(fp) + if err != nil { + t.Fatalf("discovery should find id_vars: %v", err) + } + if s.Fingerprint() != fp { + t.Fatalf("fingerprint = %s, want %s", s.Fingerprint(), fp) + } +} + +// When the matching key is found but passphrase-protected (and not in the agent), +// the error names the exact file to ssh-add instead of a generic hint. +func TestSignerForFingerprint_DiscoveredEncryptedNamesFile(t *testing.T) { + home := t.TempDir() + fp := writeKeyInDir(t, filepath.Join(home, ".ssh"), "id_vars", "pw") + t.Setenv("VARS_SSH_KEY", "") + t.Setenv("SSH_AUTH_SOCK", "") + t.Setenv("HOME", home) + _, err := signerForFingerprint(fp) + if err == nil { + t.Fatal("expected an error for a passphrase-protected key") + } + if !strings.Contains(err.Error(), "id_vars") || !strings.Contains(err.Error(), "ssh-add") { + t.Fatalf("error should name the key file and ssh-add, got: %v", err) + } +} + // noKeyEnv neutralizes every SSH key source (env override, agent, default file) // so the resolver finds nothing: the "no key available" edge. func noKeyEnv(t *testing.T) { diff --git a/scripts/smoke.sh b/scripts/smoke.sh index b23f61e..ea44f5f 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -96,7 +96,7 @@ $BIN rm RENAMED_A --force >/dev/null ! $BIN get RENAMED_A 2>/dev/null echo "--- dump ---" -contains "$($BIN dump --dotenv 2>/dev/null)" "ETHERSCAN_API=" +contains "$($BIN dump --force --dotenv 2>/dev/null)" "ETHERSCAN_API=" echo "--- version ---" contains "$($BIN --version)" "vars" diff --git a/test/e2e/integration_test.go b/test/e2e/integration_test.go index e24a05f..4ae6dfc 100644 --- a/test/e2e/integration_test.go +++ b/test/e2e/integration_test.go @@ -422,11 +422,21 @@ func TestDump(t *testing.T) { r := newRunner(t) r.mustRun("set", "A", "1") r.mustRun("set", "prod/B", "2") - out := r.mustRun("dump", "--dotenv") + out := r.mustRun("dump", "--force", "--dotenv") has(t, out, "A=1") has(t, out, "prod/B=2") } +// dump prints every secret in plaintext, so it confirms (or takes --force) and +// refuses non-interactively without it, like rm/mv. +func TestDumpRequiresForceWhenNonInteractive(t *testing.T) { + r := newRunner(t) + r.mustRun("set", "A", "1") + _, se := r.mustFail("dump") // no --force, no TTY + has(t, se, "requires confirmation") + r.mustRun("dump", "--force") // --force proceeds +} + // --- log (git-backed; skipped where git isn't functional) --- func TestLog(t *testing.T) { @@ -619,11 +629,11 @@ func TestDumpResilientToDotenvNewline(t *testing.T) { r.mustRun("set", "NORMAL", "ok") r.runStdin("a\nb", "set", "PEM", "-") // --dotenv can't represent the multi-line value: skip+warn+nonzero, keep the rest. - so, se := r.mustFail("dump", "--dotenv") + so, se := r.mustFail("dump", "--force", "--dotenv") has(t, so, "NORMAL=ok") has(t, se, "skipping") // posix dump handles both. - all := r.mustRun("dump") + all := r.mustRun("dump", "--force") has(t, all, "export NORMAL='ok'") has(t, all, "export PEM='a\nb'") } From e693dbc5384c0fd64c0cff0b18b71751e31c0a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Mon, 22 Jun 2026 11:45:10 +0000 Subject: [PATCH 2/6] Reordering commands --- cmd/{02_set.go => 01_set.go} | 0 cmd/{03_get.go => 02_get.go} | 0 cmd/{03_get_test.go => 02_get_test.go} | 0 cmd/{04_resolve.go => 03_resolve.go} | 0 cmd/{05_ls.go => 04_ls.go} | 0 cmd/{05b_scope.go => 05_scope.go} | 0 cmd/{07b_log.go => 08_log.go} | 0 cmd/{11_clone.go => 09_clone.go} | 0 cmd/{08_import.go => 10_import.go} | 0 cmd/{09_dump.go => 11_dump.go} | 0 10 files changed, 0 insertions(+), 0 deletions(-) rename cmd/{02_set.go => 01_set.go} (100%) rename cmd/{03_get.go => 02_get.go} (100%) rename cmd/{03_get_test.go => 02_get_test.go} (100%) rename cmd/{04_resolve.go => 03_resolve.go} (100%) rename cmd/{05_ls.go => 04_ls.go} (100%) rename cmd/{05b_scope.go => 05_scope.go} (100%) rename cmd/{07b_log.go => 08_log.go} (100%) rename cmd/{11_clone.go => 09_clone.go} (100%) rename cmd/{08_import.go => 10_import.go} (100%) rename cmd/{09_dump.go => 11_dump.go} (100%) diff --git a/cmd/02_set.go b/cmd/01_set.go similarity index 100% rename from cmd/02_set.go rename to cmd/01_set.go diff --git a/cmd/03_get.go b/cmd/02_get.go similarity index 100% rename from cmd/03_get.go rename to cmd/02_get.go diff --git a/cmd/03_get_test.go b/cmd/02_get_test.go similarity index 100% rename from cmd/03_get_test.go rename to cmd/02_get_test.go diff --git a/cmd/04_resolve.go b/cmd/03_resolve.go similarity index 100% rename from cmd/04_resolve.go rename to cmd/03_resolve.go diff --git a/cmd/05_ls.go b/cmd/04_ls.go similarity index 100% rename from cmd/05_ls.go rename to cmd/04_ls.go diff --git a/cmd/05b_scope.go b/cmd/05_scope.go similarity index 100% rename from cmd/05b_scope.go rename to cmd/05_scope.go diff --git a/cmd/07b_log.go b/cmd/08_log.go similarity index 100% rename from cmd/07b_log.go rename to cmd/08_log.go diff --git a/cmd/11_clone.go b/cmd/09_clone.go similarity index 100% rename from cmd/11_clone.go rename to cmd/09_clone.go diff --git a/cmd/08_import.go b/cmd/10_import.go similarity index 100% rename from cmd/08_import.go rename to cmd/10_import.go diff --git a/cmd/09_dump.go b/cmd/11_dump.go similarity index 100% rename from cmd/09_dump.go rename to cmd/11_dump.go From 23d08bf50ab077cd1ccba7ff897caa1aed0aad56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Mon, 22 Jun 2026 11:58:12 +0000 Subject: [PATCH 3/6] Adding vars info --- CHANGELOG.md | 1 + README.md | 1 + cmd/13_info.go | 73 ++++++++++++++++++++++++++++++++++++ internal/git/git.go | 19 ++++++++++ internal/session/session.go | 31 +++++++++++++++ test/e2e/integration_test.go | 15 ++++++++ 6 files changed, 140 insertions(+) create mode 100644 cmd/13_info.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5802c08..fcd1bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [0.8.0] ### Added +- `vars info` prints a read-only summary of the store: its location, the SSH key it's encrypted to and whether that key is available right now (and from where, agent, a `~/.ssh` file, or `VARS_SSH_KEY`), the secret/scope counts, and local git state. It needs no key and touches no network, so it's the command to run when you can't decrypt and want to know why. - `vars clone ` clones an existing store from a git remote into your local store directory (e.g. to set up from a store you already pushed), instead of creating a fresh, divergent one. It replaces an empty local store but refuses to overwrite one that holds secrets, locks the store directory to `0700`, and reports whether the SSH key the store is encrypted to is available (that key may differ from the one that authenticated the clone). `git clone` sets `origin`, so `vars sync` works immediately. ### Changed diff --git a/README.md b/README.md index 64f9676..0c9beed 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,7 @@ vars resolve [flags] # resolve manifest keys as shell exports vars git # run git in the store directory vars sync # pull + push the store to its remote vars clone # clone the store from a remote repo +vars info # store location, key + readiness, counts, git (read-only) ``` `resolve` flags: `-f/--file`, `-p/--profile`, `--dotenv`, `--fish`, `--partial`, diff --git a/cmd/13_info.go b/cmd/13_info.go new file mode 100644 index 0000000..537f3ce --- /dev/null +++ b/cmd/13_info.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/vars-cli/vars/internal/git" + "github.com/vars-cli/vars/internal/session" + "github.com/vars-cli/vars/internal/vault" +) + +func init() { + rootCmd.AddCommand(infoCmd) +} + +var infoCmd = &cobra.Command{ + Use: "info", + Short: "Show store location, key, readiness, and git state", + Long: `Print a summary of the store: where it lives, which SSH key it's +encrypted to and whether that key is available right now (and from where), how +many secrets and scopes it holds, and its local git state. + +Needs no key and touches no network, so it's the command to run when you can't +decrypt and want to know why.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + dir := storeDir() + if !vault.Exists(dir) { + return UserError(fmt.Sprintf("no vars store at %s (run `vars` to create one)", dir)) + } + meta, err := vault.ReadMeta(dir) + if err != nil { + return UserError(err.Error()) + } + + scheme := meta.Scheme + if scheme != session.Scheme { + scheme += " (unsupported by this build)" + } + fmt.Printf("Store: %s\n", dir) + fmt.Printf("Scheme: %s\n", scheme) + fmt.Printf("Key: %s %s\n", meta.KeyFingerprint, session.KeyStatus(meta.KeyFingerprint)) + + // Counts: lazy backend, so no key is needed to list the files. + if v, oerr := session.Open(dir); oerr == nil { + keys, _ := v.List() + scopes, _ := v.Scopes() + scopeWord := "scopes" + if len(scopes) == 1 { + scopeWord = "scope" + } + fmt.Printf("Secrets: %d (%d %s)\n", len(keys), len(scopes), scopeWord) + } + + // Git: local state only (no fetch, so it stays offline/instant). + switch { + case !git.Available() || !git.IsRepo(dir): + fmt.Println("Git: not versioned") + default: + repo := git.New(dir) + line := "repo, no remote" + if url := repo.RemoteURL(); url != "" { + line = "repo, remote " + url + } + if repo.HasUncommittedChanges() { + line += " (uncommitted changes; run `vars sync`)" + } + fmt.Printf("Git: %s\n", line) + } + return nil + }, +} diff --git a/internal/git/git.go b/internal/git/git.go index ff74c81..c7a6292 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -240,6 +240,25 @@ func (r *Repo) firstRemote() string { return "origin" } +// RemoteURL returns the URL of the first configured remote, or "" if none. +func (r *Repo) RemoteURL() string { + if !r.HasRemote() { + return "" + } + out, err := r.run("remote", "get-url", r.firstRemote()) + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + +// HasUncommittedChanges reports whether the working tree has changes git would +// commit (e.g. left behind when a best-effort auto-commit failed). +func (r *Repo) HasUncommittedChanges() bool { + out, err := r.run("status", "--porcelain") + return err == nil && strings.TrimSpace(out) != "" +} + // gitExec runs a git subcommand in dir and returns combined output. func gitExec(dir string, args ...string) (string, error) { var buf bytes.Buffer diff --git a/internal/session/session.go b/internal/session/session.go index 68ac5c6..4a1ae34 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -190,6 +190,37 @@ func KeyAvailable(fingerprint string) bool { return err == nil } +// KeyStatus describes, in one line, whether the store's key resolves right now +// and from where. For `vars info`: read-only and non-interactive (never loads a +// key or prompts). Mirrors signerForFingerprint's resolution order, keep in sync. +func KeyStatus(fingerprint string) string { + if path := os.Getenv("VARS_SSH_KEY"); path != "" { + s, err := sshderive.FromFile(path) + switch { + case err != nil: + return fmt.Sprintf("VARS_SSH_KEY %s set but unusable: %v", path, err) + case fingerprint != "" && s.Fingerprint() != fingerprint: + return fmt.Sprintf("VARS_SSH_KEY points at %s, not this store's key", s.Fingerprint()) + default: + return fmt.Sprintf("available (VARS_SSH_KEY: %s)", path) + } + } + if ag, conn, err := sshderive.DialAgent(); err == nil { + _, ferr := sshderive.FromAgent(ag, fingerprint) + conn.Close() + if ferr == nil { + return "available (loaded in ssh-agent)" + } + } + if path := keyFileForFingerprint(fingerprint); path != "" { + if s, err := sshderive.FromFile(path); err == nil && s.Fingerprint() == fingerprint { + return fmt.Sprintf("available (%s)", path) + } + return fmt.Sprintf("not loaded: run `ssh-add %s`", path) + } + return "not found: no key in ~/.ssh matches; load with `ssh-add` or set VARS_SSH_KEY" +} + // ensureSigner resolves the store's key and, if it isn't loaded yet, loads the // matching key file into ssh-agent with `ssh-add` (which prompts for its // passphrase), then retries, so a single command unlocks and runs in one go. It diff --git a/test/e2e/integration_test.go b/test/e2e/integration_test.go index 4ae6dfc..28dd8ec 100644 --- a/test/e2e/integration_test.go +++ b/test/e2e/integration_test.go @@ -427,6 +427,21 @@ func TestDump(t *testing.T) { has(t, out, "prod/B=2") } +// info is a read-only, keyless diagnostic: store path, key + readiness, counts, git. +func TestInfo(t *testing.T) { + r := newRunner(t) + r.mustFail("info") // no store yet + r.mustRun("set", "A", "1") + r.mustRun("set", "prod/B", "2") + + out := r.mustRun("info") + has(t, out, "Store:") + has(t, out, "ssh-v1") + has(t, out, "Secrets: 2 (1 scope)") + has(t, out, "available") // the runner pins VARS_SSH_KEY, so the key resolves + has(t, out, "Git:") +} + // dump prints every secret in plaintext, so it confirms (or takes --force) and // refuses non-interactively without it, like rm/mv. func TestDumpRequiresForceWhenNonInteractive(t *testing.T) { From b904d2d132cca0734597fc2da49abbb0f0291160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Mon, 22 Jun 2026 12:01:26 +0000 Subject: [PATCH 4/6] Command reorder --- cmd/{12_git.go => 10_git.go} | 2 +- cmd/{10_import.go => 11_import.go} | 0 cmd/{11_dump.go => 12_dump.go} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename cmd/{12_git.go => 10_git.go} (100%) rename cmd/{10_import.go => 11_import.go} (100%) rename cmd/{11_dump.go => 12_dump.go} (100%) diff --git a/cmd/12_git.go b/cmd/10_git.go similarity index 100% rename from cmd/12_git.go rename to cmd/10_git.go index 0e5f54f..1ee69fb 100644 --- a/cmd/12_git.go +++ b/cmd/10_git.go @@ -11,8 +11,8 @@ import ( ) func init() { - rootCmd.AddCommand(gitCmd) rootCmd.AddCommand(syncCmd) + rootCmd.AddCommand(gitCmd) } var gitCmd = &cobra.Command{ diff --git a/cmd/10_import.go b/cmd/11_import.go similarity index 100% rename from cmd/10_import.go rename to cmd/11_import.go diff --git a/cmd/11_dump.go b/cmd/12_dump.go similarity index 100% rename from cmd/11_dump.go rename to cmd/12_dump.go From 8a6d68026422e8b6ee5c514ad2b4560000acd551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Mon, 22 Jun 2026 12:07:13 +0000 Subject: [PATCH 5/6] Changelog --- CHANGELOG.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd1bb4..4e489cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,26 +4,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). -## [0.8.0] +## [0.9.0] ### Added - `vars info` prints a read-only summary of the store: its location, the SSH key it's encrypted to and whether that key is available right now (and from where, agent, a `~/.ssh` file, or `VARS_SSH_KEY`), the secret/scope counts, and local git state. It needs no key and touches no network, so it's the command to run when you can't decrypt and want to know why. -- `vars clone ` clones an existing store from a git remote into your local store directory (e.g. to set up from a store you already pushed), instead of creating a fresh, divergent one. It replaces an empty local store but refuses to overwrite one that holds secrets, locks the store directory to `0700`, and reports whether the SSH key the store is encrypted to is available (that key may differ from the one that authenticated the clone). `git clone` sets `origin`, so `vars sync` works immediately. ### Changed - The SSH key is found by **fingerprint** across `~/.ssh`, not just the conventional `id_ed25519`/`id_rsa` names. So a dedicated decryption key under any filename (e.g. `~/.ssh/id_vars`) is picked up automatically, no `VARS_SSH_KEY` needed. When the matching key is found but can't be loaded (passphrase-protected and not in the agent), the error names the exact file: `load it with ssh-add `. - When a command needs the key and it isn't loaded, vars runs `ssh-add` on **that specific key** (prompting for its passphrase) and proceeds in one go, but only when there's a terminal to prompt on and an agent to load into. Non-interactive runs get a clean error instead of hanging, and an explicit `VARS_SSH_KEY` is left strict (not auto-loaded). Use `ssh -t host vars …` to get a prompt over SSH. +- `vars dump` confirms before printing every secret in plaintext (the deliberate exception to the store's purpose). `--force`/`-f` skips the prompt and is required for non-interactive use, so a stray script can't mass-export every secret. `vars resolve` remains the command for feeding secrets into a process/pipe. + +### Fixed +- `vars dump` fails once with a single message when the store's SSH key isn't available, instead of warning per key. The per-key skip+warn remains for individual unreadable files. + +## [0.8.0] + +### Added +- `vars clone ` clones an existing store from a git remote into your local store directory (e.g. to set up from a store you already pushed), instead of creating a fresh, divergent one. It replaces an empty local store but refuses to overwrite one that holds secrets, locks the store directory to `0700`, and reports whether the SSH key the store is encrypted to is available (that key may differ from the one that authenticated the clone). `git clone` sets `origin`, so `vars sync` works immediately. + +### Changed - Writing to a store self-heals its static scaffolding: a missing `README.md`, `.gitignore`, or `.gitattributes` is recreated on the next write (a deleted `.gitignore` re-arms the default-deny allowlist before secrets are committed). Existing files are never overwritten. The scaffold now has a single source of truth in the `vault` package, shared by store creation and writes. - `vars ls ` accepts only a scope. - Concurrent mutations are serialized by an advisory file lock (`flock` on a gitignored `.vars.lock`), so two simultaneous writes no longer race the git index, and a rename can't clobber a concurrently created key. The lock auto-releases if the process dies; it's a no-op on platforms without `flock` (vars targets Unix). - Key names are restricted to `[A-Za-z0-9_-]` segments separated by `/`. This rejects accents and other non-ASCII (which collide across machines under Unicode normalization), control characters, and path-traversal, keeping keys portable and predictable. -### Changed -- `vars dump` confirms before printing every secret in plaintext (the deliberate exception to the store's purpose). `--force`/`-f` skips the prompt and is required for non-interactive use, so a stray script can't mass-export every secret. `vars resolve` remains the command for feeding secrets into a process/pipe. - ### Fixed - `vars set` treated an existing key whose value can't be decrypted (corrupt or foreign file, or the wrong key loaded) as a brand-new key: `--skip` would overwrite it and a plain `set` replaced it silently. It now surfaces the read failure instead, matching `vars import`. -- `vars dump` fails once with a single message when the store's SSH key isn't available, instead of warning per key. The per-key skip+warn remains for individual unreadable files. ## [0.7.0] From e41888682ece236e5446045f555945e4449c4b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Mon, 22 Jun 2026 12:12:28 +0000 Subject: [PATCH 6/6] Minor improvements --- CHANGELOG.md | 1 + cmd/10_git.go | 5 +++-- internal/git/git.go | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e489cd..81a0b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - The SSH key is found by **fingerprint** across `~/.ssh`, not just the conventional `id_ed25519`/`id_rsa` names. So a dedicated decryption key under any filename (e.g. `~/.ssh/id_vars`) is picked up automatically, no `VARS_SSH_KEY` needed. When the matching key is found but can't be loaded (passphrase-protected and not in the agent), the error names the exact file: `load it with ssh-add `. - When a command needs the key and it isn't loaded, vars runs `ssh-add` on **that specific key** (prompting for its passphrase) and proceeds in one go, but only when there's a terminal to prompt on and an agent to load into. Non-interactive runs get a clean error instead of hanging, and an explicit `VARS_SSH_KEY` is left strict (not auto-loaded). Use `ssh -t host vars …` to get a prompt over SSH. - `vars dump` confirms before printing every secret in plaintext (the deliberate exception to the store's purpose). `--force`/`-f` skips the prompt and is required for non-interactive use, so a stray script can't mass-export every secret. `vars resolve` remains the command for feeding secrets into a process/pipe. +- `vars sync` reports the remote it synced with (`Store synced with `); `clone` and `sync` sit together in `vars help`. ### Fixed - `vars dump` fails once with a single message when the store's SSH key isn't available, instead of warning per key. The per-key skip+warn remains for individual unreadable files. diff --git a/cmd/10_git.go b/cmd/10_git.go index 1ee69fb..c578e6d 100644 --- a/cmd/10_git.go +++ b/cmd/10_git.go @@ -52,10 +52,11 @@ var syncCmd = &cobra.Command{ if !git.Available() || !git.IsRepo(dir) { return UserError("the store is not a git repo; nothing to sync") } - if err := git.New(dir).Sync(); err != nil { + repo := git.New(dir) + if err := repo.Sync(); err != nil { return UserError(err.Error()) } - fmt.Fprintln(os.Stderr, "Synced") + fmt.Fprintf(os.Stderr, "Store synced with %s\n", repo.Remote()) return nil }, } diff --git a/internal/git/git.go b/internal/git/git.go index c7a6292..b9faaaa 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -240,6 +240,9 @@ func (r *Repo) firstRemote() string { return "origin" } +// Remote returns the name of the first configured remote (e.g. "origin"). +func (r *Repo) Remote() string { return r.firstRemote() } + // RemoteURL returns the URL of the first configured remote, or "" if none. func (r *Repo) RemoteURL() string { if !r.HasRemote() {