Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ internal/
recipe.go Recipe loading, discovery, and merging
sources.go Remote recipe sources ([[recipe_sources]]): cache under ~/.config/ralph/sources/
overrides.go Set/remove recipe overrides in config.toml (text-level, backup+validate)
vars.go Recipe variables: {{vars.<name>}} expansion in shell items ([recipe.vars] defaults + override values)
migrate.go MigrateFromLegacy (dotter → ralph)
dotfile/
symlink.go Create/update symlinks and dir symlinks
Expand Down
4 changes: 3 additions & 1 deletion cmd/ralph/commands/cmd_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,9 @@ func applyDirsMirror(ctx *applyContext, symlinkAction dotfile.SymlinkAction) {
fmt.Fprintf(ctx.w, " %s\n", bold(name))

// Resolve source directory
absoluteSource, err := config.ExpandPath(config.JoinSourcePath(ctx.cfg.DotfilesRepoPath, dm.Source))
absoluteSource, err := config.ExpandPath(
config.JoinSourcePath(ctx.cfg.DotfilesRepoPath, dm.Source),
)
if err != nil {
fmt.Fprintln(
os.Stderr,
Expand Down
6 changes: 4 additions & 2 deletions cmd/ralph/commands/sources_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ func makeSourceFixture(t *testing.T, name, ref string) (origin, checkout string)
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
cmd.Env = append(
os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test",
)
Expand Down Expand Up @@ -62,7 +63,8 @@ func advanceOrigin(t *testing.T, origin string) string {
for _, args := range [][]string{{"add", "."}, {"commit", "-m", "second"}} {
cmd := exec.Command("git", args...)
cmd.Dir = origin
cmd.Env = append(os.Environ(),
cmd.Env = append(
os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test",
)
Expand Down
11 changes: 9 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,19 +544,23 @@ exclude = ["experimental/*"]

#### `[recipes_config.overrides.<name>]`

Override `enable` and `hosts` for auto-discovered recipes by directory name.
Override `enable`, `hosts`, and `vars` for auto-discovered recipes by directory name.

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `enable` | bool (pointer) | no | `nil` | Enable/disable. |
| `hosts` | string array | no | `[]` | Host filtering. |
| `vars` | map | no | `{}` | Values for variables the recipe declares in `[recipe.vars]`. Overriding an undeclared variable is an error. See [Recipe variables](recipes.md#recipe-variables). |

```toml
[recipes_config.overrides.work-tools]
hosts = ["work-laptop"]

[recipes_config.overrides.deprecated-stuff]
enable = false

[recipes_config.overrides."thismoon/toss-bin"]
vars = { alias_name = "del" }
```

### `[[recipe_sources]]`
Expand Down Expand Up @@ -612,11 +616,14 @@ profiles = ["personal"] # optional; recipe applies only on machines whose profi
[recipe.legacy_paths]
"ralph_files/nvim/init.lua" = "nvim/init.lua"

[recipe.vars]
editor_alias = "vim" # optional; referenced as {{vars.editor_alias}} in shell items, overridable per machine

[dotfiles.nvim_init]
source = "init.lua"
target = "~/.config/nvim/init.lua"

[shell.aliases.vim]
[shell.aliases."{{vars.editor_alias}}"]
command = "nvim"
```

Expand Down
27 changes: 26 additions & 1 deletion docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ wave = 0 # optional: build before wave-1 recipes
| `wave` | int | Execution wave for the recipe's builds and packages (default `1`). Lower waves run first. See [Build ordering with waves](#build-ordering-with-waves). |
| `legacy_paths` | map | Old-to-new path mappings for [migration](migration.md) after reorganizing files |
| `delete_behavior` | string | `"delete"` (default) or `"abandon"`. Controls what happens to the recipe's artifacts when it disappears from the config. See [Recipe deletion and cleanup](#recipe-deletion-and-cleanup). |
| `vars` | map | Declared variables with default values, referenced as `{{vars.<name>}}` in shell items and overridable per machine. See [Recipe variables](#recipe-variables). |

### Available sections

Expand Down Expand Up @@ -111,7 +112,7 @@ enable = false
| `auto_discover` | bool | Enable auto-discovery |
| `dir` | string | Directory to search (default: `"recipes"`) |
| `exclude` | list | Glob patterns to exclude from discovery |
| `overrides` | map | Per-recipe overrides keyed by directory name, supporting `enable` and `hosts` |
| `overrides` | map | Per-recipe overrides keyed by directory name, supporting `enable`, `hosts`, and `vars` |

### Remote recipe sources

Expand Down Expand Up @@ -160,6 +161,30 @@ hosts = [] # explicit empty = all hosts is NOT supported; omit the field instea

In practice, items without a `hosts` field inherit the recipe-level filter, and items with a `hosts` field use their own.

## Recipe variables

A recipe can declare variables with defaults under `[recipe.vars]` and reference them as `{{vars.<name>}}` in shell alias and function names, alias commands, and function bodies. A machine sets its own values per recipe through `[recipes_config.overrides.<key>].vars` — the same key enable/hosts overrides use (directory name for local recipes, `"<source>/<recipe>"` for remote ones).

```toml
# recipes/toss-bin/recipe.toml — ships an rm wrapper by default
[recipe]
name = "toss-bin"

[recipe.vars]
alias_name = "rm"

[shell.functions."{{vars.alias_name}}"]
body = 'toss-bin --safe-mode "$@"'
```

```toml
# config.toml on a machine that wants the wrapper under a different name
[recipes_config.overrides."thismoon/toss-bin"]
vars = { alias_name = "del" }
```

Expansion happens before the recipe merges into the config, so expanded names take part in [name conflict detection](#name-conflict-detection) and shell-name validation. Overriding a variable the recipe does not declare is an error, and so is referencing an undeclared variable — a typo fails the run instead of generating a half-expanded name. Variables expand only in shell aliases and functions; other recipe sections do not see them.

## Name conflict detection

Ralph raises an error if the same item name appears in multiple recipes or in both a recipe and the main config. This applies to dotfiles, directories, repos, shell aliases, shell functions, shell env vars, pre/post link hooks, builds, packages, and template variables.
Expand Down
11 changes: 11 additions & 0 deletions internal/config/recipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ func ProcessRecipes(cfg *Config, currentHost string) error {

// Process each recipe
for _, ref := range recipeRefs {
// Vars overrides key on the recipe directory name, like enable/hosts.
dirName := filepath.Base(filepath.Dir(ref.Path))
// Local recipes resolve paths relative to the dotfiles repo.
if err := processRecipeRef(
cfg,
Expand All @@ -415,6 +417,7 @@ func ProcessRecipes(cfg *Config, currentHost string) error {
filepath.Dir(ref.Path),
"",
currentHost,
cfg.RecipesConfig.Overrides[dirName].Vars,
); err != nil {
return err
}
Expand All @@ -434,6 +437,7 @@ func processRecipeRef(
ref RecipeRef,
loadRoot, resolveDir, namePrefix string,
currentHost string,
overrideVars map[string]string,
) error {
// Disabled recipes are intentionally cleaned up — skip entirely.
if !IsEnabled(ref.Enable) {
Expand Down Expand Up @@ -484,6 +488,12 @@ func processRecipeRef(
// Get recipe name for error messages
recipeName := namePrefix + resolveRecipeName(recipe, ref)

// Expand {{vars.<name>}} placeholders before merging so the final
// names take part in duplicate detection and name validation.
if err := ExpandRecipeVars(recipe, overrideVars); err != nil {
return fmt.Errorf("recipe '%s': %w", recipeName, err)
}

// Merge into config
if err := MergeRecipeIntoConfig(cfg, recipe, recipeName); err != nil {
return err
Expand Down Expand Up @@ -556,6 +566,7 @@ func processRecipeSources(cfg *Config, currentHost string) error {
filepath.Join(checkout, filepath.Dir(ref.Path)),
src.Name+"/",
currentHost,
cfg.RecipesConfig.Overrides[src.Name+"/"+ref.Name].Vars,
); err != nil {
return err
}
Expand Down
3 changes: 2 additions & 1 deletion internal/config/sources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ func makeSourceRepo(t *testing.T, recipeName string) (repoPath, first, second st
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repoPath
cmd.Env = append(os.Environ(),
cmd.Env = append(
os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test",
)
Expand Down
8 changes: 5 additions & 3 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,11 @@ type RecipeSource struct {
// DefaultSourcesDir is the default cache directory for remote recipe sources.
const DefaultSourcesDir = "~/.config/ralph/sources"

// RecipeOverride provides enable/hosts overrides for auto-discovered recipes.
// RecipeOverride provides enable/hosts/vars overrides for auto-discovered recipes.
type RecipeOverride struct {
Enable *bool `toml:"enable,omitempty"` // nil/true = enabled, false = disabled
Hosts []string `toml:"hosts,omitempty"` // List of hostnames this recipe should apply to (empty = all hosts)
Enable *bool `toml:"enable,omitempty"` // nil/true = enabled, false = disabled
Hosts []string `toml:"hosts,omitempty"` // List of hostnames this recipe should apply to (empty = all hosts)
Vars map[string]string `toml:"vars,omitempty"` // Values for variables the recipe declares in [recipe.vars]; overriding an undeclared variable is an error
}

// RecipesConfig holds configuration for auto-discovery mode.
Expand Down Expand Up @@ -245,6 +246,7 @@ type RecipeMetadata struct {
Wave *int `toml:"wave,omitempty"` // Execution wave: lower waves complete first (nil = unset → defaults to 1; wave 0 runs before default)
Caveats string `toml:"caveats,omitempty"` // Post-apply instructions shown when a package in this recipe is rebuilt
Profiles []string `toml:"profiles,omitempty"` // Machine profile labels this recipe belongs to; recipe applies when these intersect the machine's profiles (empty = all)
Vars map[string]string `toml:"vars,omitempty"` // Declared variables with their defaults; referenced as {{vars.<name>}} in shell alias/function names, commands, and bodies, overridable via [recipes_config.overrides.<key>].vars
}

// DeleteBehaviorDelete instructs ralph to remove orphaned artifacts when a recipe is gone.
Expand Down
113 changes: 113 additions & 0 deletions internal/config/vars.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package config

import (
"fmt"
"maps"
"regexp"
"sort"
"strings"
)

// varPlaceholder matches {{vars.<name>}} references in recipe strings.
var varPlaceholder = regexp.MustCompile(`\{\{vars\.([A-Za-z_][A-Za-z0-9_]*)\}\}`)

// ExpandRecipeVars resolves {{vars.<name>}} placeholders in the recipe's
// shell aliases and functions — names, commands, and bodies — using the
// recipe's declared [recipe.vars] defaults overlaid with per-machine
// override values. It runs before the recipe merges into the config, so
// expanded names take part in duplicate detection and name validation.
// Overriding a variable the recipe does not declare is an error, as is a
// placeholder no declared variable resolves.
func ExpandRecipeVars(recipe *Recipe, overrides map[string]string) error {
declared := recipe.Recipe.Vars
for name := range overrides {
if _, ok := declared[name]; !ok {
return fmt.Errorf(
"vars override '%s' is not declared in [recipe.vars] (declared: %s)",
name,
declaredNames(declared),
)
}
}

values := make(map[string]string, len(declared))
maps.Copy(values, declared)
maps.Copy(values, overrides)

expand := func(field, s string) (string, error) {
out := varPlaceholder.ReplaceAllStringFunc(s, func(match string) string {
name := varPlaceholder.FindStringSubmatch(match)[1]
if value, ok := values[name]; ok {
return value
}
return match
})
if match := varPlaceholder.FindString(out); match != "" {
return "", fmt.Errorf(
"%s references %s, which is not declared in [recipe.vars] (declared: %s)",
field,
match,
declaredNames(declared),
)
}
return out, nil
}

aliases := make(map[string]ShellAlias, len(recipe.Shell.Aliases))
for name, alias := range recipe.Shell.Aliases {
newName, err := expand(fmt.Sprintf("shell alias '%s'", name), name)
if err != nil {
return err
}
alias.Command, err = expand(fmt.Sprintf("shell alias '%s' command", name), alias.Command)
if err != nil {
return err
}
if _, exists := aliases[newName]; exists {
return fmt.Errorf(
"shell alias '%s': expanded name '%s' collides with another alias",
name,
newName,
)
}
aliases[newName] = alias
}
recipe.Shell.Aliases = aliases

functions := make(map[string]ShellFunction, len(recipe.Shell.Functions))
for name, function := range recipe.Shell.Functions {
newName, err := expand(fmt.Sprintf("shell function '%s'", name), name)
if err != nil {
return err
}
function.Body, err = expand(fmt.Sprintf("shell function '%s' body", name), function.Body)
if err != nil {
return err
}
if _, exists := functions[newName]; exists {
return fmt.Errorf(
"shell function '%s': expanded name '%s' collides with another function",
name,
newName,
)
}
functions[newName] = function
}
recipe.Shell.Functions = functions

return nil
}

// declaredNames renders the declared variable names sorted, for stable
// error messages; "none" when the recipe declares no variables.
func declaredNames(declared map[string]string) string {
if len(declared) == 0 {
return "none"
}
names := make([]string, 0, len(declared))
for name := range declared {
names = append(names, name)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
Loading
Loading