Skip to content

Latest commit

 

History

281 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

yui 結 — target-as-truth dotfiles manager

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

crates.io CI License: MIT Ask DeepWiki View Code Wiki

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.

A whole-directory absorb is ordered for crash safety, which matters once the tree is a real ~/.config. The target is moved aside with a single same-volume rename, the link goes up immediately, and only then does the slow work — merging content into source, deleting the moved tree — run against the staged copy. So the expensive steps happen while the live path already resolves to source: pull the power in the middle and you are left with a working target plus a <target>.yui-absorb.<timestamp> directory beside it, never a half-deleted config dir.

That leftover is also its own retry plan — the name says what is still owed, so no journal file is involved. The next apply sweeps it before doing anything else: .yui-absorb. trees are merged into source and then removed (they may hold the only copy of a target-side edit), .yui-discard. trees are removed unread (their content already reached $DOTFILES/.yui/backup/ before the rename). Recovery runs ahead of the classifier on purpose — a relinked target reads InSync, so classification alone would walk right past the leftover. If the rename itself is refused — a file held open inside the tree on Windows, say — yui warns and falls back to deleting in place.

Install

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.

Quick start

# 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:

[[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 ~. Directory-level linking — junction the whole directory as one unit, so files an app creates inside it land back in source automatically — is declared with a [[link]] entry, either centrally in config.toml or as a .yuilink marker in the directory itself. See Explicit links ([[link]]).

Link mechanism is [mount]'s business:

[mount]
file_mode = "auto"   # auto | symlink | hardlink
dir_mode  = "auto"   # auto | symlink | junction

auto is symlink on Unix, hardlink (files) + junction (dirs) on Windows — the pair that needs no Developer Mode or admin.

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:

[[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.

Explicit links ([[link]])

A [[link]] entry says "link this source path to that target". It covers the two things mount entries can't express: a directory linked as one unit, and a target outside the mount's dst root (%LOCALAPPDATA%, Documents/PowerShell, …).

Entries live in either of two places, with the same schema:

where src when to prefer it
$DOTFILES/config.toml required, relative to $DOTFILES everything in one file; nothing extra in the tree, nothing extra visible from the target side
<dir>/.yuilink optional, relative to the marker's dir; omitted = the marker's dir the declaration travels with the directory — rename or delete it and the rule goes too
# $DOTFILES/config.toml — central table
[[link]]
src = "home/.config/nvim"
dst = "{{ env(name='LOCALAPPDATA') }}/nvim"
when = "yui.os == 'windows'"
# $DOTFILES/home/.config/nvim/.yuilink — same rule, declared in place
[[link]]
dst = "{{ env(name='LOCALAPPDATA') }}/nvim"
when = "yui.os == 'windows'"

An empty .yuilink is shorthand for "link this dir at the mount's natural dst" — the one thing the central table has no spelling for, since it always names its dst explicitly.

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

One source → many targets

Entries stack. Nothing stops the walker, so a whole ~/.config can be one junction while individual subdirs add extra dsts on top:

# $DOTFILES/home/.config/.yuilink — junction the whole .config dir
[[link]]
dst = "~/.config"
# $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. Central entries join the same stack; an entry that duplicates a marker exactly (same src, dst and when) collapses into it instead of doubling the list / status rows.

File-scoped entries

A src naming a file scopes the link to that file rather than its directory. Useful for paths that don't follow ~/.config/<app>/ conventions, like the PowerShell profile on Windows:

# $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'"

File scope doesn't claim the directory, so the rest of it still falls through to whatever placement an ancestor (or the parent mount) provides.

Per-entry mode

[mount] file_mode / dir_mode set the mechanism for the whole repo. One entry can opt out with mode:

[[link]]
src = "home/.config/someapp"
dst = "~/.config/someapp"
mode = "symlink"        # this dir only; everything else stays junction

That matters on Windows, where flipping dir_mode globally means every directory link needs Developer Mode or admin — a single app that refuses to work through a junction shouldn't cost the other twenty links their privilege-free default.

Legal values depend on what the entry links, and a mismatch is a config error at declaration time rather than a surprise at link time:

entry links mode default (auto)
a directory auto | symlink | junction symlink on Unix, junction on Windows
a file auto | symlink | hardlink symlink on Unix, hardlink on Windows

yui list grows a MODE column as soon as any entry overrides, so the odd one out is visible without reading the config.

Rules for src

  • Relative, and it has to stay inside its base: .., absolute paths and . are rejected. Subdirectories are fine (src = "sub/profile.ps1").
  • In a .yuilink, src names a file. Directory scope is what the marker itself means, so pointing one at a subdirectory is an error that tells you to put a marker there (or use the central table).
  • A central src may name a file or a directory. Existence is checked when the walk reaches it, after when*.tera output and *.age plaintext siblings don't exist until apply has run, so a load-time check would fail list / status on a fresh clone.
  • A central entry whose directory lies outside every mount's subtree can't be reached by any walk. apply / status warn about it rather than linking nothing in silence.
  • Markers are only read when the mount's strategy is marker (the default). Central entries are explicit instructions and apply under per-file too.

.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:

# $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.

.gitignore is honored too

Because yui is target-as-truth, your source tree is the live config — so apps write runtime state (session logs, caches, credentials) straight into it through the links. That state is already excluded from git, and by default yui excludes it as well: every directory's .gitignore is layered underneath its .yuiignore during the walk.

Without this, a tool that keeps a few hundred session files under ~/.some-app/ would mint a hardlink per file and bury yui status in noise it can do nothing about.

Two things to know:

  • .yuiignore wins. It's checked first in each directory, so !home/keep.log re-includes a file that the sibling .gitignore excluded — the escape hatch for "git shouldn't track this, but yui should still link it".
  • yui's own generated files are exempt. Rendered .tera output and decrypted .age plaintext are listed in the auto-managed block of $DOTFILES/.gitignore precisely because yui generates them. Lines between the # >>> yui rendered … >>> markers are skipped, so this can never stop apply from linking a file it just produced.

Opt out to restore .yuiignore-only walking:

[mount]
respect_gitignore = false

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

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:

[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 <path> 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.

[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)

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

On each new machine

git clone <dotfiles>
# 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.

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.

[[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 <name>)
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:

[[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:

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:

[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 <target-path> 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 <target> [--dry-run] [--yes] pull one target into source — prints diff, confirms (--yes to skip)
yui unlink <path>... 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] [--keep-last N] [--dry-run] survey or prune .yui/backup/ snapshots
yui hooks list show configured [[hook]] entries + last-run state
yui hooks run [<name>] [--force] run hooks on demand (bypassing when_run with --force)
yui secret <init|encrypt|store|unlock> manage *.age secrets + the X25519 identity — see Secrets
yui self-update [--check] [--yes] [--non-interactive] replace yui's own binary with the latest GitHub release (--check only reports; --non-interactive + --yes drives it from scripts)
yui completion <shell> 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.

gc-backup with no rule surveys; --older-than DUR (30d, 2w, 12h, 6mo, 1y — bare m is minutes, months are mo) and --keep-last N are a retention policy, so an entry survives if either rule protects it--older-than 30d --keep-last 3 prunes anything over a month old except the three newest snapshots of each target. Generation count is the axis age can't reach: the snapshot that fills a disk is usually the freshest one, taken the moment a large directory was first absorbed. --keep-last 0 protects nothing, which is the honest spelling for "purge".

Background auto-update ([ui] auto_update)

After any command other than self-update / completion, yui can keep itself current in the background. [ui] auto_update picks the behavior:

value behavior
"install" (default) Silently download + swap yui's own binary in the background. The running process keeps the old binary; the new version applies on the next launch. Prints one line (✓ yui <version> installed in the background — restart to apply.) only when an install actually happened.
"notify" Hit GitHub and show a banner at exit if a newer release exists, but never install — you run yui self-update yourself.
"off" Do nothing.

The work runs at most once per [ui] update_check_interval (humantime format, e.g. "24h" / "1d" / "30m"; default 24h), is skipped for dev builds, is serialized across concurrent processes, and stays silent on any network / lock failure.

Set the YUI_NO_AUTOUPDATE environment variable (to anything other than empty / 0 / false) to disable auto-update entirely — it takes precedence over the config.

The boolean [ui] auto_update_check is a deprecated alias kept for backward compatibility: true maps to notify, false maps to off. An explicit auto_update always wins; using auto_update_check emits a one-time migration warning.

Status

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

  • no yui secret reencrypt yet — rotating the recipient list means running yui secret encrypt --force <path> per file for now
  • the vault item name (yui-x25519-identity) is hardcoded — no per-repo override

License

MIT

About

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

Topics

Resources

Stars

11 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages