ttrun

command module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 7, 2026 License: MIT Imports: 38 Imported by: 0

README

ttrun

ttrun runs a command with environment variables loaded from a file, resolving secret references along the way. It supports two secret backends:

  • local -- secrets stored encrypted in a YAML file under your config directory, with the encryption key kept in the OS keychain
  • Vault -- secrets fetched from HashiCorp Vault using the vault CLI

Installation

go install github.com/ttab/ttrun@latest

For Vault secrets, the vault CLI must be available. The local store has no external dependencies — values are encrypted with AES-256-GCM and the key lives in your OS keychain (Secret Service on Linux, Keychain on macOS, Credential Manager on Windows).

Usage

ttrun [global options] -- command [args...]
ttrun serve [--listen=:22022]
ttrun set <secret-path>
ttrun get <secret-path>
ttrun ls [prefix]
ttrun pull [envfile]
ttrun configure <key> <value>
ttrun profile set <name> <key> <value>
ttrun direnv [envfile]
ttrun direnv hook

Global options (persistent across subcommands):

  • -h, --help — show usage information
  • -v, --verbose — print each subprocess command to stderr and show their stderr output
  • -p, --profile <name> — use a named profile for config overrides and variables
  • --remote <addr> — resolve via a remote ttrun serve instance; pass --remote= to override a configured default
  • -e, --env <path> — env file to read for the run action (default: ttrun.env)
  • -d, --debug — print resolved environment variables and exit (no command spawned)

The default action (no subcommand) reads the env file, resolves secrets, and runs the command after --. The -- separator is required because urfave/cli treats unknown positionals as candidate subcommand names; without --, ttrun ls -la would dispatch to the ls subcommand instead of running ls -la as a child process.

run, direnv, and pull resolve the active profile from (highest priority first):

  1. --profile/-p CLI flag
  2. TTRUN_PROFILE environment variable
  3. Front matter in the env file (see below)

A warning is printed when the CLI flag or environment variable overrides a front matter profile.

configure only targets a profile when --profile is explicitly passed on the command line; TTRUN_PROFILE and front matter are ignored.

Env file format

The env file contains KEY=value lines. Empty lines and lines starting with # are ignored.

An optional YAML front matter block can appear at the top of the file, terminated by a --- line. The front matter can specify a default profile:

profile: staging
---
KEY=value

Values can contain {{...}} references that are resolved before the command is started. Everything else is passed through as-is.

# Plain values
ADDR=:4280
PROFILE_ADDR=:4281
REPOSITORY_ENDPOINT=http://localhost:1080

# Secrets from the local encrypted store
GEMINI_API_KEY={{local://external/gemini_api_key}}
DEEPL_API_KEY={{local://external/deepl_api_key}}

# Secrets from Vault
CLIENT_ID={{vault://mount/services/docbrowser/credentials.client_id}}
CLIENT_SECRET={{vault://mount/services/docbrowser/credentials.client_secret}}

# Plain values can contain = signs
DB_URL=postgres://user:pass@localhost/db?sslmode=disable
Variable substitution

${name} references are resolved from the active profile's variables before secret interpolation. Without a default, an undefined variable is an error.

Default values can be provided after a ::

Syntax Result when undefined
${name} error
${name:fallback} fallback
${name:} empty string
${name:"text with spaces"} text with spaces
${name:"escaped \"quotes\" and {}"} escaped "quotes" and {}

Quoted defaults ("...") support \" for literal quotes and allow } inside the value without ending the expression.

This makes it easy to switch between environments with a single env file:

profile: customer0
---
SOME_VAR=${some_var}
GREETING=${greeting:"Hello, world!"}
COUNT=${count:0}

# Secrets from Vault
CLIENT_ID={{vault://ele-${customer}-stage/services/docbrowser/credentials.client_id}}
CLIENT_SECRET={{vault://ele-${customer}-stage/services/docbrowser/credentials.client_secret}}

Variables are defined in the profiles configuration file ($XDG_CONFIG_HOME/ttrun/profiles.yaml):

ttrun profile set customer0 some_var hello
ttrun profile set customer0 customer 000

Or edit the file directly:

customer0:
  variables:
    some_var: "hello"
    customer: "000"
customer1:
  variables:
    some_var: "world"
    customer: "001"
otherprofile:
  config:
    vault_addr: "https://other.vault.example.com"
    cache: false
  variables:
    some_var: "out of"
    customer: "333"

Secret references

Local secrets

A reference like {{local://external/gemini_api_key}} is resolved from the local encrypted store at $XDG_CONFIG_HOME/ttrun/secrets.yaml (defaults to ~/.config/ttrun/secrets.yaml).

The file is a YAML map of name → encrypted value, with a top-level key_id identifying which keychain entry was used to encrypt the values:

key_id: 8a4c7e90c1f4d0b6f9e2a3b1c5d6e7f8
values:
  external/gemini_api_key: v1.AAAA...
  external/deepl_api_key: v1.BBBB...

Each value is AES-256-GCM-encrypted under a 32-byte key kept in the OS keychain (service se.tt.ttrun, account storage-encryption-key). The key entry also carries a rotate_after timestamp; on the next ttrun invocation past that time, ttrun automatically generates a new key, re-encrypts every entry in secrets.yaml and cache.yaml under the new key, and writes the new key to the keychain. The default rotation interval is 1 year.

If a referenced secret doesn't exist, ttrun prompts you to enter a value and stores it for future use. In non-interactive contexts (ttrun direnv, ttrun serve), missing secrets are reported as errors instead.

Vault secrets

A reference like {{vault://mount/path/to/secret.field}} fetches a secret from Vault:

  • mount -- the KV secrets engine mount (e.g. ele000-stage)
  • path/to/secret -- the secret path within the mount
  • field -- the field to extract from the secret (after the last .)

If multiple references point to the same secret (same mount and path), it is only fetched once.

Vault authentication is handled by the vault CLI (e.g. via VAULT_TOKEN or a prior vault login). The Vault address is resolved from VAULT_ADDR, the profile's vault_addr, or the global default-vault-addr (in that order). If none is set, ttrun exits with an error.

Environment variables

  • TTRUN_PROFILE -- default profile name; overridden by --profile, overrides env file front matter
  • VAULT_ADDR -- Vault server address (takes precedence over the configured default)

Configuration

ttrun stores global configuration in ~/.config/ttrun/config.json. Per-profile configuration is stored in $XDG_CONFIG_HOME/ttrun/profiles.yaml (defaults to ~/.config/ttrun/profiles.yaml).

Use ttrun configure to set values. Without --profile the change applies globally; with --profile it applies to that profile only:

ttrun configure default-vault-addr https://vault.example.com
ttrun configure --profile=other default-vault-addr https://other-vault.example.com

Available configuration keys:

  • default-vault-addr -- Vault server address to use when VAULT_ADDR is not set
  • cache -- enable persistent caching of vault secrets in cache.yaml (true/false, default false)
  • remote -- default remote address used when --remote is not given. Empty string disables.
  • listen -- default listen address used when serve is invoked without --listen.

Vault caching

Vault lookups require network access and authentication. You can enable persistent caching to resolve vault secrets offline after an initial fetch:

ttrun configure cache true

When caching is enabled, resolved vault secrets are stored encrypted in $XDG_CONFIG_HOME/ttrun/cache.yaml under keys of the form <vault-host>/<mount>/<path>.<field>, using the same encryption scheme as the local secrets store. On subsequent runs, cached values are used without contacting Vault.

Pre-populating the cache

The pull command fetches and caches all vault secrets referenced in the env file in one go:

ttrun pull
ttrun pull myapp.env

This is useful for going offline or for populating a fresh machine. It fetches each unique vault path once and caches all fields returned, so even fields not yet referenced in the env file are available.

Note: pull always fetches from Vault regardless of what's already cached, so it can also be used to refresh stale cache entries.

Updating secrets

To set or update a secret in the local encrypted store:

ttrun set client_secrets/testing

This prompts for the value (with confirmation) and stores it. Use this to rotate secrets without having to delete and re-run.

Listing secrets

To list secret names in the local encrypted store:

ttrun ls
ttrun ls external

A path prefix narrows the listing.

Reading secrets

To print a secret from the local encrypted store:

ttrun get external/gemini_api_key

First run

The first time ttrun needs to read or write the local store, it asks the OS keychain for the storage encryption key. If none exists, ttrun generates a fresh 32-byte key, persists it as a JSON document (carrying the key id, key bytes, and rotate_after timestamp), and you're done — no GPG, no passphrase prompt.

If rotate_after has passed by the time ttrun starts up, a new key is generated, every value in secrets.yaml and cache.yaml is re-encrypted under the new key, and the keychain entry is updated. The default rotation interval is 1 year.

direnv integration

ttrun can be used as a direnv extension, so that your environment variables (with resolved secrets) are automatically loaded when you cd into a project directory.

1. Install the hook

Run this once to add the use_ttrun function to direnv's library:

mkdir -p ~/.config/direnv/lib
ttrun direnv hook > ~/.config/direnv/lib/ttrun.sh
2. Add use ttrun to your .envrc

In any project directory that has a ttrun.env file:

echo "use ttrun" >> .envrc
direnv allow

To use a different env file:

echo 'use ttrun myapp.env' >> .envrc
direnv allow
3. That's it

direnv will now resolve your secrets and export the environment variables whenever you enter the directory. It automatically re-evaluates when the env file changes.

Note: Since direnv runs non-interactively, ttrun direnv cannot prompt for missing secrets. Any variable whose secrets can't be resolved is skipped, and a message is logged to stderr. Populate missing local secrets with ttrun set or by running ttrun directly once.

Remote resolution

ttrun can defer all env file parsing, profile resolution and secret lookups to a remote ttrun serve instance. This is useful when secrets and Vault credentials only live on a trusted machine: the local client never sees the secret store, and only receives the resolved environment for the command it runs.

Server
ttrun serve [--listen :22022]

The server listens on :22022 by default. The first time it runs it generates an SSH host key (~/.config/ttrun/host_ed25519) and prints its fingerprint. It maintains a list of approved client public keys in ~/.config/ttrun/authorized_keys.

ttrun serve runs an interactive TUI built with Bubble Tea. The header shows the server's hostname, listen address and host fingerprint. The body shows any pending approval (with a (+N queued) indicator if multiple clients hit at once) and a colour-coded activity log. Hotkeys:

key action
y approve the highlighted prompt
n deny the highlighted prompt
q quit

When an unknown client public key connects, the server shows the client's hostname (sent by the client over the SSH handshake) and IP, plus the offered key type and fingerprint. On approval, the key is appended to authorized_keys. After authentication, the server prompts the operator once per session. Subsequent re-resolves on the same session do not require a new prompt.

The server runs resolution non-interactively: the local secrets store and any Vault credentials must be set up by the operator in advance. Missing local secrets are returned as errors instead of triggering a prompt — populate them with ttrun set (or ttrun pull for Vault) before serving.

The TUI requires a real terminal. Running ttrun serve under systemd or with redirected stdin/stdout will not work today.

Client
ttrun --remote host:22022 -- command [args...]
ttrun configure remote host:22022   # set a default
ttrun --remote=        -- command   # disable a configured default for one run

If no port is given, :22022 is used. The first time the client runs it generates an ed25519 identity at ~/.config/ttrun/client_ed25519. On the first connection to an unknown server it prompts to trust the host key (TOFU), recording the result in ~/.config/ttrun/known_hosts.

No-disk-persistence guarantee

The client never persists resolved secrets — or anything derived from them — to disk. The whole point of --remote is to run ttrun in environments we don't trust; the resolved env stays in process memory until the child inherits it, and is gone when the child exits.

The only on-disk state the --remote client touches is non-secret peer state:

  • ~/.config/ttrun/client_ed25519 — the client's SSH identity.
  • ~/.config/ttrun/known_hosts — trusted server host keys.
  • ~/.config/ttrun/config.json — default remote address and other CLI config.
  • The user's own env file (e.g. ttrun.env) — input, forwarded verbatim to the server.

Notably, the local encrypted secrets store (secrets.yaml), the vault cache (cache.yaml) and the OS keychain are not used at all in --remote mode. This is enforced structurally by a Go test (TestClientRemoteHasNoLocalStoreReferences) that fails the build if remote.go or client_tui.go ever reference local-store symbols.

The client is also a Bubble Tea TUI. The header shows the server's nice-name and address (e.g. serverhost.example.com (192.0.2.1:22022)), the host key fingerprint and the command being run. The body switches between three views:

  1. Connecting — spinner while the SSH handshake completes.
  2. Host key approval (TOFU only) — a card showing the key type and fingerprint, with y/n to approve or deny.
  3. Running — the child process's stdout and stderr stream into a scrollable viewport. The status line shows running (pid N), exited (code N), etc.
key action
r restart: send SIGINT to the child, re-resolve the env file through the existing session, and respawn
q / ctrl+c quit: stop the child, close the session, exit
ctrl+l clear the captured output
/ / pgup / pgdn scroll the output viewport

Restarting reuses the already-approved SSH session, so the operator on the server is not re-prompted. Pressing q stops the child with SIGINT, escalates to SIGKILL after 10 seconds if needed, and exits with the child's last exit code.

Because the TUI owns the terminal, the child's stdin is closed and its stdout/stderr are captured into the viewport. Programs that detect a TTY (e.g. for colour or progress bars) will see a non-TTY in --remote mode. Local mode (without --remote) is unchanged.

ttrun --debug --remote … skips the TUI: it dials, resolves once, prints the env vars and exits.

How it works

ttrun reads the env file, resolves all secret references, then starts the specified command with the resolved environment variables added to the current environment (env file values override any existing variables with the same name).

In local mode, signals (INT, TERM, HUP, QUIT, USR1, USR2) are forwarded to the child process, and ttrun exits with the child's exit code.

In --remote mode, the TUI handles input directly: r restarts and q quits. Signals sent to the ttrun client process are not forwarded to the child.

Documentation

The Go Gopher

There is no documentation for this package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL