User Guide
What is panconf?
panconf is a CLI tool that generates configuration files from multiple sources. It supports:
- Multiple input formats: JSON, JSON5, YAML, TOML
- Multiple input sources: local files, URLs, glob patterns, commands, inline data
- Multiple output formats: JSON, YAML, TOML
- Multiple transforms: deep merge, tree merge, Go templates, jq, external commands
- Multi-document nodes: each node can contain multiple named documents
- DAG-based processing: nodes are processed in dependency order
- Intermediate nodes: transform nodes without output files that can be referenced by other nodes
- Template expansion: reference nodes, environment variables, tilde paths
- Watch mode: automatically regenerate when input files change
Installation
go install github.com/gforien/panconf/cmd/panconf@latest
Quick Start
- Create a configuration file
panconf.yaml:
node:
base:
from_file: base.json
overrides:
from_file: overrides.json
config:
to_file: config.json
transform:
deepMerge:
- "{{.base}}"
- "{{.overrides}}"
- Create your input files:
// base.json
{
"name": "myapp",
"settings": {
"debug": false,
"port": 8080
}
}
// overrides.json
{
"settings": {
"debug": true,
"timeout": 30
}
}
- Run panconf:
Or watch for changes:
Output Format Options
When writing to stdout (to_file: "-"), output is JSON by default with pretty-printing and colorization.
panconf run -ojson # JSON (default)
panconf run -oyaml # YAML
panconf run -otoml # TOML
panconf run --no-color # Disable colorization
- Result in
config.json:
{
"name": "myapp",
"settings": {
"debug": true,
"port": 8080,
"timeout": 30
}
}
Configuration File
panconf looks for configuration in the current directory, checking these files in order:
panconf.jsonnet (Jsonnet - programmable configuration with functions, imports, etc.)
panconf.json5 (JSON5 - JSON with comments, trailing commas, unquoted keys, etc.)
panconf.json
panconf.toml
panconf.yaml
Use panconf init to create a default panconf.json5 with helpful comments.
Schema
node:
<name>: # Named node
# INPUT NODE (exactly one from_* field):
from_file: <path> # Single file (doc named "doc")
from_file: [<path>, ...] # Multiple files (docs named by stem)
from_file: # Named files
- <name>: <path>
from_url: <url> # Remote URL
from_glob: <pattern> # Glob pattern (docs named by stem)
from_cmd: <command> # Command output (string or array)
from_expr: # Inline data (embedded YAML/JSON)
key: value
# TRANSFORM NODE (has transform, optional to_file):
to_file: <path> # Output path ("-" for stdout, optional)
transform:
# Exactly ONE of these:
deepMerge: [<refs>] # Deep merge (permissive)
treeMerge: [<refs>] # Tree merge (strict)
gotmpl: [<template>, <data>...] # Go template from file
gotmpl_inline: # Inline Go template
tmpl: <template>
jq: <expression> # jq expression
jsonnet: <expression> # Jsonnet expression
cmd: [<args>] # External command
Node Types
There are two types of nodes:
Input Nodes
Input nodes load data from external sources. They have exactly one from_* field:
node:
config:
from_file: config.json
Transform Nodes
Transform nodes process data using a transform. They have a transform: field and optionally a to_file: field:
node:
merged:
# No to_file = intermediate node (not written to disk)
transform:
deepMerge: ["{{.base}}", "{{.env}}"]
output:
to_file: result.json
transform:
deepMerge: ["{{.merged}}"]
Multi-Document Nodes
Each node in panconf contains a map of named documents. This allows a single node to hold multiple pieces of data.
Single-Document Shorthand
When a node has only one document (single file, single URL, etc.), it's automatically named "doc" and can be accessed with shorthand:
node:
config:
from_file: config.json # Single document named "doc"
Access as {{.config}} (shorthand) or {{.config.doc}} (explicit).
Multiple Files
Load multiple files into a single node:
node:
inputs:
from_file:
- base.json # Document named "base"
- override.json # Document named "override"
Access individual documents: {{.inputs.base}}, {{.inputs.override}}
In deepMerge, {{.inputs}} expands to all documents in sorted order.
Explicit Naming
Override automatic naming with explicit names:
node:
settings:
from_file:
- defaults: configs/defaults.json # Named "defaults"
- custom: configs/overrides.json # Named "custom"
Access as {{.settings.defaults}} and {{.settings.custom}}.
Glob Documents
Glob patterns create documents named by the filename stem:
node:
configs:
from_glob: "configs/*.json"
If files are configs/database.json and configs/cache.json, access as {{.configs.database}} and {{.configs.cache}}.
Input Sources
File Input
Load configuration from local files:
node:
# Single file
config:
from_file: config.json
# Multiple files
layers:
from_file:
- base.yaml
- env.yaml
- local.yaml
# With explicit names
settings:
from_file:
- defaults: path/to/defaults.json
- custom: path/to/custom.json
Supported formats: .json, .jsonnet, .libsonnet, .yaml, .yml, .toml, .gotmpl, .tmpl
Jsonnet Input
Load configuration from Jsonnet files with full support for imports and the standard library:
node:
# Simple jsonnet file
config:
from_file: config.jsonnet
# Jsonnet with imports (imports resolved relative to file)
app:
from_file: app/config.jsonnet
config.jsonnet:
local defaults = import 'lib/defaults.libsonnet';
{
name: "myapp",
port: defaults.port,
replicas: std.parseInt(std.extVar("REPLICAS")),
items: std.range(1, 5),
}
Jsonnet files are evaluated using go-jsonnet. The resulting JSON is then available like any other input.
URL Input
Fetch configuration from remote URLs:
node:
# Single URL (document named "doc")
remote:
from_url: https://example.com/config.json
# Multiple URLs (documents named by URL filename stem)
remotes:
from_url:
- https://example.com/base.json # doc named "base"
- https://example.com/override.json # doc named "override"
# With explicit names
configs:
from_url:
- defaults: https://example.com/defaults.json
- custom: https://internal.api/config.json
The file is downloaded to a temporary location for processing. Format is detected from URL extension or Content-Type header.
Access individual documents: {{.configs.defaults}}, {{.configs.custom}}, or use {{.configs}} in deepMerge to expand all.
Glob Input
Load multiple files matching a pattern:
node:
configs:
from_glob: "configs/*.json"
Important: Glob patterns must include a file extension.
Documents are named by the filename stem (without extension). Files are sorted lexically.
Command Input
Load configuration from command output:
node:
# Array format (direct execution)
repo:
from_cmd: ["gh", "repo", "view", "--json", "owner,name,url"]
# String format (executed via shell)
context:
from_cmd: "kubectl config view --output json"
# Multiple commands
multi:
from_cmd:
- ["echo", "{\"a\": 1}"]
- ["echo", "{\"b\": 2}"]
# Named commands
info:
from_cmd:
- git: ["git", "rev-parse", "HEAD"]
- date: ["date", "+%Y-%m-%d"]
The command output is auto-detected as JSON, YAML, or TOML.
Behavior:
- Non-zero exit code causes an error
- Empty output causes an error
- Command is executed once at startup (cached like URL inputs)
Inline Input (from_expr)
Embed data directly in the panconf config:
node:
# Single inline data (document named "doc")
defaults:
from_expr:
name: myapp
settings:
debug: false
# Multiple inline expressions
configs:
from_expr:
- base:
timeout: 30
retries: 3
- overrides:
timeout: 60
Output
Output File
Write to a file (format inferred from extension):
node:
result:
to_file: output.json # JSON format
# to_file: output.yaml # YAML format
# to_file: output.toml # TOML format
transform:
deepMerge: ["{{.source}}"]
Output to Stdout
Use - to write to stdout:
node:
result:
to_file: "-"
transform:
deepMerge: ["{{.source}}"]
Multiple Outputs
There are two ways to generate multiple output files:
Option 1: Multiple transform nodes
node:
data:
from_file: data.json
json:
to_file: config.json
transform:
deepMerge: ["{{.data}}"]
yaml:
to_file: config.yaml
transform:
deepMerge: ["{{.data}}"]
Option 2: to_file array (single transform, multiple outputs)
node:
data:
from_file: data.json
out:
to_file:
- "{{.data}}": output.json # Write data to JSON
- "{{.data}}": output.yaml # Write data to YAML
- "{{.data}}": output.toml # Write data to TOML
transform:
deepMerge: ["{{.data}}"]
The to_file array syntax uses document references as keys and output paths as values. You can reference:
- The current transform's output:
"{{.thisNode}}"
- Input nodes:
"{{.inputNode}}"
- Specific documents from multi-doc nodes:
"{{.multiDoc.docName}}"
Example: Write multiple documents from a glob node
node:
configs:
from_glob: "configs/*.json" # database.json, cache.json
out:
to_file:
- "{{.configs.database}}": db-config.json
- "{{.configs.cache}}": cache-config.json
transform:
deepMerge: ["{{.configs}}"]
Intermediate Nodes
Transform nodes without a to_file: are intermediate - they're computed but not written to disk. They can be referenced by other nodes:
node:
base:
from_file: base.yaml
env:
from_file: env.yaml
# Intermediate: merge base configs
merged:
transform:
deepMerge:
- "{{.base}}"
- "{{.env}}"
# Final: uses intermediate node
config:
to_file: config.json
transform:
deepMerge:
- "{{.merged}}"
Nodes are automatically processed in dependency order (topological sort).
Transforms
| Transform |
Description |
Docs |
| deepMerge |
Deep recursive merge, type conflicts allowed |
details |
| treeMerge |
Strict deep merge, errors on type conflicts |
details |
| gotmpl |
Go template from file with Sprig functions |
details |
| gotmpl_inline |
Inline Go template |
details |
| jq |
jq expression for data transformation |
details |
| jsonnet |
Jsonnet expression with std library |
details |
| cmd |
External command (jsonnet, jq, etc.) |
details |
transform:
deepMerge: ["{{.base}}", "{{.override}}"]
# or
treeMerge: ["{{.base}}", "{{.override}}"]
# or
gotmpl: ["{{.meta.template.path}}", "{{.meta.vars.path}}"]
# or
gotmpl_inline:
tmpl: |
config:
{{ .data | toYaml | nindent 2 }}
# or
jq: |
{
name: $config.name,
env: $env.environment
}
# or
jsonnet: |
std.mergePatch(config, override)
# or
cmd: ["jsonnet", "{{.meta.template.path}}"]
See the individual transform documentation for detailed examples and options.
Template Variables
Transform arguments support Go template syntax with these variables:
The {{.self}} Variable
In hybrid nodes (nodes with both from_* and transform), the input data is available via {{.self}}:
node:
config:
from_file: base.json
to_file: output.json
transform:
deepMerge:
- "{{.self}}"
- "{{.override}}"
For jq transforms, use $self instead.
Note: {{.self}} is only available in hybrid nodes. Using it in a transform-only node will result in an error.
Single-Document Nodes
For nodes with one document, use shorthand access:
node:
config:
from_file: config.json
out:
transform:
deepMerge:
- "{{.config}}" # Data (shorthand for {{.config.doc}})
Multi-Document Nodes
For nodes with multiple documents:
node:
inputs:
from_file:
- base.json
- override.json
out:
transform:
deepMerge:
- "{{.inputs}}" # All documents (in sorted order)
- "{{.inputs.base}}" # Specific document
- "{{.inputs.override}}" # Another specific document
Field Access
Access individual fields from documents in transform arguments and to_file references:
node:
config:
from_file: config.json # {"database": {"host": "localhost"}, "cache": {...}}
override:
from_file: override.json
out:
to_file:
- "{{.config.database}}": db.json # Write only database section
- "{{.config.cache}}": cache.json # Write only cache section
transform:
deepMerge:
- "{{.config.database}}" # Merge only database section
- "{{.override}}"
Field access supports nested paths:
transform:
deepMerge:
- "{{.config.services.api.database}}" # Deeply nested field
- "{{.override}}"
For multi-document nodes, the path after the node name is the document name, then field path:
node:
configs:
from_glob: "configs/*.json" # base.json, prod.json
out:
transform:
deepMerge:
- "{{.configs.base.database}}" # 'database' field in base doc
- "{{.configs.prod.database}}" # 'database' field in prod doc
Error handling:
- Missing field:
"field 'nonexistent' not found"
- Non-object value in merge:
"field 'port' is not an object (got float64), cannot use in merge transform"
jq Access
In jq expressions, nodes are available as variables:
node:
config:
from_file: config.json # Single-doc
inputs:
from_file:
- base.json # Multi-doc
- override.json
out:
transform:
jq: |
{
# Single-doc: direct access
name: $config.name,
# Multi-doc: access by doc name
base: $inputs.base,
override: $inputs.override
}
Metadata Access
Access file paths and other metadata via .meta:
node:
template:
from_file: config.gotmpl
data:
from_file: vars.yaml
out:
transform:
gotmpl:
- "{{.meta.template.path}}"
- "{{.meta.data.path}}"
Available metadata:
| Property |
Description |
.meta.node.path |
File path (empty for from_expr, from_url, from_cmd) |
.meta.node.format |
Format: json, yaml, toml, jsonnet, etc. |
.meta.node.name |
Document name |
For multi-doc nodes: .meta.node.docName.path, .meta.node.docName.format, etc.
Note: Transform nodes have no metadata.
Environment Variables
Environment variables are expanded in all string values:
node:
config:
to_file: $HOME/config.json
# or: ${HOME}/config.json
Tilde Expansion
~ is expanded to the user's home directory:
node:
config:
to_file: ~/config.json
CLI Commands
Init Command
Create a default panconf.json5 configuration file with helpful comments:
Run Command
Generate configuration files once:
Watch Command
Watch local input files and regenerate on changes:
Behavior:
- Local files: watched for changes (modifications, deletions)
- Glob patterns: watched for changes (new files, modifications, deletions)
- URL inputs: downloaded once at startup and cached (not re-fetched)
Example output:
watching /path/to/base.json (node: base)
watching /path/to/override.json (node: override)
watching for changes... (press Ctrl+C to stop)
wrote /path/to/config.json
change detected: /path/to/override.json
wrote /path/to/config.json
Transform Subcommands
Run transforms directly on files (useful for debugging):
# Deep merge files (output to stdout as YAML)
panconf transform deepMerge file1.json file2.yaml file3.toml
# Tree merge files (output to stdout as YAML)
panconf transform treeMerge file1.json file2.yaml
Examples
Environment-Specific Configuration
node:
base:
from_file: config/base.yaml
env:
from_file: config/$ENVIRONMENT.yaml
config:
to_file: config.json
transform:
deepMerge:
- "{{.base}}"
- "{{.env}}"
Run with:
ENVIRONMENT=production panconf run
Multi-File Node with Merge
node:
configs:
from_file:
- defaults.yaml
- environment.yaml
- local.yaml
merged:
to_file: config.json
transform:
deepMerge:
- "{{.configs}}" # Expands to all in sorted order
Glob with Individual Document Access
node:
services:
from_glob: "services/*.yaml"
manifest:
to_file: manifest.yaml
transform:
gotmpl_inline:
tmpl: |
services:
api:
{{ .services.api | toYaml | nindent 4 }}
worker:
{{ .services.worker | toYaml | nindent 4 }}
Using Jsonnet
node:
template:
from_file: config.jsonnet
config:
to_file: config.json
transform:
cmd:
- jsonnet
- "{{.meta.template.path}}"
Cross-Format Conversion
node:
source:
from_file: config.yaml
json:
to_file: config.json
transform:
deepMerge:
- "{{.source}}"
toml:
to_file: config.toml
transform:
deepMerge:
- "{{.source}}"
Fetching Remote Config
node:
remote:
from_url: https://api.example.com/config.json
local:
from_file: local-overrides.json
config:
to_file: config.json
transform:
deepMerge:
- "{{.remote}}"
- "{{.local}}"
Using Command Output
node:
base:
from_file: base.json
repo:
from_cmd: ["gh", "repo", "view", "--json", "owner,name"]
config:
to_file: config.json
transform:
deepMerge:
- "{{.base}}"
- "{{.repo}}"
Multiple Commands with Names
node:
info:
from_cmd:
- git: ["git", "rev-parse", "--short", "HEAD"]
- date: ["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"]
build:
to_file: build-info.json
transform:
jq: |
{
commit: $info.git,
timestamp: $info.date
}
Using Inline Templates
node:
database:
from_file: database.yaml
cache:
from_file: cache.yaml
config:
to_file: config.yaml
transform:
gotmpl_inline:
tmpl: |
services:
database:
{{ .database | toYaml | nindent 4 }}
cache:
{{ .cache | toYaml | nindent 4 }}
Using jq for Transformations
node:
config:
from_file: config.json
env:
from_file: env.yaml
result:
to_file: result.json
transform:
jq: |
{
name: $config.name,
env: $env.environment,
merged: ($config * $env)
}
Using Jsonnet as Config File
Use panconf.jsonnet for programmable configuration with functions and imports:
// panconf.jsonnet
local env = std.extVar("ENVIRONMENT");
// Helper functions
local inputNode(file) = { from_file: file };
local mergeNode(file, sources) = {
to_file: file,
transform: { deepMerge: sources },
};
// Generate nodes dynamically
local environments = ["dev", "staging", "prod"];
{
node: {
base: inputNode("config/base.json"),
[e]: inputNode("config/" + e + ".json") for e in environments,
out: mergeNode("config.json", ["{{.base}}", "{{." + env + "}}"]),
},
}
Run with:
ENVIRONMENT=prod panconf run
Validation Rules
panconf validates configuration and provides helpful error messages:
| Rule |
Error Message |
| Node must be input or transform |
node must have either a source (from_*) or a transform |
| Input cannot have multiple sources |
must specify only one of from_file, from_url, from_glob, from_cmd, or from_expr |
| Glob must include extension |
glob pattern must include a file extension |
| Transform must be specified |
transform must specify one of cmd, deepMerge, treeMerge, gotmpl, gotmpl_inline, jq, or jsonnet |
| Only one transform allowed |
transform must specify only one of cmd, deepMerge, treeMerge, gotmpl, gotmpl_inline, jq, or jsonnet |
| Valid output extension (for merge transforms) |
file extension must be .json, .yaml, .yml, or .toml |
| Cycle in node dependencies |
cycle detected: a -> b -> a |
Error Handling
Type Conflicts (treeMerge only)
When using treeMerge, type conflicts produce detailed errors:
config error: type conflict at 'settings.debug'
- defined as scalar in base.json
- defined as object in override.json
Command Failures
When a cmd transform fails, panconf shows the error and stderr:
command failed: exit status 1
stderr: jsonnet: parse error: ...
Missing Files
node 'config': file not found: config.json
Invalid Glob
node 'configs': glob pattern must include a file extension (e.g., '**.json')