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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ GitHub Releases publish:
1. **Source vs target**
- `source` = manifest, source of truth (relative to config file directory)
- `target` = live location under `$HOME` (relative path in config)
- both may include environment variables (`$VAR` / `${VAR}`) that are expanded at runtime
- expansion happens before normalization/validation
- missing/empty env vars are errors; expanded paths must still be relative and non-escaping

2. **Sync entries**
- config has `syncs[]`, each with one `target` + one `source`
Expand All @@ -70,6 +73,7 @@ GitHub Releases publish:

4. **Scoped runs**
- optional `[path]` narrows commands to matching target subpaths
- matching uses post-expansion target roots

5. **Preview and safety**
- `status` is preview
Expand Down Expand Up @@ -108,6 +112,10 @@ GitHub Releases publish:
syncs:
- target: .config/nvim
source: .config/nvim
- target: ./
source: ./global
- target: ./
source: "./$HOSTNAME/$USER"
on:
deploy:
remove-unmanaged:
Expand Down
9 changes: 2 additions & 7 deletions docs/internal/contracts/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"$defs": {
"relativePath": {
"type": "string",
"description": "Relative path only. Runtime also rejects paths that lexically escape the base directory after normalization.",
"description": "Relative path only; runtime expands `$VAR` and `${VAR}` placeholders before normalization. Missing/empty variables fail validation. Expanded paths must still be relative and non-escaping.",
"allOf": [
{
"minLength": 1
Expand All @@ -47,12 +47,7 @@
},
{
"not": {
"pattern": "^\\s*\\$"
}
},
{
"not": {
"pattern": "\\$\\{"
"pattern": "\\$\\{[^}]*$"
}
},
{
Expand Down
9 changes: 6 additions & 3 deletions docs/internal/contracts/validation-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ When `--json` is set, errors are emitted via JSON envelope (`ok=false`, `error.c
| `DFM_CONFIG_SCHEMA_UNKNOWN_KEY` | unknown key present | `Unknown config key: {key_path}` |
| `DFM_CONFIG_SCHEMA_TYPE` | wrong type for key (e.g. string instead of list) | `Invalid type at {key_path}: expected {expected}` |
| `DFM_CONFIG_SCHEMA_REQUIRED` | missing required key | `Missing required key: {key_path}` |
| `DFM_CONFIG_PATH_NOT_RELATIVE` | `syncs[].source` or `syncs[].target` is absolute / `~` / env-like | `Path must be relative: {key_path}` |
| `DFM_CONFIG_PATH_ESCAPE` | normalized config path escapes base via `..` | `Path escapes base directory: {key_path}` |
| `DFM_CONFIG_PATH_NOT_RELATIVE` | resolved `syncs[].source` or `syncs[].target` is absolute / starts with `~` / otherwise not relative after placeholder expansion | `Path must be relative: {key_path}` |
| `DFM_CONFIG_PATH_ESCAPE` | normalized config path escapes base via `..` after placeholder expansion | `Path escapes base directory: {key_path}` |
| `DFM_CONFIG_PATH_ENV_VAR_UNDEFINED` | `$VAR`/`${VAR}` placeholder references missing or empty environment variable during expansion | `Environment variable {var} required for path: {key_path}` |

Paths that include `$VAR`/`${VAR}` placeholders are expanded using the runtime environment before the checks above; missing/empty variables raise `DFM_CONFIG_PATH_ENV_VAR_UNDEFINED` and the resulting path is subjected to the usual relative/escape validation.

## CLI path scoping

Expand Down Expand Up @@ -78,7 +81,7 @@ When `--json` is set, errors are emitted via JSON envelope (`ok=false`, `error.c
To keep errors deterministic:
1. config source resolution + path checks
2. YAML parse
3. schema + path validation
3. schema + placeholder expansion + path validation (missing/empty env vars raise `DFM_CONFIG_PATH_ENV_VAR_UNDEFINED` before other path checks)
4. `[path]` matching checks
5. runtime filesystem operations

Expand Down
11 changes: 7 additions & 4 deletions docs/internal/specs/cli-and-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Rules:
- log level defaults to `info`; supported levels: `debug`, `info`, `warn`, `error`
- warnings/errors are emitted as human-readable stderr diagnostics
- `--dry-run` is valid for `deploy`/`import` only
- `[path]` narrows execution to matching target subpaths
- `[path]` narrows execution to matching target subpaths (against post-expansion target roots)

## Config surface

Expand All @@ -59,9 +59,11 @@ syncs:
```

Key constraints:
- `target`: relative to `$HOME`
- `source`: relative to config file directory
- config paths are relative-only
- `target`: relative to `$HOME` after env expansion
- `source`: relative to config file directory after env expansion
- env placeholders are supported in `target`/`source`: `$VAR`, `${VAR}`
- missing or empty env values are validation errors
- expanded paths are still required to be relative-only and non-escaping
- unknown keys are validation errors

Machine-readable schema:
Expand All @@ -81,6 +83,7 @@ Machine-readable schema:
Text mode:
- sync blocks always start with:
- `sync[idx] target=~/<target> source=./<source>`
- header uses configured path text (placeholders stay visible if present)
- each command prints only non-empty phase blocks
- summary line prints only non-zero categories
- status uses potential-action phrases (`can create`, `can update`, `can replace type`, `can add`, `can remove`)
Expand Down
6 changes: 6 additions & 0 deletions docs/internal/specs/decision-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ Examples:
- `target: .config/nvim` + `[path]=~/.config/nvim/lua` → selected (subtree only)
- `target: .config/nvim` + `[path]=~/.config` → not selected

### Env-var placeholders and CLI scope

- `syncs[].source`/`target` may contain `$VAR` and `${VAR}` placeholders expanded from the runtime environment before lexical normalization and CLI scoping.
- The post-expansion path is what `[path]` matching uses, so scope behavior remains the same (equal target or inside target subtree).
- Missing/empty placeholders, absolute post-expansion paths, or post-expansion escape (`..`) paths fail validation before scoping.

## 3) Per-path behavior matrix

| Scenario | Deploy | Import | Status |
Expand Down
5 changes: 4 additions & 1 deletion docs/internal/specs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ It complements:

| Decision | Why |
|---|---|
| `source` and `target` in config are relative-only | Prevents surprising absolute-path writes and keeps configs portable. |
| `source` and `target` in config are relative-only **after env expansion** | Prevents surprising absolute-path writes and keeps configs portable. |
| `source` is relative to config directory; `target` is relative to `$HOME` | Clear, stable roots for both sides. |
| Lexical normalization applies (`.`, `..`, duplicate separators) | Ensures consistent matching and validation. |
| Config paths may embed env placeholders (`$VAR` and `${VAR}`) that are expanded from the runtime environment before normalization | Lets configs reuse environment-specific roots while keeping resolution deterministic. |
| Missing or empty env vars referenced by path placeholders are errors | Prevents silent fallback to unintended roots. |
| Expanded paths must still sit inside their base roots and pass relative/escape validation | Preserves safety guarantees even when runtime env is used. |
| Escaping base roots via `..` is invalid | Prevents traversal outside intended scope. |
| Symlinks are treated as symlink entries (no realpath-based sync semantics) | Matches “treat like git entries” model and avoids hidden path rewrites. |
| CLI `[path]` accepts absolute, `~`-based, and relative forms | Convenient for both shell and scripting usage. |
Expand Down
4 changes: 2 additions & 2 deletions docs/internal/specs/open-questions.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
owner: Core Engineering
status: Implementation-ready
last-updated: 2026-02-16
last-updated: 2026-02-17
canonical-source: docs/internal/specs/open-questions.md
---

Expand All @@ -27,7 +27,7 @@ Most previously open items are now resolved in:
- Config is YAML-only, unknown keys are errors.
- Config resolution order is `--config` → `DOTFILES_MANAGER_CONFIG` → `./.dotfiles-manager.yaml` (cwd).
- Default config lookup is cwd-only (no parent search).
- Config `source`/`target` are relative-only.
- Config `source`/`target` accept env placeholders (`$VAR`, `${VAR}`) and are validated as relative-only after expansion.
- Lexical path normalization; base escape via `..` is invalid.
- `[path]` accepts absolute, `~`, and relative forms.
- `[path]` must be target or subpath; parent-of-target does not match.
Expand Down
5 changes: 5 additions & 0 deletions docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ Main commands:
- Config format is YAML.
- Default discovery is current-directory only (no parent-directory search).
- Config JSON Schema is available at `../internal/contracts/config-schema.json` for editor/tooling validation.
- `syncs[].target`/`syncs[].source` support env vars (`$VAR`, `${VAR}`) with strict checks:
- expansion happens before normalization/validation
- missing/empty values are errors
- expanded paths must still be relative and non-escaping
- `[path]` scoping matches against post-expansion target roots
- Unmanaged/missing behavior is opt-in by patterns; empty defaults evaluate manifest paths only.
- Logs are always written to a log file.
- macOS default: `~/Library/Logs/dotfiles-manager/dotfiles-manager.log`
Expand Down
3 changes: 3 additions & 0 deletions docs/user/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ A sync is selected only when `[path]` is:
- exactly the sync target, or
- inside the sync target subtree

Target matching uses post-expansion target roots (after `$VAR`/`${VAR}` resolution).

If `[path]` matches no syncs, command fails.

## Output and exit codes
Expand All @@ -144,6 +146,7 @@ If `[path]` matches no syncs, command fails.
- text mode prints per-sync sections with exact file operations.
- every sync header uses:
- `sync[idx] target=~/<target> source=./<source>`
- sync headers show configured path text (placeholders stay visible if present in config)
- when `[path]` scopes into a subpath, header appends:
- `scope=<sync-relative-prefix>`
- text mode only prints non-empty phase blocks.
Expand Down
14 changes: 11 additions & 3 deletions docs/user/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The default config filename is `.dotfiles-manager.yaml`.
syncs:
- target: .config/nvim
source: .config/nvim
- target: ./
source: "./$HOSTNAME/$USER"
on:
deploy:
remove-unmanaged:
Expand Down Expand Up @@ -54,8 +56,14 @@ Runtime validation is still authoritative.
## `syncs[]` keys

Required:
- `target` — relative to `$HOME`
- `source` — relative to config file directory
- `target` — relative to `$HOME` after env expansion
- `source` — relative to config file directory after env expansion

Env expansion in paths:
- supported in `target` and `source`: `$VAR` and `${VAR}`
- expansion runs before path normalization and validation
- missing or empty env values are errors
- expanded paths must still be relative and must not escape base via `..`

Optional behavior keys:
- `on.deploy.remove-unmanaged` — patterns for unmanaged target paths removed during `deploy`
Expand Down Expand Up @@ -83,7 +91,7 @@ Optional behavior keys:

## Path and order behavior

- Config paths are relative-only.
- Config paths are relative-only after env expansion.
- Path normalization is lexical.
- Paths must not escape their base via `..` after normalization.
- Overlapping syncs are allowed.
Expand Down
13 changes: 13 additions & 0 deletions docs/user/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ Use `status` first to preview both sides.

`source` is the manifest/source of truth. Command direction (`deploy` vs `import`) determines which side updates the other.

## Can I use environment variables in `source`/`target` paths?

Yes.

Supported syntax:
- `$VAR`
- `${VAR}`

Rules:
- expansion happens before path validation
- missing or empty env values are errors
- expanded paths must still be relative and must not escape base directories via `..`

## How does `[path]` filtering work?

A command-scoped `[path]` only selects syncs where that path is the sync target or inside it. Parent-of-target paths do not match.
Expand Down
12 changes: 9 additions & 3 deletions docs/user/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,19 @@ Use YAML (default filename: `.dotfiles-manager.yaml` in your current working dir
syncs:
- target: .config/nvim
source: .config/nvim
- target: ./
source: "./$HOSTNAME/$USER"
```

Path rules:
- `target` is relative to `$HOME`
- `source` is relative to the directory containing the config file
- config paths must be relative (no absolute paths)
- `target` is relative to `$HOME` after env expansion
- `source` is relative to the directory containing the config file after env expansion
- config paths must be relative after env expansion (no absolute paths)
- config paths must not escape base directories via `..` after normalization
- env vars are supported in `target`/`source`: `$VAR` and `${VAR}`
- expansion happens before path validation
- missing or empty env values are errors
- expanded path must still be relative and non-escaping

Optional editor schema:
- `docs/internal/contracts/config-schema.json`
Expand Down
13 changes: 11 additions & 2 deletions internal/app/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,8 +488,17 @@ func selectSyncs(cfg *config.Config, configPath string, pathInput any, normalize
pathValue, hasScope := normalizedPath.(string)

for idx, syncCfg := range cfg.Syncs {
targetRoot := filepath.Clean(filepath.Join(home, syncCfg.Target))
sourceRoot := filepath.Clean(filepath.Join(configDir, syncCfg.Source))
targetPath, err := config.ExpandSyncPath(syncCfg.Target, fmt.Sprintf("syncs[%d].target", idx))
if err != nil {
return nil, err
}
sourcePath, err := config.ExpandSyncPath(syncCfg.Source, fmt.Sprintf("syncs[%d].source", idx))
if err != nil {
return nil, err
}

targetRoot := filepath.Clean(filepath.Join(home, targetPath))
sourceRoot := filepath.Clean(filepath.Join(configDir, sourcePath))

if !hasScope {
selections = append(selections, syncSelection{
Expand Down
33 changes: 33 additions & 0 deletions internal/app/cli_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,39 @@ func TestSelectSyncsMultipleMatches(t *testing.T) {
require.Equal(t, "", selected[1].ScopePrefix)
}

func TestSelectSyncsExpandsEnvPaths(t *testing.T) {
t.Setenv("DFM_TEST_HOST_ENV", "host-a")
t.Setenv("DFM_TEST_USER_ENV", "alice")

cfgPath := filepath.Join(t.TempDir(), ".dotfiles-manager.yaml")
cfg := &config.Config{Syncs: []config.Sync{
{Target: ".config/$DFM_TEST_HOST_ENV", Source: "./$DFM_TEST_USER_ENV/global"},
}}

selected, err := selectSyncs(cfg, cfgPath, nil, nil)
require.NoError(t, err)
require.Len(t, selected, 1)

home, err := os.UserHomeDir()
require.NoError(t, err)
require.Equal(t, filepath.Join(home, ".config", "host-a"), selected[0].TargetRoot)
require.Equal(t, filepath.Join(filepath.Dir(cfgPath), "alice", "global"), selected[0].SourceRoot)
}

func TestSelectSyncsRejectsMissingEnvPath(t *testing.T) {
t.Setenv("DFM_TEST_EMPTY_ENV", "")

cfgPath := filepath.Join(t.TempDir(), ".dotfiles-manager.yaml")
cfg := &config.Config{Syncs: []config.Sync{
{Target: ".config/nvim", Source: "./$DFM_TEST_EMPTY_ENV/global"},
}}

selected, err := selectSyncs(cfg, cfgPath, nil, nil)
require.Nil(t, selected)
require.Error(t, err)
require.Equal(t, dfmerr.CodeConfigPathEnvUndefined, dfmerr.MustCode(err))
}

func TestIsWithinTarget(t *testing.T) {
t.Parallel()
target := filepath.Join("/tmp", "root")
Expand Down
Loading