An open API service indexing awesome lists of open source software.

https://github.com/yukimemi/yui

Target-as-truth dotfiles manager. Edit your live configs, source repo updates automatically via hardlink/junction/symlink.
https://github.com/yukimemi/yui

chezmoi-alternative cli command-line-tool cross-platform dotfiles dotfiles-manager hardlink junction linux macos rust symlink tera tera-templates windows

Last synced: about 2 months ago
JSON representation

Target-as-truth dotfiles manager. Edit your live configs, source repo updates automatically via hardlink/junction/symlink.

Awesome Lists containing this project

README

          


yui — target-as-truth dotfiles manager


結 — edit your live configs, the source repo updates itself.


crates.io
CI
License: MIT

`yui` flips the chezmoi flow: instead of editing your source repo and
running `apply` to push changes out to `~`, you edit `~` directly and
the source follows automatically. The two sides share a backing inode
(hardlink / junction / symlink), so an app's write to the target *is*
a write to source.

It exists to fix three chezmoi pain points the author hit running
chezmoi for years:

1. **The edit-source-then-apply tax** — every config tweak became a
two-step ceremony.
2. **Source ↔ target drift** — apps overwrite the target directly,
and the user finds out at the next `chezmoi diff`.
3. **Untracked new files** — apps that create new files inside a
managed directory aren't visible to chezmoi unless you remember
to `chezmoi add` them.

## How it works

Your dotfiles repo is a normal directory tree. `yui apply` walks it
and links each file/directory into its target location:

| platform | files | directories |
|----------|-------|-------------|
| Linux / macOS | symlink | symlink |
| Windows (default) | **hardlink** | **junction** |
| Windows (opt-in) | symlink | symlink (Developer Mode / admin) |

The Windows defaults are deliberate: hardlinks and junctions both
work without elevated permissions and survive most editors' "atomic
save" rename trick. When that trick *does* break the hardlink, the
**absorb classifier** notices on the next `apply` / `status`:

```
target's file-id == source's file-id? → InSync
content identical, different file-id? → RelinkOnly
target newer + content differs? → AutoAbsorb (target wins)
source newer + content differs? → NeedsConfirm (anomaly)
target missing? → Restore
```

`AutoAbsorb` backs source up under `$DOTFILES/.yui/backup/` and
copies target's content into source before relinking — your local
edit is preserved, even when an editor saved over the link.

For directories the same target-wins merge applies: target's files
land in source (overwriting on conflict), source-only scaffolding
(like `.yuilink` markers) survives, and the dir is then re-exposed
via a platform-appropriate link back to source — junction on
Windows, symlink on Unix/macOS, or whatever the configured `dir_mode`
resolves to. Non-regular entries inside the target — junctions,
symlinks, device files — are skipped with a warning since following
them safely is ill-defined.

Per-file content collisions inside the merge run through the same
absorb classifier the file-level path uses: identical content is a
no-op, target-newer copies through (AutoAbsorb), and source-newer +
diff defers to `[absorb] on_anomaly` (skip / force / ask). The
marker is consent for the *whole-tree* merge, but a single file
where the source side is newer is still a real anomaly worth
surfacing.

## Install

```sh
cargo install yui-cli
```

Pre-built binaries for Linux x86_64, Windows x86_64, and macOS
(Intel + Apple Silicon) are attached to every
[GitHub Release](https://github.com/yukimemi/yui/releases).

## Quick start

```sh
# Scaffold a source repo at the current directory and install git hooks.
yui init --git-hooks

# Edit $DOTFILES/config.toml to declare your mounts, then:
yui apply # render templates + link targets + auto-absorb drift
yui list # see every src→dst mapping at a glance
yui status # check what drifted
yui doctor # environment sanity check
```

Smallest useful `$DOTFILES/config.toml`:

```toml
[[mount.entry]]
src = "home"
dst = "~" # ~ expands to $HOME / $USERPROFILE per OS

[[mount.entry]]
src = "appdata"
dst = "{{ env(name='APPDATA') }}"
when = "yui.os == 'windows'"
```

Add files under `home/` and they'll link into `~`. Add a `.yuilink`
file to a directory to junction the whole directory as one unit (so
files an app creates inside that dir land back in source
automatically).

`src` is the path *to* yui's source for that mount; it accepts
relative paths (resolved against `$DOTFILES`), absolute paths, `~`
/ `~/...`, and Tera tags. So a private clone outside `$DOTFILES`
can participate as its own mount:

```toml
[[mount.entry]]
src = "~/.dotfiles-private/home"
dst = "~"
```

## Templates (`*.tera`)

Files ending in `.tera` are rendered with [Tera] before linking; the
output is a sibling file with the `.tera` suffix dropped. `yui` adds
the rendered file to a managed `# >>> yui rendered (auto-managed) <<<`
section of `.gitignore` so it doesn't get committed.

```
home/.gitconfig.tera → home/.gitconfig → ~/.gitconfig
```

Templates have access to `yui.os` / `yui.host` / `yui.user` /
`yui.arch` / `yui.source` and your `[vars]` table. Per-host overrides
go in `config.local.toml` (machine-local, gitignored), which `yui`
loads after `config.*.toml` so its values win.

[Tera]: https://keats.github.io/tera/

## One source → many targets

If you want the same source directory linked to different places on
different OSes — common for editor configs (`~/.config/nvim` on Unix,
`%LOCALAPPDATA%\nvim` on Windows) — drop a `.yuilink` with content:

```toml
# $DOTFILES/home/.config/nvim/.yuilink
[[link]]
dst = "~/.config/nvim"

[[link]]
dst = "{{ env(name='LOCALAPPDATA') }}/nvim"
when = "yui.os == 'windows'"
```

`yui list` shows each link and which `when` would activate it.

### Stacking markers and file-level entries

Markers compose. A parent `.yuilink` no longer stops the walker, so
you can junction a whole `~/.config` and *also* layer extra dsts onto
specific subdirs:

```toml
# $DOTFILES/home/.config/.yuilink — junction the whole .config dir
[[link]]
dst = "~/.config"
```

```toml
# $DOTFILES/home/.config/nvim/.yuilink — extra Windows-only dst
[[link]]
dst = "{{ env(name='LOCALAPPDATA') }}/nvim"
when = "yui.os == 'windows'"
```

Both links land — the parent takes care of the natural placement, the
child adds its OS-specific alternate.

A `[[link]]` may also carry a `src = ""` to scope the link to
a single sibling file rather than the directory itself. Useful for
paths that don't follow `~/.config//` conventions, like the
PowerShell profile on Windows:

```toml
# $DOTFILES/home/.config/powershell/.yuilink
[[link]]
src = "Microsoft.PowerShell_profile.ps1"
dst = "{{ env(name='USERPROFILE') }}/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
when = "yui.os == 'windows'"
```

`src` must be a single filename (no path separators); the file lives
right next to the marker. The rest of the directory still falls
through to whatever placement an ancestor (or the parent mount)
provides.

## `.yuiignore` — exclude paths from being linked

A `$DOTFILES/.yuiignore` file (gitignore syntax) keeps matched paths
out of every yui flow — render skips them, list omits them, and apply
won't link them. Useful for editor lock-files, build artifacts, OS
junk like `.DS_Store`, and anything else that lives next to your real
config but shouldn't be propagated:

```gitignore
# $DOTFILES/.yuiignore
**/.DS_Store
**/lock.json
home/.config/nvim/lazy-lock.json # exact path also works

# Exclude all of build/ except the one file we DO want linked
build/
!build/result.toml
```

Nested `.yuiignore` files inside subdirectories are honored too, with
the same rule-scoping semantics as `.gitignore`: deeper layers override
shallower ones, `!negation` re-includes paths, and rules apply only to
the subtree below the file. Put repo-wide rules at `$DOTFILES/.yuiignore`
and per-tree rules where they belong.

## Secrets (`*.age` — opt-in)

Files ending in `.age` are encrypted with [age]; on every `apply` the
ciphertext is decrypted to a sibling file without the suffix, exactly
like `*.tera` rendering. The plaintext sibling is added to the managed
`.gitignore` section so it never gets committed.

```
home/.ssh/id_ed25519.age → home/.ssh/id_ed25519 → ~/.ssh/id_ed25519
↑ committed ↑ gitignored ↑ linked
```

### Bootstrap

```sh
yui secret init # generates ~/.config/yui/age.txt
# appends the public key to $DOTFILES/config.local.toml
yui secret encrypt home/.ssh/id_ed25519
# produces home/.ssh/id_ed25519.age, ready to commit
```

`config.toml` after `init`:

```toml
[secrets]
identity = "~/.config/yui/age.txt" # picked up automatically
recipients = [
"age1abc...", # this machine's public key
# add more entries to grant other machines access
]
```

The feature is **off** until `recipients` has at least one entry — old
repos without `[secrets]` keep behaving exactly as before.

### Multi-machine

age supports multiple recipients per file. To grant a new machine
access:

1. On the new machine: `yui secret init` → generates a per-machine key,
appends its public key to `config.local.toml` `[secrets].recipients`.
Move that public-key line to `config.toml` (the committed one) so
other machines see it too.
2. On a machine that already has the secret: re-encrypt every `.age`
so its recipient list includes the new public key. (For now: run
`yui secret encrypt --force ` per file. A `yui secret reencrypt`
helper is planned.)

### Carrying the X25519 across machines (vault model)

`apply` only ever uses the plain X25519 secret at
`[secrets].identity` — no device prompts on the hot path. To
ferry that secret to a new machine, yui wraps a vault provider
of your choice (**Bitwarden** or **1Password**) so the same
auth you already use for that vault — master password, biometric,
passkey unlock in the web vault, SSO — gates the unlock here.

```toml
[secrets]
identity = "~/.config/yui/age.txt" # X25519 plain, gitignored
recipients = ["age1abc…"] # X25519 publics for *.age files

[secrets.vault]
provider = "bitwarden" # or "1password"
```

The vault item is stored under the fixed name
`yui-x25519-identity` — yui doesn't expose a per-repo override
yet (no one's hit the multi-yui-tree-on-one-vault collision in
practice, so it's hardcoded).

#### Setup (once on the first machine)

```sh
yui secret init # generates ~/.config/yui/age.txt
yui secret store # pushes the file into the vault Secure Note
```

#### On each new machine

```sh
git clone
# 1. Authenticate the vault CLI ONCE on this machine:
bw login && bw unlock # Bitwarden — or `op signin` for 1Password
# 2. Pull the X25519 from the vault:
yui secret unlock # writes ~/.config/yui/age.txt
yui apply # done
```

The vault CLI itself is the auth boundary — yui shells out to
`bw` / `op` and inherits whatever factor that CLI accepts.
Bitwarden's web vault supports passkey unlock; once you've used
your Pixel passkey to log into the BW web vault and the CLI
session is alive, `yui secret unlock` will quietly fetch the
X25519 with no further prompts.

#### Plugin recipients (advanced, unsupported)

`[secrets].recipients` accepts plugin-flavoured public keys
(`age1yubikey1…`, `age1fido2-hmac1…`, …) alongside the X25519
ones. yui doesn't ship first-class commands for plugin
identities — `apply` decrypts only with the X25519 in
`[secrets].identity` — but encrypting `*.age` files to
plugin-backed *recipients* works as long as the matching
`age-plugin-*` binary is on `$PATH`, so a YubiKey holder can
decrypt the same file via `age` directly without yui in the
loop. No support promises, but the path is open.

[age]: https://age-encryption.org/

## Hooks — run scripts around `apply`

Drop a script under `$DOTFILES/.yui/bin/` and reference it with a
`[[hook]]` entry. The script stays a normal executable (you can run
it directly without yui); yui just decides *when* to invoke it.

```toml
[[hook]]
name = "brew-bundle"
script = ".yui/bin/brew-bundle.sh"
# Defaults: command="bash", args=["{{ script_path }}"], when_run="onchange", phase="post"
when = "yui.os == 'macos'"
```

Schema:

| field | required? | default | meaning |
|---|---|---|---|
| `name` | ✓ | — | unique identifier (state-tracking key, `yui hooks run `) |
| `script` | ✓ | — | path to script, relative to `$DOTFILES` |
| `command` | | `"bash"` | Tera-templated interpreter |
| `args` | | `["{{ script_path }}"]` | Tera-templated each |
| `when_run` | | `"onchange"` | `once` \| `onchange` \| `every` |
| `phase` | | `"post"` | `pre` \| `post` (around `apply`) |
| `when` | | _always_ | optional Tera bool predicate |

`onchange` re-runs whenever the script's SHA-256 differs from the
last successful run. State is tracked in `$DOTFILES/.yui/state.json`
(gitignored — it's per-machine).

`command` and each `args` element are Tera-rendered with the standard
`yui.*` / `vars.*` / `env(...)` plus these extras for the script:
`script_path`, `script_dir`, `script_name`, `script_stem`,
`script_ext`. Example with a Deno script:

```toml
[[hook]]
name = "denops-build"
script = ".yui/bin/build.ts"
command = "deno"
args = ["run", "-A", "{{ script_path }}"]
when_run = "onchange"
phase = "post"
when = "yui.os != 'windows'"
```

Manual control:

```sh
yui hooks list # what's configured + last-run state
yui hooks run # run all hooks per their rules
yui hooks run brew-bundle # run just this one (still honors `when`)
yui hooks run brew-bundle --force # bypass when_run state check
```

`apply` runs `pre` hooks before render/link and `post` hooks after
all linking. A failing hook stops `apply` immediately — fix the
script, then re-run.

## Anomalies and the `[absorb]` policy

When source AND target both diverge from each other, `yui` can't
auto-merge. It defers to your `[absorb] on_anomaly` setting:

```toml
[absorb]
auto = true # auto-absorb on any AutoAbsorb classification
require_clean_git = true # treat dirty source as anomaly
on_anomaly = "ask" # "ask" | "skip" | "force"
```

- `ask` — on a TTY, render the diff and prompt y/N; off-TTY, skip
- `skip` — log a warning and leave both sides untouched
- `force` — treat the anomaly as auto-absorb anyway (target wins)

Need to absorb a single file regardless of policy? `yui absorb
` does that — bypasses `auto`, `require_clean_git`, and
`on_anomaly` for an explicit user-initiated pull.

## Commands

| | |
|---|---|
| `yui init [--git-hooks]` | scaffold `config.toml` + `.gitignore` in cwd |
| `yui apply [--dry-run]` | render → link → auto-absorb |
| `yui render [--check] [--dry-run]` | template-only pass; `--check` fails on drift |
| `yui link [--dry-run]` | alias for apply (kept for muscle memory) |
| `yui list [--all] [--icons MODE] [--no-color]` | every src→dst mapping |
| `yui status [--icons MODE] [--no-color]` | drift overview, exits non-zero on any divergence |
| `yui diff [--icons MODE] [--no-color]` | unified diff of every drifted entry (link or render) |
| `yui absorb [--dry-run] [--yes]` | pull one target into source — prints diff, confirms (`--yes` to skip) |
| `yui unlink ...` | tear down a specific link |
| `yui update [--dry-run]` | `git pull --ff-only` source repo, then re-apply |
| `yui unmanaged [--icons MODE] [--no-color]` | list source files no `[[mount.entry]]` claims |
| `yui doctor` | environment sanity check |
| `yui gc-backup [--older-than DUR] [--dry-run]` | survey or prune `.yui/backup/` snapshots by suffix age |
| `yui hooks list` | show configured `[[hook]]` entries + last-run state |
| `yui hooks run [] [--force]` | run hooks on demand (bypassing `when_run` with `--force`) |
| `yui completion ` | print shell completion (bash / zsh / fish / powershell / elvish) |

`--icons` accepts `unicode` (default), `nerd` (Nerd-Font glyphs),
`ascii` (CI-log-safe). The `[ui] icons = "..."` config key sets it
globally.

## Status

Used in production for the author's own ~/dotfiles. Known gaps:

- no built-in encryption (use `pass` / `1password-cli` from a Tera
template instead)

## License

MIT