feat(templates): adds arbitrary shell execution and pda-getting
This commit is contained in:
parent
2ca32769d5
commit
f9ff2c0d62
8 changed files with 139 additions and 11 deletions
43
README.md
43
README.md
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
2
testdata/template-enum-err.ct
vendored
2
testdata/template-enum-err.ct
vendored
|
|
@ -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
5
testdata/template-pda-ref-err.ct
vendored
Normal 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
13
testdata/template-pda-ref.ct
vendored
Normal 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!
|
||||||
2
testdata/template-require-err.ct
vendored
2
testdata/template-require-err.ct
vendored
|
|
@ -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
5
testdata/template-shell.ct
vendored
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue