From f9ff2c0d629889984e5bd95f902822744dbfe0eb Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 23:28:19 +0000 Subject: [PATCH] feat(templates): adds arbitrary shell execution and pda-getting --- README.md | 43 +++++++++++++++---- cmd/get.go | 8 +++- cmd/template.go | 72 ++++++++++++++++++++++++++++++++ testdata/template-enum-err.ct | 2 +- testdata/template-pda-ref-err.ct | 5 +++ testdata/template-pda-ref.ct | 13 ++++++ testdata/template-require-err.ct | 2 +- testdata/template-shell.ct | 5 +++ 8 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 testdata/template-pda-ref-err.ct create mode 100644 testdata/template-pda-ref.ct create mode 100644 testdata/template-shell.ct diff --git a/README.md b/README.md index 550b72d..a1d6a66 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,14 @@

`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), -- Git-backed [version control](https://github.com/Llywelwyn/pda#git), -- [search and filtering](https://github.com/Llywelwyn/pda#filtering) by key and/or value, -- plaintext exports in multiple formats, +- Git-backed [version control](https://github.com/Llywelwyn/pda#git) with automatic syncing, +- [search and filtering](https://github.com/Llywelwyn/pda#filtering) by key, value, or store, +- plaintext exports in 7 different formats, - support for all [binary data](https://github.com/Llywelwyn/pda#binary), -- [time-to-live](https://github.com/Llywelwyn/pda#ttl)/expiry support, -- built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor), +- expiring keys with a [time-to-live](https://github.com/Llywelwyn/pda#ttl), +- 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). @@ -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. -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. @@ -481,6 +481,35 @@ pda get names NAMES=Bob,Alice

+`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 +``` + +

+ +`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 +``` + +

+ pass `no-template` to output literally without templating. ```bash pda set hello "{{ if .MORNING }}Good morning.{{ end }}" diff --git a/cmd/get.go b/cmd/get.go index 5a3a85d..e4b6b1c 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -148,18 +148,22 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) { val := parts[1] vars[key] = val } + funcMap := templateFuncMap() + funcMap["pda"] = func(key string) (string, error) { + return pdaGet(key, substitutions) + } tpl, err := template.New("cmd"). Delims("{{", "}}"). // Render missing map keys as zero values so the default helper can decide on fallbacks. Option("missingkey=zero"). - Funcs(templateFuncMap()). + Funcs(funcMap). Parse(string(tplBytes)) if err != nil { return nil, err } var buf bytes.Buffer if err := tpl.Execute(&buf, vars); err != nil { - return nil, err + return nil, cleanTemplateError(err) } return buf.Bytes(), nil } diff --git a/cmd/template.go b/cmd/template.go index 0bd1209..d8cef0d 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -25,6 +25,7 @@ package cmd import ( "fmt" "os" + "os/exec" "slices" "strconv" "strings" @@ -81,5 +82,76 @@ func templateFuncMap() template.FuncMap { return parts }, "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 : error calling func: " +// We extract just 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 +} diff --git a/testdata/template-enum-err.ct b/testdata/template-enum-err.ct index f3a5e79..b0597cf 100644 --- a/testdata/template-enum-err.ct +++ b/testdata/template-enum-err.ct @@ -2,4 +2,4 @@ $ fecho tpl {{ enum .LEVEL "info" "warn" }} $ pda set level@tple < tpl $ pda get level@tple LEVEL=debug --> FAIL -FAIL cannot get 'level@tple': template: cmd:1:3: executing "cmd" at : error calling enum: invalid value 'debug', allowed: [info warn] +FAIL cannot get 'level@tple': invalid value 'debug', allowed: [info warn] diff --git a/testdata/template-pda-ref-err.ct b/testdata/template-pda-ref-err.ct new file mode 100644 index 0000000..e779297 --- /dev/null +++ b/testdata/template-pda-ref-err.ct @@ -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 diff --git a/testdata/template-pda-ref.ct b/testdata/template-pda-ref.ct new file mode 100644 index 0000000..eccba79 --- /dev/null +++ b/testdata/template-pda-ref.ct @@ -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! diff --git a/testdata/template-require-err.ct b/testdata/template-require-err.ct index 55a686b..255cf57 100644 --- a/testdata/template-require-err.ct +++ b/testdata/template-require-err.ct @@ -2,4 +2,4 @@ $ fecho tpl {{ require .FILE }} $ pda set tmpl@tplr < tpl $ pda get tmpl@tplr --> FAIL -FAIL cannot get 'tmpl@tplr': template: cmd:1:3: executing "cmd" at : error calling require: required value is missing or empty +FAIL cannot get 'tmpl@tplr': required value is missing or empty diff --git a/testdata/template-shell.ct b/testdata/template-shell.ct new file mode 100644 index 0000000..ad5c933 --- /dev/null +++ b/testdata/template-shell.ct @@ -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