849 lines
36 KiB
Text
849 lines
36 KiB
Text
|
|
#!/usr/bin/env nu
|
||
|
|
# Workspace secret management — SOPS/age encrypted secrets from Nickel component configs.
|
||
|
|
#
|
||
|
|
# Required workspace structure:
|
||
|
|
# <workspace-root>/
|
||
|
|
# ├── templates/ YAML templates with PLACEHOLDER values (one per secret)
|
||
|
|
# └── infra/<infra>/
|
||
|
|
# ├── sops.yaml SOPS creation rules (age recipient + encrypted_regex)
|
||
|
|
# ├── .kage Age private key — NEVER commit, back up securely
|
||
|
|
# ├── secrets/ Encrypted *.sops.yaml files (safe to commit)
|
||
|
|
# └── components/<name>.ncl Nickel component config (used by `new`)
|
||
|
|
#
|
||
|
|
# Infra target resolution (first wins):
|
||
|
|
# 1. --infra <name> flag
|
||
|
|
# 2. WORKSPACE_INFRA env var
|
||
|
|
#
|
||
|
|
# Workspace root resolution (first wins):
|
||
|
|
# 1. WORKSPACE_ROOT env var
|
||
|
|
# 2. Current working directory ($env.PWD)
|
||
|
|
#
|
||
|
|
# Output format (--out/-o, default text; get only):
|
||
|
|
# text KEY: value listing (all-keys) or bare value (single-key) — default
|
||
|
|
# json structured — supported by: list, pending, info, get
|
||
|
|
#
|
||
|
|
# --env-var/-e (get only): emit `export KEY='value'` lines instead of --out.
|
||
|
|
# --env-prefix/-p <string> prepends a prefix to each KEY (e.g. -p MYAPP_ -> MYAPP_FOO=...);
|
||
|
|
# passing --env-prefix with a non-empty value implies -e, so -e itself is optional then.
|
||
|
|
# The resulting var name (prefix + key) is always upper-cased regardless of the
|
||
|
|
# secret's own key casing — text/json output is unaffected and keeps original casing.
|
||
|
|
# Both are mutually exclusive with --out json.
|
||
|
|
# Note: Nushell reserves the bare identifier `env` for its builtin $env record,
|
||
|
|
# so the long flag is --env-var rather than --env — the short form -e is unaffected.
|
||
|
|
#
|
||
|
|
# Usage:
|
||
|
|
# nu secrets.nu --infra libre-wuji gen
|
||
|
|
# nu secrets.nu --infra libre-wuji make postgresql POSTGRES_PASSWORD=secret
|
||
|
|
# nu secrets.nu --infra libre-wuji new postgresql POSTGRES_PASSWORD=secret
|
||
|
|
# nu secrets.nu --infra libre-wuji list
|
||
|
|
# nu secrets.nu --infra libre-wuji --out json list
|
||
|
|
# nu secrets.nu --infra libre-wuji show postgresql # human-readable decrypt
|
||
|
|
# nu secrets.nu --infra libre-wuji get postgresql # all keys, plain KEY: value
|
||
|
|
# nu secrets.nu --infra libre-wuji get postgresql POSTGRES_PASSWORD # single key, bare value
|
||
|
|
# nu secrets.nu --infra libre-wuji -o json get postgresql # all keys, JSON
|
||
|
|
# source <(nu secrets.nu --infra libre-wuji -e get wireguard) # export lines, no prefix
|
||
|
|
# source <(nu secrets.nu --infra libre-wuji -p WG_ get wireguard) # export WG_KEY=value lines
|
||
|
|
# nu secrets.nu --infra libre-wuji edit docker_mailserver
|
||
|
|
#
|
||
|
|
# WORKSPACE_INFRA=libre-wuji nu secrets.nu make postgresql POSTGRES_PASSWORD=secret
|
||
|
|
|
||
|
|
def main [
|
||
|
|
--infra (-i): string = "" # Infra name; falls back to WORKSPACE_INFRA env var
|
||
|
|
--out (-o): string = "text" # Output format: text | json (list, pending, info, get)
|
||
|
|
--env-var (-e) # get only: emit `export KEY='value'` lines
|
||
|
|
--env-prefix (-p): string = "" # get only: prefix prepended to each KEY in -e output (implies -e)
|
||
|
|
cmd?: string # gen | make | new | list | show | get | edit
|
||
|
|
...args: string
|
||
|
|
]: nothing -> nothing {
|
||
|
|
let infra_name = if ($infra | is-not-empty) {
|
||
|
|
$infra
|
||
|
|
} else {
|
||
|
|
$env.WORKSPACE_INFRA? | default ""
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($infra_name | is-empty) {
|
||
|
|
show-help
|
||
|
|
error make --unspanned { msg: "Infra name required — pass --infra <name> or set WORKSPACE_INFRA" }
|
||
|
|
}
|
||
|
|
|
||
|
|
if $out not-in ["text" "json"] {
|
||
|
|
error make --unspanned { msg: $"Invalid --out '($out)' — must be 'text' or 'json'" }
|
||
|
|
}
|
||
|
|
let env_mode = ($env_var or ($env_prefix | is-not-empty))
|
||
|
|
if $env_mode and $out == "json" {
|
||
|
|
error make --unspanned { msg: "--env-var/-e (or --env-prefix) and --out json are mutually exclusive" }
|
||
|
|
}
|
||
|
|
|
||
|
|
let ws_root = ($env.WORKSPACE_ROOT? | default $env.PWD)
|
||
|
|
let infra_dir = ($ws_root | path join "infra" $infra_name)
|
||
|
|
let infra_root = ($ws_root | path join "infra")
|
||
|
|
let secrets_dir = ($infra_dir | path join "secrets")
|
||
|
|
let templates_dir = ($ws_root | path join "templates")
|
||
|
|
|
||
|
|
# sops.yaml and .kage may live in infra/<name>/ or fall back to infra/
|
||
|
|
let sops_yaml = do {
|
||
|
|
let specific = ($infra_dir | path join "sops.yaml")
|
||
|
|
if ($specific | path exists) { $specific } else { $infra_root | path join "sops.yaml" }
|
||
|
|
}
|
||
|
|
let kage_file = do {
|
||
|
|
let specific = ($infra_dir | path join ".kage")
|
||
|
|
if ($specific | path exists) { $specific } else { $infra_root | path join ".kage" }
|
||
|
|
}
|
||
|
|
|
||
|
|
let ctx = {
|
||
|
|
infra_name: $infra_name,
|
||
|
|
ws_root: $ws_root,
|
||
|
|
infra_dir: $infra_dir,
|
||
|
|
secrets_dir: $secrets_dir,
|
||
|
|
templates_dir: $templates_dir,
|
||
|
|
sops_yaml: $sops_yaml,
|
||
|
|
kage_file: $kage_file,
|
||
|
|
out_format: $out,
|
||
|
|
env_var: $env_mode,
|
||
|
|
env_prefix: $env_prefix,
|
||
|
|
}
|
||
|
|
|
||
|
|
let c = ($cmd | default "help")
|
||
|
|
match $c {
|
||
|
|
"gen" => { cmd-gen $ctx }
|
||
|
|
"make" => { cmd-make $ctx $args }
|
||
|
|
"new" => { cmd-new $ctx $args }
|
||
|
|
"info" => { cmd-info $ctx ($args | get 0? | default "") }
|
||
|
|
"list" => { cmd-list $ctx }
|
||
|
|
"pending" => { cmd-pending $ctx }
|
||
|
|
"show" => { cmd-show $ctx ($args | get 0? | default "") }
|
||
|
|
"get" => { cmd-get $ctx $args }
|
||
|
|
"edit" => { cmd-edit $ctx ($args | get 0? | default "") }
|
||
|
|
_ => { show-help }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def show-help [] {
|
||
|
|
print "
|
||
|
|
Workspace secret management (shared tool)
|
||
|
|
|
||
|
|
Required workspace structure:
|
||
|
|
<workspace-root>/
|
||
|
|
├── templates/ YAML templates — one file per secret, keys with PLACEHOLDER values
|
||
|
|
└── infra/<infra>/
|
||
|
|
├── sops.yaml SOPS creation rules (age recipient, encrypted_regex)
|
||
|
|
├── .kage Age private key (NEVER commit — back up to vault)
|
||
|
|
├── secrets/ Encrypted *.sops.yaml output (safe to commit)
|
||
|
|
└── components/<name>.ncl Nickel component config (required by `new`)
|
||
|
|
|
||
|
|
Infra target (first wins): --infra <name> | WORKSPACE_INFRA env var
|
||
|
|
Workspace root (first wins): WORKSPACE_ROOT env var | current directory
|
||
|
|
Output format: --out/-o text|json (default text) — list, pending, info, get
|
||
|
|
--env-var/-e — get only: 'export KEY=value' lines
|
||
|
|
--env-prefix/-p <string> — get only: prefix each KEY (implies -e)
|
||
|
|
(-e/-p and --out json are mutually exclusive)
|
||
|
|
|
||
|
|
Commands:
|
||
|
|
gen Generate age keypair, write .kage, update sops.yaml
|
||
|
|
make <name> [KEY=value ...] Create SOPS-encrypted secret; prefers infra/<infra>/secrets/<name>.sops.yaml.template
|
||
|
|
over templates/<name>.yaml; staged template is deleted on success
|
||
|
|
new <name> [KEY=value ...] Derive template from component requires.credentials, then encrypt
|
||
|
|
Keys with credential_generators are auto-generated (no KEY=value needed)
|
||
|
|
Override any auto-generated key by supplying KEY=value explicitly
|
||
|
|
info <name> Show credentials and generators declared by a component
|
||
|
|
list List encrypted secrets; appends ??? section for pending staged templates
|
||
|
|
pending List only staged templates (*.sops.yaml.template) without encrypted secret
|
||
|
|
show <name> Decrypt and print for a human — plaintext, warning banner, no --out/-e
|
||
|
|
get <name> [key] Decrypt for script consumption — all keys or one key
|
||
|
|
text (default): KEY: value listing, or bare value for a single key
|
||
|
|
--out json: {name, values} or {name, key, value}
|
||
|
|
--env-var/-e: export KEY='value' lines (all keys or the one key)
|
||
|
|
--env-prefix/-p <string>: same, with <string> prepended to each KEY
|
||
|
|
edit <name> Open SOPS secret in \$EDITOR (decrypt/re-encrypt transparently)
|
||
|
|
|
||
|
|
Credential generators (declared in component NCL as credential_generators):
|
||
|
|
random_password Generates a random URL-safe password (length configurable)
|
||
|
|
wg_privkey Calls `wg genkey` — requires wireguard-tools
|
||
|
|
wg_pubkey Derives public key from an already-generated private key
|
||
|
|
bcrypt_password Calls `htpasswd -nbB` — derives bcrypt hash from a source key
|
||
|
|
|
||
|
|
Examples:
|
||
|
|
nu secrets.nu --infra libre-wuji gen
|
||
|
|
nu secrets.nu --infra libre-wuji info wireguard # show generators before creating
|
||
|
|
nu secrets.nu --infra libre-wuji new wireguard # all keys auto-generated
|
||
|
|
nu secrets.nu --infra libre-wuji new postgresql POSTGRES_PASSWORD=hunter2
|
||
|
|
nu secrets.nu --infra libre-wuji make hcloud token=hcloud_xxxxx
|
||
|
|
nu secrets.nu --infra libre-wuji list
|
||
|
|
nu secrets.nu --infra libre-wuji --out json list
|
||
|
|
nu secrets.nu --infra libre-wuji show postgresql
|
||
|
|
nu secrets.nu --infra libre-wuji get postgresql POSTGRES_PASSWORD
|
||
|
|
nu secrets.nu --infra libre-wuji -o json get postgresql
|
||
|
|
source <(nu secrets.nu --infra libre-wuji -e get wireguard)
|
||
|
|
source <(nu secrets.nu --infra libre-wuji -p WG_ get wireguard)
|
||
|
|
WORKSPACE_INFRA=libre-wuji nu secrets.nu edit docker_mailserver
|
||
|
|
"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Generate age keypair and write sops.yaml
|
||
|
|
def cmd-gen [ctx: record] {
|
||
|
|
if (^which age-keygen | complete).exit_code != 0 {
|
||
|
|
error make --unspanned { msg: "age-keygen not found — install: brew install age" }
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($ctx.kage_file | path exists) {
|
||
|
|
print $"⚠️ ($ctx.kage_file) already exists"
|
||
|
|
print "Overwrite? This invalidates all existing encrypted secrets. [y/N] "
|
||
|
|
let ans = (input "")
|
||
|
|
if $ans not-in ["y", "Y"] { print "Aborted."; return }
|
||
|
|
^rm -f $ctx.kage_file
|
||
|
|
}
|
||
|
|
|
||
|
|
let result = (do { ^age-keygen -o $ctx.kage_file } | complete)
|
||
|
|
if $result.exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"age-keygen failed: ($result.stderr)" }
|
||
|
|
}
|
||
|
|
|
||
|
|
let pub_key = (
|
||
|
|
open --raw $ctx.kage_file
|
||
|
|
| lines
|
||
|
|
| where { $in | str starts-with "# public key: " }
|
||
|
|
| first
|
||
|
|
| str replace "# public key: " ""
|
||
|
|
| str trim
|
||
|
|
)
|
||
|
|
|
||
|
|
print $"✓ Age keypair generated"
|
||
|
|
print $" private key: ($ctx.kage_file)"
|
||
|
|
print $" public key: ($pub_key)"
|
||
|
|
|
||
|
|
let base_keys = ["api_key" "certificate" "key" "password" "secret" "token"
|
||
|
|
"AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY"
|
||
|
|
"RESTIC_PASSWORD" "RESTIC_REPOSITORY" "KOPIA_PASSWORD"
|
||
|
|
"RELAY_USER" "RELAY_USERNAME" "RELAY_PASSWORD" "ADMIN_PASSWORD"]
|
||
|
|
let regex = $"^(($base_keys | sort | str join '|'))$"
|
||
|
|
let sops_content = ([
|
||
|
|
"creation_rules:"
|
||
|
|
' - path_regex: "secrets/.*\\.sops\\.yaml$"'
|
||
|
|
$" age: ($pub_key)"
|
||
|
|
$' encrypted_regex: "($regex)"'
|
||
|
|
] | str join "\n")
|
||
|
|
$"($sops_content)\n" | save --force $ctx.sops_yaml
|
||
|
|
print $"✓ ($ctx.sops_yaml) updated"
|
||
|
|
|
||
|
|
let gitignore = ($ctx.infra_dir | path join ".gitignore")
|
||
|
|
let required_entries = [".kage" "secrets/*.sops.yaml.template"]
|
||
|
|
let existing = if ($gitignore | path exists) { open --raw $gitignore } else { "" }
|
||
|
|
let missing = ($required_entries | where {|e| not ($existing | str contains $e)})
|
||
|
|
if ($missing | is-not-empty) {
|
||
|
|
if not ($gitignore | path exists) {
|
||
|
|
"# do not commit age private keys or staged secret templates\n" | save $gitignore
|
||
|
|
}
|
||
|
|
for e in $missing {
|
||
|
|
$"\n($e)\n" | save --append $gitignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
print "⚠️ Back up .kage securely — it cannot be recovered if lost."
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create SOPS-encrypted secret(s) from existing template(s) + KEY=value pairs.
|
||
|
|
# Accepts multiple names and glob patterns expanded against templates/.
|
||
|
|
def cmd-make [ctx: record, args: list<string>] {
|
||
|
|
if ($args | is-empty) {
|
||
|
|
error make --unspanned { msg: "Usage: make [--force] <name|pattern> [name ...] [KEY=value ...]" }
|
||
|
|
}
|
||
|
|
|
||
|
|
let force = ("--force" in $args) or ("-f" in $args)
|
||
|
|
let rest = ($args | where { |a| $a != "--force" and $a != "-f" })
|
||
|
|
let pairs = ($rest | where { |a| $a | str contains "=" })
|
||
|
|
let raw_names = ($rest | where { |a| not ($a | str contains "=") } | each { str replace --regex '\.yaml$' "" })
|
||
|
|
|
||
|
|
let names = ($raw_names | each {|n|
|
||
|
|
if ($n | str contains "*") or ($n | str contains "?") {
|
||
|
|
let matches = (try { glob ($ctx.templates_dir | path join $"($n).yaml") } catch { [] })
|
||
|
|
if ($matches | is-empty) {
|
||
|
|
error make --unspanned { msg: $"Pattern '($n)' matched no templates in ($ctx.templates_dir)" }
|
||
|
|
}
|
||
|
|
$matches | each {|f| $f | path basename | str replace --regex '\.yaml$' "" }
|
||
|
|
} else {
|
||
|
|
[$n]
|
||
|
|
}
|
||
|
|
} | flatten)
|
||
|
|
|
||
|
|
if ($names | is-empty) {
|
||
|
|
error make --unspanned { msg: "No names resolved from arguments" }
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($names | length) == 1 {
|
||
|
|
_cmd-make-one $ctx ($names | first) $pairs $force
|
||
|
|
} else {
|
||
|
|
for name in $names {
|
||
|
|
print $"── ($name)"
|
||
|
|
_cmd-make-one $ctx $name $pairs $force
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def _cmd-make-one [ctx: record, name: string, pairs: list<string>, force: bool] {
|
||
|
|
let staged_path = ($ctx.secrets_dir | path join $"($name).sops.yaml.template")
|
||
|
|
let infra_tpl = ($ctx.secrets_dir | path join "templates" $"($name).yaml")
|
||
|
|
let generic_path = ($ctx.templates_dir | path join $"($name).yaml")
|
||
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
||
|
|
let global_path = ($prov_root | path join "workspace" "tools" "templates" $"($name).yaml")
|
||
|
|
|
||
|
|
let tpl = if ($staged_path | path exists) {
|
||
|
|
{ path: $staged_path, staged: true }
|
||
|
|
} else if ($infra_tpl | path exists) {
|
||
|
|
{ path: $infra_tpl, staged: false }
|
||
|
|
} else if ($generic_path | path exists) {
|
||
|
|
{ path: $generic_path, staged: false }
|
||
|
|
} else if ($global_path | path exists) {
|
||
|
|
{ path: $global_path, staged: false }
|
||
|
|
} else {
|
||
|
|
let avail = (try {
|
||
|
|
ls $ctx.templates_dir
|
||
|
|
| where type == file
|
||
|
|
| get name
|
||
|
|
| each {|f| $f | path basename | str replace ".yaml" ""}
|
||
|
|
| str join ", "
|
||
|
|
} catch { "" })
|
||
|
|
let infra_tpl_avail = (try {
|
||
|
|
ls ($ctx.secrets_dir | path join "templates")
|
||
|
|
| where type == file
|
||
|
|
| get name
|
||
|
|
| each {|f| $f | path basename | str replace ".yaml" ""}
|
||
|
|
| str join ", "
|
||
|
|
} catch { "" })
|
||
|
|
let global_avail = (try {
|
||
|
|
ls ($prov_root | path join "workspace" "tools" "templates")
|
||
|
|
| where type == file
|
||
|
|
| get name
|
||
|
|
| each {|f| $f | path basename | str replace ".yaml" ""}
|
||
|
|
| str join ", "
|
||
|
|
} catch { "" })
|
||
|
|
error make --unspanned {
|
||
|
|
msg: $"Template '($name)' not found. Checked:\n ($staged_path)\n ($infra_tpl)\n ($generic_path)\n ($global_path)\nAvailable in secrets/templates/: ($infra_tpl_avail)\nAvailable in templates/: ($avail)\nAvailable in global templates/: ($global_avail)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
_assert-sops-ready $ctx
|
||
|
|
|
||
|
|
mkdir $ctx.secrets_dir
|
||
|
|
let output = ($ctx.secrets_dir | path join $"($name).sops.yaml")
|
||
|
|
|
||
|
|
if ($output | path exists) and not $force {
|
||
|
|
print $" ($output) already exists"
|
||
|
|
let ans = (input " Overwrite? [y/N] ")
|
||
|
|
if $ans not-in ["y", "Y"] {
|
||
|
|
print " skipped"
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let overrides = (_parse-pairs $pairs)
|
||
|
|
|
||
|
|
# Staged templates: YAML-based key overrides (values may be real or CHANGE_ME sentinels).
|
||
|
|
# Generic templates: positional PLACEHOLDER replacement (original behaviour).
|
||
|
|
let content = if $tpl.staged and not ($overrides | is-empty) {
|
||
|
|
mut rec = (open --raw $tpl.path | from yaml)
|
||
|
|
for kv in ($overrides | items {|k v| {key: $k, val: $v}}) {
|
||
|
|
$rec = ($rec | upsert $kv.key $kv.val)
|
||
|
|
}
|
||
|
|
$rec | to yaml
|
||
|
|
} else if not $tpl.staged {
|
||
|
|
mut c = (open --raw $tpl.path)
|
||
|
|
for kv in ($overrides | items {|k v| {key: $k, val: $v}}) {
|
||
|
|
$c = ($c | str replace "PLACEHOLDER" $kv.val)
|
||
|
|
}
|
||
|
|
$c
|
||
|
|
} else {
|
||
|
|
open --raw $tpl.path
|
||
|
|
}
|
||
|
|
|
||
|
|
# Keys come from the actual content to encrypt — covers both standalone and cmd-new paths.
|
||
|
|
# Must happen before sops encrypt so the regex is up to date.
|
||
|
|
let content_keys = (try { $content | from yaml | columns } | default [])
|
||
|
|
let regex_updated = (_ensure-keys-in-regex $ctx $content_keys)
|
||
|
|
|
||
|
|
$content | save --force $output
|
||
|
|
|
||
|
|
let enc = (do {
|
||
|
|
with-env { SOPS_AGE_KEY_FILE: $ctx.kage_file } {
|
||
|
|
^sops --encrypt --in-place --config $ctx.sops_yaml $output
|
||
|
|
}
|
||
|
|
} | complete)
|
||
|
|
|
||
|
|
if $enc.exit_code != 0 {
|
||
|
|
^rm -f $output
|
||
|
|
error make --unspanned { msg: $"sops encrypt failed:\n($enc.stderr)" }
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"✓ ($output) created"
|
||
|
|
print $" Keys encrypted: ($content_keys | str join ', ')"
|
||
|
|
|
||
|
|
if $tpl.staged {
|
||
|
|
^rm -f $tpl.path
|
||
|
|
print $" Staged template removed: ($tpl.path | path basename)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Derive template from component requires.credentials, then call cmd-make
|
||
|
|
def cmd-new [ctx: record, args: list<string>] {
|
||
|
|
if ($args | is-empty) {
|
||
|
|
error make --unspanned { msg: "Usage: new <name> [KEY=value ...]" }
|
||
|
|
}
|
||
|
|
|
||
|
|
let name = ($args | first | str replace --regex '\.yaml$' "")
|
||
|
|
let pairs = ($args | skip 1)
|
||
|
|
let ncl_path = do {
|
||
|
|
let p1 = ($ctx.infra_dir | path join "components" $"($name).ncl")
|
||
|
|
let p2 = ($ctx.infra_dir | path join "components" $"($name | str replace --all '_' '-').ncl")
|
||
|
|
if ($p1 | path exists) { $p1 } else { $p2 }
|
||
|
|
}
|
||
|
|
|
||
|
|
if not ($ncl_path | path exists) {
|
||
|
|
cmd-make $ctx $args
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
# Guard: staged template takes priority in _cmd-make-one, which would orphan the
|
||
|
|
# workspace template that cmd-new is about to generate with real credential values.
|
||
|
|
let staged_path = ($ctx.secrets_dir | path join $"($name).sops.yaml.template")
|
||
|
|
if ($staged_path | path exists) {
|
||
|
|
error make --unspanned {
|
||
|
|
msg: $"Staged template already exists: ($staged_path)\nEncrypt it with 'secret-make ($name)', or remove it to regenerate credentials via 'secret-new'."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
||
|
|
let export = (do {
|
||
|
|
^nickel export --format json --import-path $prov_root $ncl_path
|
||
|
|
} | complete)
|
||
|
|
|
||
|
|
if $export.exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"nickel export failed for ($name):\n($export.stderr)" }
|
||
|
|
}
|
||
|
|
|
||
|
|
let comp = ($export.stdout | from json)
|
||
|
|
let name_key = ($name | str replace --all '-' '_')
|
||
|
|
let entry = ($comp | get -o $name_key | default ($comp | get -o $name | default {}))
|
||
|
|
let creds = ($entry | get -o requires | default {} | get -o credentials | default [])
|
||
|
|
|
||
|
|
if ($creds | is-empty) {
|
||
|
|
error make --unspanned { msg: $"Component '($name)' has no requires.credentials — nothing to template" }
|
||
|
|
}
|
||
|
|
|
||
|
|
let generators = ($entry | get -o credential_generators | default {})
|
||
|
|
|
||
|
|
# Generate values: process in order so dependent generators (wg_pubkey) see already-generated keys.
|
||
|
|
mut generated: record = {}
|
||
|
|
for k in $creds {
|
||
|
|
let gen = ($generators | get -o $k | default {})
|
||
|
|
let supplied = ($pairs | where {|p| $p | str starts-with $"($k)=" } | get 0? | default "")
|
||
|
|
let value = if ($supplied | is-not-empty) {
|
||
|
|
$supplied | str replace $"($k)=" ""
|
||
|
|
} else if ($gen | is-not-empty) {
|
||
|
|
_generate-value $k $gen $generated
|
||
|
|
} else {
|
||
|
|
"PLACEHOLDER"
|
||
|
|
}
|
||
|
|
$generated = ($generated | insert $k $value)
|
||
|
|
}
|
||
|
|
|
||
|
|
let values_map = $generated # immutable copy — mut can't be captured in closures
|
||
|
|
mkdir $ctx.templates_dir
|
||
|
|
let template_path = ($ctx.templates_dir | path join $"($name).yaml")
|
||
|
|
let content = ($creds | each {|k| $"($k): ($values_map | get $k)" } | str join "\n")
|
||
|
|
$"($content)\n" | save --force $template_path
|
||
|
|
print $"✓ Template generated: ($template_path)"
|
||
|
|
print $" Credentials: ($creds | str join ', ')"
|
||
|
|
if ($generators | is-not-empty) {
|
||
|
|
let auto = ($creds | where {|k| ($generators | get -o $k | default {} | is-not-empty) and ($pairs | where {|p| $p | str starts-with $"($k)=" } | is-empty) })
|
||
|
|
if ($auto | is-not-empty) {
|
||
|
|
print $" Auto-generated: ($auto | str join ', ')"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
_cmd-make-one $ctx $name [] false
|
||
|
|
}
|
||
|
|
|
||
|
|
# Show credentials and generators declared by a component
|
||
|
|
def cmd-info [ctx: record, name: string] {
|
||
|
|
if ($name | is-empty) {
|
||
|
|
error make --unspanned { msg: "Usage: info <name>" }
|
||
|
|
}
|
||
|
|
let name = ($name | str replace --regex '\.yaml$' "")
|
||
|
|
let ncl_path = ($ctx.infra_dir | path join "components" $"($name).ncl")
|
||
|
|
if not ($ncl_path | path exists) {
|
||
|
|
error make --unspanned { msg: $"Component config not found: ($ncl_path)" }
|
||
|
|
}
|
||
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
||
|
|
let export = (do { ^nickel export --format json --import-path $prov_root $ncl_path } | complete)
|
||
|
|
|
||
|
|
if $export.exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"nickel export failed for ($name):\n($export.stderr)" }
|
||
|
|
}
|
||
|
|
let entry = ($export.stdout | from json | get -o $name | default {})
|
||
|
|
let creds = ($entry | get -o requires | default {} | get -o credentials | default [])
|
||
|
|
let gens = ($entry | get -o credential_generators | default {})
|
||
|
|
|
||
|
|
let secret_path = ($ctx.secrets_dir | path join $"($name).sops.yaml")
|
||
|
|
let secret_status = if ($secret_path | path exists) { "exists" } else { "not created" }
|
||
|
|
|
||
|
|
if $ctx.out_format == "json" {
|
||
|
|
let credentials = ($creds | each {|k|
|
||
|
|
let gen = ($gens | get -o $k | default {})
|
||
|
|
let kind = ($gen.kind? | default "")
|
||
|
|
{
|
||
|
|
key: $k,
|
||
|
|
generator: $kind,
|
||
|
|
auto: ($kind | is-not-empty),
|
||
|
|
}
|
||
|
|
})
|
||
|
|
{
|
||
|
|
component: $name,
|
||
|
|
secret_path: $secret_path,
|
||
|
|
secret_status: $secret_status,
|
||
|
|
credentials: $credentials,
|
||
|
|
} | to json | print
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"\nComponent: ($name)"
|
||
|
|
print $"Secret: ($secret_path) \(($secret_status)\)"
|
||
|
|
print ""
|
||
|
|
if ($creds | is-empty) {
|
||
|
|
print " No credentials declared (requires.credentials is empty)"
|
||
|
|
} else {
|
||
|
|
print " Key Generator Action"
|
||
|
|
print " ─────────────────── ───────────────── ──────────────────────────────"
|
||
|
|
for k in $creds {
|
||
|
|
let gen = ($gens | get -o $k | default {})
|
||
|
|
let kind = ($gen.kind? | default "")
|
||
|
|
let gen_label = match $kind {
|
||
|
|
"random_password" => $"random_password \(len=($gen.length? | default 32)\)"
|
||
|
|
"wg_privkey" => "wg genkey"
|
||
|
|
"wg_pubkey" => $"wg pubkey ← ($gen.source? | default 'WG_PRIVATE_KEY')"
|
||
|
|
"bcrypt_password" => $"htpasswd -nbB ← ($gen.source? | default 'WGUI_PASSWORD')"
|
||
|
|
"" => "— manual (PLACEHOLDER)"
|
||
|
|
_ => $kind
|
||
|
|
}
|
||
|
|
let action = if ($kind | is-not-empty) { "auto-generated" } else { "supply KEY=value" }
|
||
|
|
print $" ($k | fill -w 20) ($gen_label | fill -w 18) ($action)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
print ""
|
||
|
|
if ($secret_path | path exists) {
|
||
|
|
print $" Run: just secret-show ($name) to inspect current values"
|
||
|
|
} else {
|
||
|
|
print $" Run: just secret-new ($name) to create (auto-generates keys above)"
|
||
|
|
}
|
||
|
|
print ""
|
||
|
|
}
|
||
|
|
|
||
|
|
# List encrypted secrets and flag staged templates that have not been encrypted yet
|
||
|
|
def cmd-list [ctx: record] {
|
||
|
|
let sops_files = (try { glob $"($ctx.secrets_dir)/*.sops.yaml" } catch { [] })
|
||
|
|
let tpl_files = (try { glob $"($ctx.secrets_dir)/*.sops.yaml.template" } catch { [] })
|
||
|
|
|
||
|
|
let secrets = (
|
||
|
|
$sops_files
|
||
|
|
| each {|f|
|
||
|
|
let info = (ls $f | first)
|
||
|
|
{
|
||
|
|
name: ($f | path basename | str replace ".sops.yaml" ""),
|
||
|
|
size: $info.size,
|
||
|
|
modified: $info.modified,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
let pending = (
|
||
|
|
$tpl_files
|
||
|
|
| each {|t|
|
||
|
|
let name = ($t | path basename | str replace ".sops.yaml.template" "")
|
||
|
|
let sops = ($ctx.secrets_dir | path join $"($name).sops.yaml")
|
||
|
|
if not ($sops | path exists) { [$name] } else { [] }
|
||
|
|
}
|
||
|
|
| flatten
|
||
|
|
)
|
||
|
|
|
||
|
|
if $ctx.out_format == "json" {
|
||
|
|
{ secrets_dir: $ctx.secrets_dir, secrets: $secrets, pending: $pending } | to json | print
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($sops_files | is-empty) and ($tpl_files | is-empty) {
|
||
|
|
print $"No encrypted secrets or staged templates found in ($ctx.secrets_dir)"
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($sops_files | is-not-empty) {
|
||
|
|
print $"\nEncrypted secrets in ($ctx.secrets_dir):\n"
|
||
|
|
for s in $secrets {
|
||
|
|
let mod = ($s.modified | format date "%Y-%m-%d %H:%M")
|
||
|
|
print $" ($s.name) ($s.size) ($mod)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($pending | is-not-empty) {
|
||
|
|
print $"\n Pending — template staged, secret not yet encrypted:\n"
|
||
|
|
for name in $pending {
|
||
|
|
print $" ??? ($name)"
|
||
|
|
}
|
||
|
|
print $"\n Run: just secret-make <name> to encrypt a pending secret"
|
||
|
|
}
|
||
|
|
|
||
|
|
print ""
|
||
|
|
}
|
||
|
|
|
||
|
|
# List only staged templates (*.sops.yaml.template) without a corresponding encrypted secret
|
||
|
|
def cmd-pending [ctx: record] {
|
||
|
|
let tpl_files = (try { glob $"($ctx.secrets_dir)/*.sops.yaml.template" } catch { [] })
|
||
|
|
|
||
|
|
let pending = (
|
||
|
|
$tpl_files
|
||
|
|
| each {|t|
|
||
|
|
let name = ($t | path basename | str replace ".sops.yaml.template" "")
|
||
|
|
let sops = ($ctx.secrets_dir | path join $"($name).sops.yaml")
|
||
|
|
if not ($sops | path exists) { [$name] } else { [] }
|
||
|
|
}
|
||
|
|
| flatten
|
||
|
|
)
|
||
|
|
|
||
|
|
if $ctx.out_format == "json" {
|
||
|
|
{ secrets_dir: $ctx.secrets_dir, pending: $pending } | to json | print
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($tpl_files | is-empty) {
|
||
|
|
print "No staged templates found"
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($pending | is-empty) {
|
||
|
|
print "No pending secrets — all staged templates have a corresponding encrypted secret"
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"\nPending secrets in ($ctx.secrets_dir):\n"
|
||
|
|
for name in $pending {
|
||
|
|
print $" ??? ($name | fill -w 30) run: just secret-make ($name)"
|
||
|
|
}
|
||
|
|
print ""
|
||
|
|
}
|
||
|
|
|
||
|
|
# Decrypt and print a secret for human inspection (plaintext, with warning banner).
|
||
|
|
def cmd-show [ctx: record, name: string] {
|
||
|
|
if ($name | is-empty) {
|
||
|
|
error make --unspanned { msg: "Usage: show <name>" }
|
||
|
|
}
|
||
|
|
let name = ($name | str replace --regex '\.yaml$' "")
|
||
|
|
let path = ($ctx.secrets_dir | path join $"($name).sops.yaml")
|
||
|
|
_assert-secret-exists $path $name
|
||
|
|
_assert-kage-exists $ctx.kage_file
|
||
|
|
|
||
|
|
print $"⚠️ Decrypted content of ($name):\n"
|
||
|
|
with-env { SOPS_AGE_KEY_FILE: $ctx.kage_file } {
|
||
|
|
^sops --decrypt --config $ctx.sops_yaml $path
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Decrypt and return a secret's value(s) for script consumption.
|
||
|
|
# `get <name>` — all keys: text (KEY: value), --out json, or -e/--env-var (export lines)
|
||
|
|
# `get <name> <key>` — one key: bare value (text), {key,value} (json), or export line (-e)
|
||
|
|
def cmd-get [ctx: record, args: list<string>] {
|
||
|
|
if ($args | is-empty) {
|
||
|
|
error make --unspanned { msg: "Usage: get <name> [key]" }
|
||
|
|
}
|
||
|
|
let name = ($args | first | str replace --regex '\.yaml$' "")
|
||
|
|
let key = ($args | get 1? | default "")
|
||
|
|
let path = ($ctx.secrets_dir | path join $"($name).sops.yaml")
|
||
|
|
_assert-secret-exists $path $name
|
||
|
|
_assert-kage-exists $ctx.kage_file
|
||
|
|
|
||
|
|
let dec = (with-env { SOPS_AGE_KEY_FILE: $ctx.kage_file } {
|
||
|
|
^sops --decrypt --config $ctx.sops_yaml $path
|
||
|
|
} | complete)
|
||
|
|
if $dec.exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"sops decrypt failed:\n($dec.stderr)" }
|
||
|
|
}
|
||
|
|
let values = ($dec.stdout | from yaml)
|
||
|
|
|
||
|
|
if ($key | is-not-empty) {
|
||
|
|
if $key not-in ($values | columns) {
|
||
|
|
error make --unspanned { msg: $"Key '($key)' not found in secret '($name)' — available: ($values | columns | str join ', ')" }
|
||
|
|
}
|
||
|
|
let val = ($values | get $key)
|
||
|
|
if $ctx.env_var {
|
||
|
|
let var_name = ($"($ctx.env_prefix)($key)" | str upcase)
|
||
|
|
print $"export ($var_name)=(_shell-quote ($val | into string))"
|
||
|
|
} else if $ctx.out_format == "json" {
|
||
|
|
{ name: $name, key: $key, value: $val } | to json | print
|
||
|
|
} else {
|
||
|
|
print $val
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if $ctx.env_var {
|
||
|
|
for kv in ($values | items {|k, v| {key: $k, val: $v} }) {
|
||
|
|
let var_name = ($"($ctx.env_prefix)($kv.key)" | str upcase)
|
||
|
|
print $"export ($var_name)=(_shell-quote ($kv.val | into string))"
|
||
|
|
}
|
||
|
|
} else if $ctx.out_format == "json" {
|
||
|
|
{ name: $name, values: $values } | to json | print
|
||
|
|
} else {
|
||
|
|
for kv in ($values | items {|k, v| {key: $k, val: $v} }) {
|
||
|
|
print $"($kv.key): ($kv.val)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Single-quote a value for safe use in a POSIX shell `export KEY=value` line.
|
||
|
|
def _shell-quote [val: string]: nothing -> string {
|
||
|
|
$"'($val | str replace --all "'" "'\\''")'"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Open SOPS secret in $EDITOR — decrypt on open, re-encrypt on save
|
||
|
|
def cmd-edit [ctx: record, name: string] {
|
||
|
|
if ($name | is-empty) {
|
||
|
|
error make --unspanned { msg: "Usage: edit <name>" }
|
||
|
|
}
|
||
|
|
let name = ($name | str replace --regex '\.yaml$' "")
|
||
|
|
let path = ($ctx.secrets_dir | path join $"($name).sops.yaml")
|
||
|
|
_assert-secret-exists $path $name
|
||
|
|
_assert-kage-exists $ctx.kage_file
|
||
|
|
|
||
|
|
let editor = (match ($env.EDITOR? | default "vim") {
|
||
|
|
"zed" => "vim",
|
||
|
|
$e => $e,
|
||
|
|
})
|
||
|
|
with-env { SOPS_AGE_KEY_FILE: $ctx.kage_file, EDITOR: $editor } {
|
||
|
|
do --ignore-errors { ^sops --config $ctx.sops_yaml $path }
|
||
|
|
}
|
||
|
|
let code = $env.LAST_EXIT_CODE
|
||
|
|
if $code != 0 and $code != 200 {
|
||
|
|
error make --unspanned { msg: $"sops failed (exit ($code))" }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Generate a credential value using the generator spec from the component config.
|
||
|
|
# `generated` carries already-generated values so dependent keys (e.g. wg_pubkey) can reference them.
|
||
|
|
def _generate-value [key: string, gen: record, generated: record]: nothing -> string {
|
||
|
|
match ($gen.kind? | default "") {
|
||
|
|
"random_password" => {
|
||
|
|
let len = ($gen.length? | default 32 | into int)
|
||
|
|
let r = (do { ^openssl rand -base64 64 } | complete)
|
||
|
|
if $r.exit_code == 0 {
|
||
|
|
$r.stdout | str trim
|
||
|
|
| str replace --all "=" ""
|
||
|
|
| str replace --all "/" "_"
|
||
|
|
| str replace --all "+" "-"
|
||
|
|
| str substring 0..$len
|
||
|
|
} else {
|
||
|
|
random chars --length $len
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"wg_privkey" => {
|
||
|
|
if (^which wg | complete).exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"wg_privkey: `wg` not found — install wireguard-tools\n macOS: brew install wireguard-tools\n Debian: apt-get install -y wireguard-tools" }
|
||
|
|
}
|
||
|
|
let r = (do { ^wg genkey } | complete)
|
||
|
|
if $r.exit_code == 0 { $r.stdout | str trim } else {
|
||
|
|
error make --unspanned { msg: $"wg genkey failed: ($r.stderr | str trim)" }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"wg_pubkey" => {
|
||
|
|
if (^which wg | complete).exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"wg_pubkey: `wg` not found — install wireguard-tools\n macOS: brew install wireguard-tools\n Debian: apt-get install -y wireguard-tools" }
|
||
|
|
}
|
||
|
|
let source = ($gen.source? | default "WG_PRIVATE_KEY")
|
||
|
|
let priv = ($generated | get -o $source | default "")
|
||
|
|
if ($priv | is-empty) {
|
||
|
|
error make --unspanned { msg: $"wg_pubkey: source key ($source) must be generated before ($key)" }
|
||
|
|
}
|
||
|
|
let r = (do { $priv | ^wg pubkey } | complete)
|
||
|
|
if $r.exit_code == 0 { $r.stdout | str trim } else {
|
||
|
|
error make --unspanned { msg: $"wg pubkey failed: ($r.stderr | str trim)" }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"bcrypt_password" => {
|
||
|
|
if (^which htpasswd | complete).exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"bcrypt_password: `htpasswd` not found — install apache2-utils/httpd\n macOS: brew install httpd\n Debian: apt-get install -y apache2-utils" }
|
||
|
|
}
|
||
|
|
let source = ($gen.source? | default "WGUI_PASSWORD")
|
||
|
|
let plaintext = ($generated | get -o $source | default "")
|
||
|
|
if ($plaintext | is-empty) {
|
||
|
|
error make --unspanned { msg: $"bcrypt_password: source key ($source) must be generated before ($key)" }
|
||
|
|
}
|
||
|
|
let r = (do { ^htpasswd -nbB -C 12 wg $plaintext } | complete)
|
||
|
|
if $r.exit_code == 0 {
|
||
|
|
$r.stdout | str trim | split row ":" | get 1 | str trim
|
||
|
|
} else {
|
||
|
|
error make --unspanned { msg: $"htpasswd failed: ($r.stderr | str trim)" }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ => { "PLACEHOLDER" }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Parse KEY=value pairs into a record
|
||
|
|
def _parse-pairs [pairs: list<string>]: nothing -> record {
|
||
|
|
$pairs | reduce --fold {} {|pair, acc|
|
||
|
|
let parts = ($pair | split row "=" | collect)
|
||
|
|
if ($parts | length) < 2 {
|
||
|
|
error make --unspanned { msg: $"Invalid KEY=value pair: ($pair)" }
|
||
|
|
}
|
||
|
|
let k = ($parts | first)
|
||
|
|
let v = ($parts | skip 1 | str join "=")
|
||
|
|
$acc | insert $k $v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Ensure all given keys appear in the sops.yaml encrypted_regex.
|
||
|
|
# Parses the current regex, adds missing keys (sorted, deduplicated), writes back.
|
||
|
|
# Returns true if the regex was updated, false if already complete.
|
||
|
|
def _ensure-keys-in-regex [ctx: record, keys: list<string>]: nothing -> bool {
|
||
|
|
if not ($ctx.sops_yaml | path exists) { return false }
|
||
|
|
let raw = (open --raw $ctx.sops_yaml)
|
||
|
|
let regex_line = ($raw | lines | where {|l| $l | str contains "encrypted_regex"} | get 0?)
|
||
|
|
if ($regex_line | default "" | is-empty) { return false }
|
||
|
|
|
||
|
|
let current_keys = (
|
||
|
|
$regex_line
|
||
|
|
| str replace --regex '.*encrypted_regex:\s*"' ""
|
||
|
|
| str replace --regex '".*' ""
|
||
|
|
| str replace --regex '^\^?\(' ""
|
||
|
|
| str replace --regex '\)\$?$' ""
|
||
|
|
| split row "|"
|
||
|
|
| each { str trim }
|
||
|
|
| where { is-not-empty }
|
||
|
|
)
|
||
|
|
let missing = ($keys | where {|k| ($k | is-not-empty) and ($k not-in $current_keys) })
|
||
|
|
if ($missing | is-empty) { return false }
|
||
|
|
|
||
|
|
let updated_keys = ($current_keys ++ $missing | uniq | sort)
|
||
|
|
let new_regex = $"^(($updated_keys | str join '|'))$"
|
||
|
|
let new_line = ($regex_line | str replace --regex 'encrypted_regex:.*' $"encrypted_regex: \"($new_regex)\"")
|
||
|
|
let new_raw = ($raw | str replace $regex_line $new_line)
|
||
|
|
$new_raw | save --force $ctx.sops_yaml
|
||
|
|
print $" [+] sops.yaml regex updated — added: ($missing | str join ', ')"
|
||
|
|
true
|
||
|
|
}
|
||
|
|
|
||
|
|
def _assert-sops-ready [ctx: record] {
|
||
|
|
if not ($ctx.sops_yaml | path exists) {
|
||
|
|
error make --unspanned { msg: $"($ctx.sops_yaml) not found — run: secrets.nu --infra ($ctx.infra_name) gen" }
|
||
|
|
}
|
||
|
|
if not ($ctx.kage_file | path exists) {
|
||
|
|
error make --unspanned { msg: $"($ctx.kage_file) not found — run: secrets.nu --infra ($ctx.infra_name) gen" }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def _assert-secret-exists [path: string, name: string] {
|
||
|
|
if not ($path | path exists) {
|
||
|
|
error make --unspanned { msg: $"Secret '($name)' not found at ($path)" }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def _assert-kage-exists [kage_file: string] {
|
||
|
|
if not ($kage_file | path exists) {
|
||
|
|
error make --unspanned { msg: $"($kage_file) not found — cannot decrypt" }
|
||
|
|
}
|
||
|
|
}
|