feat(templates): adds arbitrary shell execution and pda-getting

This commit is contained in:
Lewis Wynne 2026-02-12 23:28:19 +00:00
parent 2ca32769d5
commit f9ff2c0d62
8 changed files with 139 additions and 11 deletions

View file

@ -19,14 +19,14 @@
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
`pda!` is a command-line key-value store tool with: `pda!` is a command-line key-value store tool with:
- [templates](https://github.com/Llywelwyn/pda#templates), - [templates](https://github.com/Llywelwyn/pda#templates) supporting arbitrary shell execution, conditionals, loops, more,
- [encryption](https://github.com/Llywelwyn/pda#encryption) at rest using [age](https://github.com/FiloSottile/age), - [encryption](https://github.com/Llywelwyn/pda#encryption) at rest using [age](https://github.com/FiloSottile/age),
- Git-backed [version control](https://github.com/Llywelwyn/pda#git), - Git-backed [version control](https://github.com/Llywelwyn/pda#git) with automatic syncing,
- [search and filtering](https://github.com/Llywelwyn/pda#filtering) by key and/or value, - [search and filtering](https://github.com/Llywelwyn/pda#filtering) by key, value, or store,
- plaintext exports in multiple formats, - plaintext exports in 7 different formats,
- support for all [binary data](https://github.com/Llywelwyn/pda#binary), - support for all [binary data](https://github.com/Llywelwyn/pda#binary),
- [time-to-live](https://github.com/Llywelwyn/pda#ttl)/expiry support, - expiring keys with a [time-to-live](https://github.com/Llywelwyn/pda#ttl),
- built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor), - built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor) and [configuration](https://github.com/Llywelwyn/pda#config),
and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb). and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb).
@ -394,7 +394,7 @@ Values support effectively all of Go's `text/template` syntax. Templates are eva
`text/template` is a Turing-complete templating library that supports most of what you'd expect in a scripting language. Actions are given with ``{{ action }}`` syntax and support pipelines and nested templates, along with a lot more. I recommend reading the documentation if you want to do anything more complicated than described here. `text/template` is a Turing-complete templating library that supports most of what you'd expect in a scripting language. Actions are given with ``{{ action }}`` syntax and support pipelines and nested templates, along with a lot more. I recommend reading the documentation if you want to do anything more complicated than described here.
To fit `text/template` nicely into this tool, pda has a sparse set of additional functions built-in. For example, `default` values, `enum`s, `require`d values, `time`, `lists`, among others. These same functions are also available in `git.default_commit_message` templates, along with `{{ summary }}` which returns the action that triggered the commit (e.g. "set foo", "removed bar"). To fit `text/template` nicely into this tool, pda has a sparse set of additional functions built-in. For example, `default` values, `enum`s, `require`d values, `time`, `lists`, arbitrary `shell` execution, and getting other `pda` keys (recursively!). These same functions are also available in `git.default_commit_message` templates, along with `summary` which returns the action that triggered the commit (e.g. "set foo", "removed bar").
Below is more detail on the extra functions added by this tool. Below is more detail on the extra functions added by this tool.
@ -481,6 +481,35 @@ pda get names NAMES=Bob,Alice
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
`shell` executes a command and returns stdout.
```bash
pda set rev '{{ shell "git rev-parse --short HEAD" }}'
pda get rev
# a1b2c3d
pda set today '{{ shell "date +%Y-%m-%d" }}'
pda get today
# 2025-06-15
```
<p align="center"></p><!-- spacer -->
`pda` gets another key.
```bash
pda set base_url "https://api.example.com"
pda set endpoint '{{ pda "base_url" }}/users/{{ require .ID }}'
pda get endpoint ID=42
# https://api.example.com/users/42
# Cross-store references work too.
pda set host@urls "https://example.com"
pda set api '{{ pda "host@urls" }}/api'
pda get api
# https://example.com/api
```
<p align="center"></p><!-- spacer -->
pass `no-template` to output literally without templating. pass `no-template` to output literally without templating.
```bash ```bash
pda set hello "{{ if .MORNING }}Good morning.{{ end }}" pda set hello "{{ if .MORNING }}Good morning.{{ end }}"

View file

@ -148,18 +148,22 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) {
val := parts[1] val := parts[1]
vars[key] = val vars[key] = val
} }
funcMap := templateFuncMap()
funcMap["pda"] = func(key string) (string, error) {
return pdaGet(key, substitutions)
}
tpl, err := template.New("cmd"). tpl, err := template.New("cmd").
Delims("{{", "}}"). Delims("{{", "}}").
// Render missing map keys as zero values so the default helper can decide on fallbacks. // Render missing map keys as zero values so the default helper can decide on fallbacks.
Option("missingkey=zero"). Option("missingkey=zero").
Funcs(templateFuncMap()). Funcs(funcMap).
Parse(string(tplBytes)) Parse(string(tplBytes))
if err != nil { if err != nil {
return nil, err return nil, err
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tpl.Execute(&buf, vars); err != nil { if err := tpl.Execute(&buf, vars); err != nil {
return nil, err return nil, cleanTemplateError(err)
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }

View file

@ -25,6 +25,7 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@ -81,5 +82,76 @@ func templateFuncMap() template.FuncMap {
return parts return parts
}, },
"time": func() string { return time.Now().UTC().Format(time.RFC3339) }, "time": func() string { return time.Now().UTC().Format(time.RFC3339) },
"shell": func(command string) (string, error) {
sh := os.Getenv("SHELL")
if sh == "" {
sh = "/bin/sh"
}
out, err := exec.Command(sh, "-c", command).Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
return "", fmt.Errorf("shell %q: %s", command, strings.TrimSpace(string(exitErr.Stderr)))
}
return "", fmt.Errorf("shell %q: %w", command, err)
}
return strings.TrimRight(string(out), "\n"), nil
},
"pda": func(key string) (string, error) {
return pdaGet(key, nil)
},
} }
} }
// cleanTemplateError strips Go template engine internals from function call
// errors, returning just the inner error message. Template execution errors
// look like: "template: cmd:1:3: executing "cmd" at <func args>: error calling func: <inner>"
// We extract just <inner> for cleaner user-facing output.
func cleanTemplateError(err error) error {
msg := err.Error()
const marker = "error calling "
if i := strings.Index(msg, marker); i >= 0 {
rest := msg[i+len(marker):]
if j := strings.Index(rest, ": "); j >= 0 {
return fmt.Errorf("%s", rest[j+2:])
}
}
return err
}
const maxTemplateDepth = 16
func templateDepth() int {
s := os.Getenv("PDA_TEMPLATE_DEPTH")
if s == "" {
return 0
}
n, _ := strconv.Atoi(s)
return n
}
func pdaGet(key string, substitutions []string) (string, error) {
depth := templateDepth()
if depth >= maxTemplateDepth {
return "", fmt.Errorf("pda: max template depth (%d) exceeded", maxTemplateDepth)
}
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("pda: %w", err)
}
args := append([]string{"get", key}, substitutions...)
cmd := exec.Command(exe, args...)
cmd.Env = append(os.Environ(), fmt.Sprintf("PDA_TEMPLATE_DEPTH=%d", depth+1))
out, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
msg := strings.TrimSpace(string(exitErr.Stderr))
msg = strings.TrimPrefix(msg, "FAIL ")
if strings.Contains(msg, "max template depth") {
return "", fmt.Errorf("pda: max template depth (%d) exceeded (possible circular reference involving %q)", maxTemplateDepth, key)
}
return "", fmt.Errorf("pda: %s", msg)
}
return "", fmt.Errorf("pda: %w", err)
}
return strings.TrimRight(string(out), "\n"), nil
}

View file

@ -2,4 +2,4 @@
$ fecho tpl {{ enum .LEVEL "info" "warn" }} $ fecho tpl {{ enum .LEVEL "info" "warn" }}
$ pda set level@tple < tpl $ pda set level@tple < tpl
$ pda get level@tple LEVEL=debug --> FAIL $ pda get level@tple LEVEL=debug --> FAIL
FAIL cannot get 'level@tple': template: cmd:1:3: executing "cmd" at <enum .LEVEL "info" "warn">: error calling enum: invalid value 'debug', allowed: [info warn] FAIL cannot get 'level@tple': invalid value 'debug', allowed: [info warn]

5
testdata/template-pda-ref-err.ct vendored Normal file
View file

@ -0,0 +1,5 @@
# pda errors on missing key
$ fecho tpl1 {{ pda "missing" }}
$ pda set ref@tplre < tpl1
$ pda get ref@tplre --> FAIL
FAIL cannot get 'ref@tplre': pda: cannot get 'missing': no such key

13
testdata/template-pda-ref.ct vendored Normal file
View file

@ -0,0 +1,13 @@
# pda function cross-references another key
$ pda set base https://example.com
$ fecho tpl1 {{ pda "base" }}/api
$ pda set endpoint@tplr < tpl1
$ pda get endpoint@tplr
https://example.com/api
# pda with substitution vars passed through
$ fecho tpl2 Hello, {{ default "World" .NAME }}
$ pda set greeting@tplr < tpl2
$ fecho tpl3 {{ pda "greeting@tplr" }}!
$ pda set shout@tplr < tpl3
$ pda get shout@tplr NAME=Alice
Hello, Alice!

View file

@ -2,4 +2,4 @@
$ fecho tpl {{ require .FILE }} $ fecho tpl {{ require .FILE }}
$ pda set tmpl@tplr < tpl $ pda set tmpl@tplr < tpl
$ pda get tmpl@tplr --> FAIL $ pda get tmpl@tplr --> FAIL
FAIL cannot get 'tmpl@tplr': template: cmd:1:3: executing "cmd" at <require .FILE>: error calling require: required value is missing or empty FAIL cannot get 'tmpl@tplr': required value is missing or empty

5
testdata/template-shell.ct vendored Normal file
View file

@ -0,0 +1,5 @@
# Shell function executes a command and returns stdout
$ fecho tpl1 {{ shell "echo hello" }}
$ pda set shelltest@tpls < tpl1
$ pda get shelltest@tpls
hello