From Shell Scripts to Go: Building a Multi-Vault Secret Management Library
I needed multi-vault support without duplicating scripts or breaking existing users--so I designed a vault interface in shell, then ported it 1:1 into Go. Learn about interface-first design, compatibility-preserving rewrites, and when to evolve from shell to Go.
- tags
- #Go #Golang #Shell-Scripting #Zsh #Secret-Management #Bitwarden #1password #Pass #Vault-Abstraction #Dotfiles #Cli-Tools
- categories
- Go-Libraries Tutorials
- published
- reading time
- 8 minutes
What I Built
I started with Bitwarden-only shell scripts to restore secrets for my dotfiles. That worked fine, but I wanted flexibility to switch backends without rewriting every script–different environments might require different vaults. So I built a vault abstraction in shell and then ported that interface 1:1 into Go.
The result is vaultmux : a library that keeps vault choice invisible to consumers, improves performance and testability, and lets the shell and Go implementations coexist without breaking existing workflows.
This pattern means you can change vault backends without rewriting every script that touches secrets.
The Lock-In: Hardcoded Backend Calls
My dotfiles began with hardcoded Bitwarden calls. They were simple, fast to write, and totally locked to one backend:
| |
This worked fine for solo use. But I wanted the flexibility to switch backends without rewriting every script. Different environments have different constraints–some can’t use cloud vaults, some require specific tools, some need offline-first workflows.
Rather than wait until I hit a hard constraint, I built the abstraction upfront.
The Insight: Shared Operations
All three vaults do the same things–store, retrieve, list, sync. They just have different CLIs.
That’s the click.
If I define a common interface for those operations, consumer code never needs to know which vault it’s using. The backend becomes a configuration choice, not a fork in the code.
The Shell Interface
I defined the operations every backend must support. I kept the interface intentionally small and stable–easier to implement, easier to trust. Here’s the core:
| |
(Full interface: 14 ops–see _interface.md )
The abstraction layer (~600 lines in lib/_vault.sh) loads backends dynamically:
| |
Now the restore script becomes backend-agnostic:
| |
What I Gained: Zero-Cost Switching
This is the point where the abstraction pays for itself.
Switching vaults requires zero code changes:
| |
Consumer code (restore-ssh.sh, restore-aws.sh, etc.) never changes. The backend is a runtime choice.
Each backend implements the same interface but calls different CLIs:
| |
Bitwarden needs sessions; pass uses gpg-agent. Consumer code never knows the difference.
Why Shell Hit Its Ceiling
The shell abstraction worked in production for months. But I hit limits:
Performance: Process Overhead
Shell scripts spawn processes constantly:
| |
Restoring a dozen secrets took 20-30 seconds on my setup. Not terrible, but slow enough to be annoying during development when I’d reset my environment frequently.
The win wasn’t “Go is magically faster”–it’s that I reduced process churn. Go spawns the vault CLI once per item (no jq subprocess), uses native JSON parsing, and caches session validation. That dropped restore time to ~1-2 seconds (an order-of-magnitude improvement).
Testability: No Mocking Framework
Shell tests with bats exist, but coverage tools are limited. I had ~60% coverage and couldn’t easily improve it. No structured mocking, no type safety.
Go’s mock backend made testing trivial:
| |
I hit >90% coverage in the Go version with comprehensive error scenario tests.
Error Handling: String Parsing
Shell error handling is brittle:
| |
Go has proper error types:
| |
The Go Rewrite: Same Interface, Better Runtime
I ported the shell interface to Go as vaultmux , a standalone library.
Same Operations, Stronger Types
The full interface mirrors the shell design, with typed sessions and structured errors:
| |
Shell sessions were strings; Go has a proper interface:
| |
Context-Aware Operations
Shell has no timeout mechanism. Go uses context.Context everywhere:
| |
Backend Registration Pattern
Shell dynamically sources files. Go uses init registration to avoid import cycles:
| |
Consumer just imports backend packages:
| |
What “Production-Ready” Means
I shipped vaultmux v0.1.0 with:
- Stable interface (no breaking changes planned)
- Mock backend included for unit testing
- Error taxonomy (
ErrNotFound,ErrSessionExpired, etc.) - Context timeouts on all operations
- >90% test coverage on core library
This isn’t just “it works on my machine”–it’s designed for third parties to depend on. It’s intentionally small, stable, and designed to be embedded.
The Migration Strategy: Coexistence
Shipping the Go rewrite as a separate library instead of replacing shell scripts in-place meant:
- My existing shell scripts saw zero breakage
- I could iterate on Go independently
- Shell and Go coexist during transition (strangler fig pattern)
Blackdot now uses both:
- Shell scripts for interactive operations (setup wizard, drift detection)
- Go binary for performance-critical paths (bulk restore, CI/CD)
The shell script calling vault_get_notes and the Go program calling backend.GetNotes() hit the same bw command under the hood. Same backend CLIs, same behavior, different coordination layers.
When to Use Which
Use shell abstraction when:
- You need interactive prompts (setup wizards)
- Performance doesn’t matter (one-time operations)
- Maximum portability matters (any Unix with zsh)
Use Go library when:
- Performance matters (bulk operations, CI/CD)
- Type safety helps (complex logic, error scenarios)
- Comprehensive tests are needed (>90% coverage)
In practice, both coexist. Shell scripts aren’t going anywhere–the Go version is additive, not disruptive.
Lessons Learned
1. Interface-First Design Transfers
Defining the 14-operation interface before implementing backends saved months. When I ported to Go, the interface translated 1:1. No architectural surprises.
2. Make Rewrites Additive
Shipping as a separate library (vaultmux) instead of replacing shell scripts meant zero breakage. This is how you ship architectural changes in production–make them additive, not destructive.
3. Shell Scripts Scale Further Than You Think
Shell abstraction worked in production for months before I needed Go. Don’t jump to Go prematurely–shell scripts with good design can handle more than you expect.
But when performance or testability become blockers, Go is the right evolution target.
4. Shell Out to CLIs, Don’t Reimplement
I could have reimplemented Bitwarden’s API in Go (talking to their server directly). I chose to shell out to bw instead.
Why? The CLI is battle-tested. Bitwarden handles auth, encryption, edge cases, API changes. I just coordinate the CLI. My library is ~300 lines per backend instead of thousands.
Get Started
The Go library is open source:
| |
Example usage:
| |
Want to add a new backend? See the extension guide for HashiCorp Vault, AWS Secrets Manager, etc.
Code: github.com/blackwell-systems/vaultmux
Shell version: github.com/blackwell-systems/blackdot (lib/_vault.sh)
Docs: Extension guide
License: MIT