From 0f4c011401792c06fea7ef5a6311ee55781636b7 Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 06:44:33 +1100 Subject: [PATCH 01/34] docs(changelog): explain the Homebrew trust step for the cask migration Homebrew 7 does not install an untrusted cask during a tap migration, so existing formula users have to trust and install the cask themselves. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f69a405..81ce57f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A source's `.mate.yaml` accepts `include`, like `mate.yaml`. Included packages count as that source's packages, and included variables are available to every template while the source is active, including the `.mate.yaml` itself. Paths are relative to the source directory. Previously the key was silently ignored ### Changed -- The Homebrew package is now a cask instead of a formula. `brew install subbeh/tap/statemate` works as before, and existing formula installs are moved to the cask on `brew upgrade` +- The Homebrew package is now a cask instead of a formula. `brew install subbeh/tap/statemate` works as before. `brew update` migrates an existing formula install, but Homebrew 7 only installs a trusted cask, so if it reports that the migration target is not trusted, run `brew uninstall --formula statemate`, `brew trust --cask subbeh/tap/statemate` and `brew install --cask subbeh/tap/statemate` - `mate apply` now lists the files it is about to change and asks for confirmation before doing anything. Pass `--noconfirm` to skip the prompt (`--force` and `--dry-run` skip it too). Without a terminal the apply stops, so unattended runs must pass `--noconfirm` or `--force` - `mate clean`, `mate delete` and `mate packages apply` accept `--noconfirm` as well, as an alias for their `--force` / `--yes` flag, so the same flag skips prompts on every command that asks - Pressing Enter at a script or hook prompt (`[Y]es / [n]o / [a]ll / [q]uit`) now runs it, as `y` does. Before, Enter was ignored and the prompt waited for a letter From ff8b89e47f831c64b6012855be0656b2384901ce Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 07:07:35 +1100 Subject: [PATCH 02/34] feat(source)!: shorten attributes to #enc, #tmpl and #link The long #encrypted, #template and #symlink attributes are replaced by #enc, #tmpl and #link, for scripts too. Any attribute mate does not recognise is now an error naming the file, as is a #perm: mode that is not octal. Before, it was dropped silently, so a typo deployed the file without its effect, and a leftover #encrypted would have deployed ciphertext. The state database is migrated once (user_version 1) to rewrite recorded script and source paths to the short names. Scripts are looked up by path, so renaming the sources would otherwise make every #once script look unrun. BREAKING CHANGE: sources and scripts named with #encrypted, #template or #symlink must be renamed to #enc, #tmpl or #link. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++ README.md | 4 +- docs/README.md | 6 +-- docs/attributes.md | 56 ++++++++++---------- docs/commands/mate_cat.md | 2 +- docs/commands/mate_decrypt.md | 10 ++-- docs/commands/mate_doctor.md | 2 +- docs/commands/mate_edit.md | 2 +- docs/commands/mate_encrypt.md | 2 +- docs/commands/mate_eval.md | 6 +-- docs/commands/mate_hooks.md | 2 +- docs/commands/mate_rename.md | 6 +-- docs/concepts.md | 4 +- docs/configuration.md | 12 ++--- docs/encryption.md | 26 +++++----- docs/hooks.md | 8 +-- docs/machines.md | 4 +- docs/repository-layout.md | 6 +-- docs/scripts.md | 10 ++-- docs/secrets.md | 8 +-- docs/system-files.md | 2 +- docs/templates.md | 12 ++--- docs/tips.md | 18 +++---- docs/troubleshooting.md | 6 +-- internal/cli/add.go | 4 +- internal/cli/add_test.go | 4 +- internal/cli/apply_test.go | 6 +-- internal/cli/cat.go | 2 +- internal/cli/decrypt.go | 24 ++++----- internal/cli/decrypt_test.go | 24 ++++----- internal/cli/diff.go | 2 +- internal/cli/diff_test.go | 4 +- internal/cli/doctor.go | 12 ++--- internal/cli/doctor_test.go | 12 ++--- internal/cli/edit.go | 2 +- internal/cli/edit_test.go | 8 +-- internal/cli/encrypt.go | 46 +++++++---------- internal/cli/eval.go | 6 +-- internal/cli/hooks.go | 2 +- internal/cli/managed.go | 6 +-- internal/cli/managed_test.go | 30 +++++------ internal/cli/rename.go | 14 ++--- internal/cli/rename_test.go | 16 +++--- internal/cli/scripts.go | 2 +- internal/cli/scripts_run_test.go | 8 +-- internal/cli/secrets_test.go | 40 +++++++-------- internal/config/parse.go | 22 ++++---- internal/hooks/hooks_test.go | 24 ++++----- internal/scripts/confirm_test.go | 2 +- internal/scripts/discover.go | 4 ++ internal/scripts/scripts_test.go | 19 ++++++- internal/scripts/types.go | 28 ++++++++-- internal/source/attrs.go | 53 ++++++++++++++++--- internal/source/scanner.go | 7 +++ internal/source/source_test.go | 77 +++++++++++++++++++++++----- internal/state/db.go | 82 +++++++++++++++++++++++++++++- internal/state/files.go | 2 +- internal/state/migrate_test.go | 59 +++++++++++++++++++++ internal/target/apply.go | 6 +-- internal/target/apply_test.go | 8 +-- internal/target/diff.go | 6 +-- internal/target/import_test.go | 14 ++--- internal/target/symlink_test.go | 4 +- internal/template/context.go | 11 ++-- internal/template/template_test.go | 4 +- internal/util/path.go | 15 ++++++ internal/util/path_test.go | 33 ++++++++++++ 67 files changed, 643 insertions(+), 329 deletions(-) create mode 100644 internal/state/migrate_test.go create mode 100644 internal/util/path_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ce57f..88c0590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Breaking:** the `#encrypted`, `#template` and `#symlink` attributes are renamed to `#enc`, `#tmpl` and `#link`, and the long forms are no longer recognised. Scripts use `#tmpl` too. Rename existing sources, for example `ssh/.ssh/config#encrypted` to `ssh/.ssh/config#enc`; mate stops with "#encrypted was renamed to #enc" until they are. `mate encrypt` and `mate add --encrypt`/`--template` write the short forms, and a `var_files` or `include` entry named without a suffix finds the `#enc` file. Recorded script runs follow the rename, so `#once` scripts do not run again +- An unrecognised attribute in a file, directory or script name is now an error that names the file, and so is a `#perm:` mode that is not octal. Before, it was dropped silently, so a typo such as `config#templte` deployed an unrendered `config` + ## [0.6.0] - 2026-10-17 ### Added diff --git a/README.md b/README.md index e962f08..36cff85 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ A source directory is deployed relative to your home directory: Behavior is controlled by `#` suffixes on filenames, stripped from the target: ``` -.ssh/config#encrypted#perm:600 encrypted in the repo, mode 0600 deployed -.gitconfig#template rendered as a Go template +.ssh/config#enc#perm:600 encrypted in the repo, mode 0600 deployed +.gitconfig#tmpl rendered as a Go template .gitconfig#profile:work only on machines matching the work profile .claude/settings.json#import app owns it; changes flow back to the repo ``` diff --git a/docs/README.md b/docs/README.md index 761d175..15da1f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,8 +8,8 @@ makes it so. ~/dotfiles/ mate.yaml sources, profiles, packages, hooks zsh/.zshrc → ~/.zshrc - git/.config/git/config#template → ~/.config/git/config, rendered per machine - ssh/.ssh/config#encrypted → ~/.ssh/config, decrypted + git/.config/git/config#tmpl → ~/.config/git/config, rendered per machine + ssh/.ssh/config#enc → ~/.ssh/config, decrypted arch/etc#owner-r:root/keyd/… → /etc/keyd/…, owned by root nvim/.mate.yaml packages: { common: [neovim] } nvim/.matescripts/ @@ -55,7 +55,7 @@ makes it so. | Reference | Contents | |-----------|----------| | [Configuration](configuration.md) | `mate.yaml`, `.mate.yaml`, profiles, local overrides | -| [File Attributes](attributes.md) | `#template`, `#encrypted`, `#import`, `#perm:`, and the rest | +| [File Attributes](attributes.md) | `#tmpl`, `#enc`, `#import`, `#perm:`, and the rest | | [Templates](templates.md) | Variables and functions available when rendering | | [Secrets](secrets.md) | Bitwarden references and the encrypted cache | | [Scripts](scripts.md) | Lifecycle scripts, frequency, timing | diff --git a/docs/attributes.md b/docs/attributes.md index 42a6c4f..670561b 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -1,24 +1,24 @@ # File Attributes Attributes are `#`-suffixes on a filename that control how a file is deployed. -They are stripped from the target name, so `config#encrypted#perm:600` deploys as +They are stripped from the target name, so `config#enc#perm:600` deploys as `config`. ``` -.ssh/config#encrypted#perm:600 → ~/.ssh/config, decrypted, mode 0600 +.ssh/config#enc#perm:600 → ~/.ssh/config, decrypted, mode 0600 ``` Order does not matter, and any number can be combined. An unrecognised attribute -is ignored silently but still stripped, so a typo such as `config#templte` -deploys an unrendered `config`. Everything after the first `#` in a name is +is an error: every command that reads the sources stops and names the file, so a +typo such as `config#templte` cannot deploy an unrendered `config`. The same goes +for a `#perm:` mode that is not octal. Everything after the first `#` in a name is attributes, so a file whose real name contains `#` cannot be managed. | Attribute | Effect | |-----------|--------| -| [`#template`](#template) | Render as a Go template | -| [`#tmpl`](#template) | Alias for `#template` | -| [`#encrypted`](#encrypted) | Decrypt with age before deploying | -| [`#symlink`](#symlink) | Recreate a symlink (source must be one) | +| [`#tmpl`](#tmpl) | Render as a Go template | +| [`#enc`](#enc) | Decrypt with age before deploying | +| [`#link`](#link) | Recreate a symlink (source must be one) | | [`#import`](#import) | Target is authoritative; changes flow back to the source | | [`#profile:name`](#profilename) | Only deploy under this profile | | [`#perm:600`](#perm600) | Set file mode | @@ -40,13 +40,13 @@ Attributes are written on directories too. There, only some take effect: The source directory's own name counts as well: a source named `etc#owner-r:root` makes every file in it root-owned. -## `#template` +## `#tmpl` Renders the file with Go's `text/template` before writing it. See [Templates](templates.md) for the available variables and functions. ``` -gitconfig#template +gitconfig#tmpl ``` ```gotemplate @@ -54,19 +54,17 @@ gitconfig#template email = {{ .Vars.email }} ``` -`#tmpl` is an equivalent short form. - Because the deployed content differs from the source, statemate compares the *rendered* output against the target. A template whose variables change is detected as modified even though the source file itself did not change. -## `#encrypted` +## `#enc` Marks the file as age-encrypted in the repository. It is decrypted on apply, so the target holds plaintext while the repository holds ciphertext. ``` -.ssh/id_ed25519#encrypted#perm:600 +.ssh/id_ed25519#enc#perm:600 ``` Deploying needs an age identity (`identity` or `identity_command` in @@ -80,20 +78,20 @@ remove the suffix for you. `mate edit` decrypts to a temporary file, opens your editor, and re-encrypts on save, preserving the original mode. `mate cat` decrypts to stdout. -Combines with `#template`: the file is decrypted first, then rendered. +Combines with `#tmpl`: the file is decrypted first, then rendered. -## `#symlink` +## `#link` Reproduces a symlink at the target. **The source file must itself be a symlink**; statemate reads where it points and recreates a link to that same destination. ```bash cd ~/dotfiles/mysource -ln -s /opt/homebrew/bin/nvim 'bin/vim#symlink' +ln -s /opt/homebrew/bin/nvim 'bin/vim#link' ``` ``` -~/dotfiles/mysource/bin/vim#symlink → /opt/homebrew/bin/nvim +~/dotfiles/mysource/bin/vim#link → /opt/homebrew/bin/nvim ⇓ apply ~/bin/vim → /opt/homebrew/bin/nvim ``` @@ -107,19 +105,19 @@ The link text is copied verbatim, so a relative link resolves relative to the retargeting the source link is a change, and a link that points nowhere on this machine is deployed as-is. -A `#symlink` attribute on a regular file is an error, reported by `mate status` +A `#link` attribute on a regular file is an error, reported by `mate status` and `mate apply` alike: ``` -Error: computing changes: readlink .../f.txt#symlink: invalid argument +Error: computing changes: readlink .../f.txt#link: invalid argument ``` -Because the link destination is copied verbatim, `#template`, `#encrypted`, -`#perm:`, `#owner:` and `#group:` have no effect alongside `#symlink`: there is +Because the link destination is copied verbatim, `#tmpl`, `#enc`, +`#perm:`, `#owner:` and `#group:` have no effect alongside `#link`: there is no content to render, decrypt, chmod, or chown. Links are created without sudo, -so a `#symlink` cannot target a directory you cannot write to. +so a `#link` cannot target a directory you cannot write to. -If a target is a symlink but the source is not marked `#symlink`, statemate treats +If a target is a symlink but the source is not marked `#link`, statemate treats it as a conflict the first time it sees it, rather than silently replacing it. This is what happens on the first apply after migrating from GNU Stow; answer `[o]verwrite` to replace the links with real files. @@ -132,7 +130,7 @@ changes whenever you toggle a setting, so without `#import` every `mate apply` stops to ask about a drifted target, and the answer is always "import". ``` -claude/.claude/settings.json#encrypted#import +claude/.claude/settings.json#enc#import ``` | Source | Target | Result | @@ -154,10 +152,10 @@ still prompts once, because no recorded state exists to say which side is newer. shows the diff in the import direction — target as the new side. `mate apply` prints `← path (imported)`. -Composes with `#encrypted`: imported content is encrypted before being written to +Composes with `#enc`: imported content is encrypted before being written to the source, so the repository never holds plaintext. -**Cannot be combined with `#template`.** Importing would write the rendered output +**Cannot be combined with `#tmpl`.** Importing would write the rendered output over the template, destroying the source. Statemate rejects the combination with an error rather than doing it. @@ -264,7 +262,7 @@ real example, deploying to `/etc` as root: restic/ .mate.yaml # targets: { etc: /etc } etc#owner-r:root/ - ssh/ssh_config.d/50-storagebox.conf#template + ssh/ssh_config.d/50-storagebox.conf#tmpl ``` Note that directories which already exist and carry no perm/owner/group attribute @@ -280,7 +278,7 @@ socket directory, a cache directory some program refuses to create itself: ssh/ .cache/ssh/controlmasters#perm:700/ .gitkeep ← so git stores the directory at all - .ssh/config#template + .ssh/config#tmpl ``` Git cannot store an empty directory, so the keep-file is unavoidable. Hide it diff --git a/docs/commands/mate_cat.md b/docs/commands/mate_cat.md index 5ff8b17..76896fa 100644 --- a/docs/commands/mate_cat.md +++ b/docs/commands/mate_cat.md @@ -17,7 +17,7 @@ mate cat [flags] ## Examples ``` - mate cat ssh/.ssh/config#encrypted + mate cat ssh/.ssh/config#enc mate cat ~/.config/app/config.yaml ``` diff --git a/docs/commands/mate_decrypt.md b/docs/commands/mate_decrypt.md index 1f5de01..d0801f2 100644 --- a/docs/commands/mate_decrypt.md +++ b/docs/commands/mate_decrypt.md @@ -7,13 +7,13 @@ Decrypt a managed file Decrypt a file in place. This reads the encrypted file, decrypts it using the configured age identity, -writes it back, and removes the #encrypted attribute from the filename -wherever it appears among the attributes (config#encrypted#template becomes -config#template). +writes it back, and removes the #enc attribute from the filename +wherever it appears among the attributes (config#enc#tmpl becomes +config#tmpl). The file can be a managed source file or any file path (e.g. a var_file in .matedata/). Paths are resolved relative to the current directory, -falling back to the source directory. The #encrypted suffix is optional. +falling back to the source directory. The #enc suffix is optional. The age identity must be configured in mate.yaml: @@ -29,7 +29,7 @@ mate decrypt [flags] ## Examples ``` - mate decrypt nvim/secrets.yaml#encrypted + mate decrypt nvim/secrets.yaml#enc mate decrypt .matedata/secrets.yaml ``` diff --git a/docs/commands/mate_doctor.md b/docs/commands/mate_doctor.md index 4c7ce1d..b14d002 100644 --- a/docs/commands/mate_doctor.md +++ b/docs/commands/mate_doctor.md @@ -5,7 +5,7 @@ Check configuration and dependencies ## Synopsis Check that the configuration loads and validates, that every source directory -exists, that every active hook matches some managed file, that every #encrypted +exists, that every active hook matches some managed file, that every #enc file in any source really is age-encrypted, that the age identity and recipients work, and which package managers are available. diff --git a/docs/commands/mate_edit.md b/docs/commands/mate_edit.md index 5faeaa9..3a9dea3 100644 --- a/docs/commands/mate_edit.md +++ b/docs/commands/mate_edit.md @@ -13,7 +13,7 @@ Files under the source directory are opened directly. If you pass a target path (a deployed file), the corresponding source file is opened instead -- mate never edits deployed files in place. -For encrypted files (with the '#encrypted' attribute), the file is decrypted to a +For encrypted files (with the '#enc' attribute), the file is decrypted to a temporary location, opened in the editor, and re-encrypted after saving. The original file permissions are preserved. diff --git a/docs/commands/mate_encrypt.md b/docs/commands/mate_encrypt.md index 97e4c5a..843e5e9 100644 --- a/docs/commands/mate_encrypt.md +++ b/docs/commands/mate_encrypt.md @@ -7,7 +7,7 @@ Encrypt a managed file Encrypt a file in place. This reads the file, encrypts it using the configured age recipients, -writes it back, and adds the #encrypted suffix to the filename. +writes it back, and adds the #enc suffix to the filename. The file can be a managed source file or any file path (e.g. a var_file in .matedata/). Paths are resolved relative to the current directory, diff --git a/docs/commands/mate_eval.md b/docs/commands/mate_eval.md index 1778115..a1175b5 100644 --- a/docs/commands/mate_eval.md +++ b/docs/commands/mate_eval.md @@ -7,7 +7,7 @@ Render a template file Render a file as a template and print the result. Useful for debugging a template, or previewing what apply would deploy. Any -file is rendered, whether or not it is marked #template. An encrypted file is +file is rendered, whether or not it is marked #tmpl. An encrypted file is decrypted first, which needs the age identity. The path is used as given: absolute, starting with ~, or relative to the @@ -20,8 +20,8 @@ mate eval [flags] ## Examples ``` - mate eval git/.config/git/config#template - mate eval --profile work git/.config/git/config#template + mate eval git/.config/git/config#tmpl + mate eval --profile work git/.config/git/config#tmpl ``` ## Options diff --git a/docs/commands/mate_hooks.md b/docs/commands/mate_hooks.md index 13c39ed..b554686 100644 --- a/docs/commands/mate_hooks.md +++ b/docs/commands/mate_hooks.md @@ -23,7 +23,7 @@ auto-confirms them and --no-scripts skips them. Hooks declared in a source's .mate.yaml are named <source>/<name> and match only that source's files. Patterns match target paths; source attributes such -as #template and #perm:600 are ignored when included in a pattern. +as #tmpl and #perm:600 are ignored when included in a pattern. ## Options diff --git a/docs/commands/mate_rename.md b/docs/commands/mate_rename.md index c81f78a..762b671 100644 --- a/docs/commands/mate_rename.md +++ b/docs/commands/mate_rename.md @@ -11,7 +11,7 @@ This renames the source file, the target file, and updates tracking. Attributes belong to the source file only; the target is always named without them. If <new-name> has no attributes, the source keeps the ones it already has. If it has any, they replace the source's attributes, so give the -full set. #encrypted cannot be added or dropped this way, since that would +full set. #enc cannot be added or dropped this way, since that would not change the content: use 'mate encrypt' or 'mate decrypt'. Hooks matching the old or the new target path run afterwards, with a @@ -26,8 +26,8 @@ mate rename [flags] ``` mate rename nvim/init.lua init.vim mate rename zsh/.zshrc .zshrc.bak - mate rename git/.gitconfig#template .gitconfig.local - (source becomes .gitconfig.local#template, target .gitconfig.local) + mate rename git/.gitconfig#tmpl .gitconfig.local + (source becomes .gitconfig.local#tmpl, target .gitconfig.local) mate rename ssh/.ssh/config config#perm:600 (changes only the attributes; the target keeps its name) ``` diff --git a/docs/concepts.md b/docs/concepts.md index 614e690..2ba5a46 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -43,7 +43,7 @@ every file the first one manages as an orphan. Never run `mate clean --all` against a repository other than your usual one. Two hashes rather than one is what lets statemate tell *which side* of a file -changed. For a plain file they are identical. For a `#template` or `#encrypted` +changed. For a plain file they are identical. For a `#tmpl` or `#enc` file they differ, because the target holds rendered or decrypted content — and comparing the target against the source directly would report every such file as permanently modified. @@ -85,7 +85,7 @@ Conflict: /home/you/.zshrc ``` `[i]mport` copies the target's content back into the source. It is not offered for -`#template` files, whose source is not the deployed content. `[d]iff` shows the +`#tmpl` files, whose source is not the deployed content. `[d]iff` shows the difference and asks again. For files where importing is always the right answer, mark them [`#import`](attributes.md#import) and statemate stops asking. diff --git a/docs/configuration.md b/docs/configuration.md index 2446f0b..4f15d0a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -136,13 +136,13 @@ Loads variables from YAML or TOML files, merged in order after `variables`: ```yaml var_files: - .matedata/vars.yaml - - .matedata/secrets.yaml # resolves .matedata/secrets.yaml#encrypted too + - .matedata/secrets.yaml # resolves .matedata/secrets.yaml#enc too ``` Paths are relative to the source directory, or absolute, or start with `~/`. A missing file is skipped without error, and so is a file that is not `.yaml`, `.yml` or `.toml`. If the plain path does not exist, statemate looks for the same -path with an `#encrypted` suffix and decrypts it, so the config need not change +path with an `#enc` suffix and decrypts it, so the config need not change when you encrypt a var file. ### `include` @@ -152,7 +152,7 @@ Splits **packages and variables** across files: ```yaml include: - packages.yaml - - .matedata/secrets.yaml#encrypted + - .matedata/secrets.yaml#enc ``` ```yaml @@ -168,7 +168,7 @@ ignored. Package lists are appended (duplicates dropped) and variables merged, s an include adds to what `mate.yaml` already declares. Where both define the same variable, the include wins. A missing include file is skipped. -An included file may be `#encrypted`, and as with `var_files` the suffix is found +An included file may be `#enc`, and as with `var_files` the suffix is found automatically if the plain path does not exist. This is the usual way to keep a list of secret-bearing variables in the repository. @@ -181,7 +181,7 @@ profiles: ``` Every profile's includes are read when the config loads, including inactive -profiles. An `#encrypted` include therefore needs a working age identity on every +profiles. An `#enc` include therefore needs a working age identity on every machine, not only on those that use that profile. ### `ignore` @@ -406,7 +406,7 @@ every template while the source is active, this `.mate.yaml` included, and win over profile variables of the same name. While statemate reads the `include` list, variables from the include are still unset and `bitwarden` returns an empty string, so a template that loops over an included variable must tolerate it being -empty. `#encrypted` is not supported here. An included file inside the source is +empty. `#enc` is not supported here. An included file inside the source is deployed like any other file unless it is listed under `ignore`. ### `targets` diff --git a/docs/encryption.md b/docs/encryption.md index 510d859..44afd65 100644 --- a/docs/encryption.md +++ b/docs/encryption.md @@ -4,7 +4,7 @@ A dotfiles repository is usually public, or at least pushed somewhere you do not fully control. statemate keeps secrets out of it in two ways, and both rest on one [age](https://age-encryption.org) key: -| | [`#encrypted` files](attributes.md#encrypted) | [Bitwarden references](secrets.md) | +| | [`#enc` files](attributes.md#enc) | [Bitwarden references](secrets.md) | |---|---|---| | The repository holds | age ciphertext | only a reference, such as `{{ bitwarden "github" "field" "token" }}` | | The value lives | in the repository | in your Bitwarden vault | @@ -43,7 +43,7 @@ age: ## 3. Back up the private key -**Without the private key, every `#encrypted` file is unrecoverable.** Store a copy +**Without the private key, every `#enc` file is unrecoverable.** Store a copy somewhere that does not depend on this repository. A password manager works well: statemate's author keeps it as a custom field of a Bitwarden item, which is also what makes [bootstrapping a new machine](new-machine.md) a single step. @@ -64,7 +64,7 @@ To use a different key for one run, or on a machine whose config names none, set `STATEMATE_KEY_FILE` to its path. It overrides `identity` and `identity_command`: ```bash -STATEMATE_KEY_FILE=~/backup/key.txt mate cat ssh/.ssh/config#encrypted +STATEMATE_KEY_FILE=~/backup/key.txt mate cat ssh/.ssh/config#enc ``` ## 4. Encrypt files @@ -78,7 +78,7 @@ mate add --encrypt ~/.ssh/config Or encrypt one that is already managed: ```bash -mate encrypt ssh/.ssh/config # becomes ssh/.ssh/config#encrypted +mate encrypt ssh/.ssh/config # becomes ssh/.ssh/config#enc ``` The repository now holds armored ciphertext, while `~/.ssh/config` stays plaintext. @@ -86,17 +86,17 @@ To work with encrypted sources: ```bash mate edit ~/.ssh/config # decrypt to a temp file, edit, re-encrypt on save -mate cat ssh/.ssh/config#encrypted +mate cat ssh/.ssh/config#enc mate diff # diffs the decrypted content ``` To make `git diff` readable too, see [Tips: readable diffs](tips.md#readable-diffs-for-encrypted-files). -Renaming a file to `#encrypted` by hand does not encrypt it. `mate doctor` reports -any `#encrypted` file that holds plaintext, in every source and for every +Renaming a file to `#enc` by hand does not encrypt it. `mate doctor` reports +any `#enc` file that holds plaintext, in every source and for every profile, so it is caught before the secret is committed. To fix it, remove -`#encrypted` from the name and run `mate encrypt` on the file. +`#enc` from the name and run `mate encrypt` on the file. ### More than one key @@ -105,7 +105,7 @@ Files are encrypted to the recipients at the time they are encrypted, so after adding a key, re-encrypt existing files for the new machine to read them: ```bash -mate decrypt path/to/file#encrypted && mate encrypt path/to/file +mate decrypt path/to/file#enc && mate encrypt path/to/file ``` One shared key, kept in a password manager, avoids that bookkeeping. @@ -116,7 +116,7 @@ For values that already live in your vault, reference them from a [template](templates.md) instead of committing them at all: ```gotemplate -# git/.config/gh/hosts.yml#template +# git/.config/gh/hosts.yml#tmpl github.com: oauth_token: {{ bitwarden "github.com" "field" "gh-cli-token" }} ``` @@ -168,10 +168,10 @@ the YAML block scalar. See [`generate`](configuration.md#generate). | Operation | Needs | |-----------|-------| | `mate encrypt`, `mate add --encrypt` | `recipients` | -| Deploying, `mate cat`, `mate eval` on an `#encrypted` file | `identity` or `identity_command` | -| `mate edit` on an `#encrypted` file | both | +| Deploying, `mate cat`, `mate eval` on an `#enc` file | `identity` or `identity_command` | +| `mate edit` on an `#enc` file | both | | `mate secrets fetch`, and the secrets cache in general | `identity` or `identity_command`, plus `bw` | -| Importing into an `#encrypted` source ([`#import`](attributes.md#import)) | `recipients` | +| Importing into an `#enc` source ([`#import`](attributes.md#import)) | `recipients` | Without the identity, statemate stops with an error instead of deploying ciphertext or writing the secrets cache unencrypted. diff --git a/docs/hooks.md b/docs/hooks.md index e1c93a6..98875c5 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -91,14 +91,14 @@ Patterns match the **target** path, meaning where the file is deployed: limits it to files inside the directory. - There is no `!` negation, unlike [`ignore`](configuration.md#ignore). A hook that should skip some files needs narrower patterns. -- Attributes such as `#template` are not part of the target path, so a pattern +- Attributes such as `#tmpl` are not part of the target path, so a pattern never needs them. Recognized source attributes in a pattern are ignored, on file and directory components alike: - `.config#perm-r:700/tmux/*.conf#template` matches the same files as + `.config#perm-r:700/tmux/*.conf#tmpl` matches the same files as `.config/tmux/*.conf`. Attributes do not filter matches by profile or file type. -- Unknown `#` suffixes stay literal. To match a literal `#template` in a target - path, write `[#]template` or `\#template`. In YAML, a pattern containing a +- Unknown `#` suffixes stay literal. To match a literal `#tmpl` in a target + path, write `[#]template` or `\#tmpl`. In YAML, a pattern containing a backslash must be in single quotes. - `target_base` and `targets:` mappings make no difference: a unit deployed to `/etc/systemd/system` matches both `*.service` and diff --git a/docs/machines.md b/docs/machines.md index 05489ed..8e23457 100644 --- a/docs/machines.md +++ b/docs/machines.md @@ -117,7 +117,7 @@ For files that are mostly the same, a [template](templates.md) is less to maintain than two copies: ```gotemplate -# git/.config/git/config#template +# git/.config/git/config#tmpl [user] email = {{ .Vars.email }} @@ -175,7 +175,7 @@ would get without being on it: ```bash mate profile -p work # which sources it resolves to mate status -p work # what would change there -mate eval -p work git/.config/git/config#template +mate eval -p work git/.config/git/config#tmpl ``` The results reflect *this* machine's files and state, so `status` is only a rough diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 35a8b45..d164da4 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -16,7 +16,7 @@ and the scripts that set it up. mate.yaml git/ .mate.yaml ← packages: git, lazygit, gh - .config/git/config#template + .config/git/config#tmpl nvim/ .mate.yaml ← packages: neovim, tree-sitter-cli .config/nvim/init.lua @@ -146,7 +146,7 @@ only changes attributes is fine: `mate status` shows no change. Two changes need a command, because they touch more than the name: ```bash -mate encrypt ssh/.ssh/config # adds #encrypted and encrypts the content -mate decrypt ssh/.ssh/config#encrypted # removes it and decrypts the content +mate encrypt ssh/.ssh/config # adds #enc and encrypts the content +mate decrypt ssh/.ssh/config#enc # removes it and decrypts the content mate rename zsh/.zshrc .zshrc.local # renames the deployed file too ``` diff --git a/docs/scripts.md b/docs/scripts.md index 33a4231..3eba8bd 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -18,7 +18,7 @@ a source: ## Naming ``` --.##[#template][#profile:] +-.##[#tmpl][#profile:] ``` The order prefix is optional and controls execution order. Scripts are sorted by @@ -31,7 +31,7 @@ README, so keep documentation elsewhere. ``` 01-setup.sh#once#before -02-secrets.sh#onchange#before#template +02-secrets.sh#onchange#before#tmpl 50-fonts.sh#weekly#after 99-cleanup.sh#always#after bootstrap.sh ← manual only @@ -164,9 +164,9 @@ A failing script aborts the apply. The parent environment is inherited as well. -### `#template` +### `#tmpl` -A script marked `#template` is rendered before execution, giving it access to +A script marked `#tmpl` is rendered before execution, giving it access to [variables and secrets](templates.md): ```bash @@ -176,7 +176,7 @@ echo "{{ bitwarden "api" "field" "token" }}" > ~/.config/app/token ``` The rendered copy is what runs; the script in the repository is left alone. It -is written to a temporary executable file and run directly, so a `#template` +is written to a temporary executable file and run directly, so a `#tmpl` script **must** start with a shebang. ## Commands diff --git a/docs/secrets.md b/docs/secrets.md index c64a5c6..0651dad 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -125,8 +125,8 @@ existing cache. ## Discovery `mate secrets fetch` finds references by **rendering every template** with the -`bitwarden` function replaced by a recorder: `#template` files in active sources, -`#template` scripts in any `.matescripts/`, and every source's `.mate.yaml`. This means a reference inside a +`bitwarden` function replaced by a recorder: `#tmpl` files in active sources, +`#tmpl` scripts in any `.matescripts/`, and every source's `.mate.yaml`. This means a reference inside a conditional is only discovered when that branch is taken under the current profile — which is usually what you want, since the other branch's secret is not needed on this machine. @@ -147,9 +147,9 @@ Two mechanisms, for different needs: - **`bitwarden` references** — the value lives in your vault, and only a reference is committed. Best for credentials that already belong in a password manager. -- **[`#encrypted` files](attributes.md#encrypted)** — the value is committed as +- **[`#enc` files](attributes.md#enc)** — the value is committed as age ciphertext. Best for files that are wholly secret, like a private key, and for machines that must apply without vault access. -They compose: an `#encrypted#template` file is decrypted, then rendered, so it can +They compose: an `#enc#tmpl` file is decrypted, then rendered, so it can hold `bitwarden` references too. diff --git a/docs/system-files.md b/docs/system-files.md index 648e67a..a7c5495 100644 --- a/docs/system-files.md +++ b/docs/system-files.md @@ -36,7 +36,7 @@ Add `#perm:600` to anything that holds a secret: ``` restic/ etc#owner-r:root/ - restic/password#template#perm:600 + restic/password#tmpl#perm:600 ``` Several mappings can share a source: diff --git a/docs/templates.md b/docs/templates.md index d24cbbb..1d38598 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -1,10 +1,10 @@ # Templates -A file marked [`#template`](attributes.md#template) (or `#tmpl`) is rendered with +A file marked [`#tmpl`](attributes.md#tmpl) (or `#tmpl`) is rendered with Go's [`text/template`](https://pkg.go.dev/text/template) before being deployed. ``` -gitconfig#template +gitconfig#tmpl ``` ```gotemplate @@ -21,15 +21,15 @@ gitconfig#template Preview the result without applying: ```bash -mate eval git/gitconfig#template -mate eval --profile work git/gitconfig#template +mate eval git/gitconfig#tmpl +mate eval --profile work git/gitconfig#tmpl ``` Templates are also rendered in: - a source's [`.mate.yaml`](configuration.md#source-directory-config), including its `generate` content -- scripts marked [`#template`](scripts.md#template) +- scripts marked [`#tmpl`](scripts.md#tmpl) - hook [`run:`](hooks.md#run) commands, which also get `.Files` The repository's own `mate.yaml`, var files, and include files are **not** @@ -167,7 +167,7 @@ template file itself is untouched. ## Debugging `mate eval ` renders to stdout, which is the fastest way to find a mistake. -It renders any file you give it, whether or not it is marked `#template`. A +It renders any file you give it, whether or not it is marked `#tmpl`. A template that fails to parse reports the line: ``` diff --git a/docs/tips.md b/docs/tips.md index 0a0f459..480e971 100644 --- a/docs/tips.md +++ b/docs/tips.md @@ -71,8 +71,8 @@ autocommand plugin solves each of them. The complete version is ### Filetype detection for source files -Neovim reads `settings.json#profile:work#encrypted` as a file with the extension -`json#profile:work#encrypted`, so highlighting and LSP stop working. Strip the +Neovim reads `settings.json#profile:work#enc` as a file with the extension +`json#profile:work#enc`, so highlighting and LSP stop working. Strip the attributes and detect again: ```lua @@ -99,7 +99,7 @@ autocommand can ask which one you meant: ``` statemate: managed file, edit which? -1: source: config#encrypted +1: source: config#enc 2: target: ~/.ssh/config ``` @@ -107,9 +107,9 @@ The plugin runs `mate managed` on the opened path and switches the buffer to the source if you pick it. It skips files whose `ACTIVE` column is empty, since their source belongs to a profile that is not in use. -### Edit `#encrypted` files in place +### Edit `#enc` files in place -`BufReadCmd` and `BufWriteCmd` autocommands on `*#encrypted*` can decrypt with +`BufReadCmd` and `BufWriteCmd` autocommands on `*#enc*` can decrypt with `age --decrypt --identity ` on read and re-encrypt with `age --armor --recipient …` on write, reading the recipients from `mate.yaml`. Disable the swap file, undo file, and backups for those buffers so @@ -128,7 +128,7 @@ encrypted on disk and in the repository: ```gitattributes # .gitattributes in the repository -*#encrypted* diff=age +*#enc* diff=age ``` ```ini @@ -137,8 +137,8 @@ encrypted on disk and in the repository: textconv = age --decrypt --identity ~/.config/statemate/key.txt ``` -The pattern ends in `*` because `#encrypted` need not be the last attribute -(`settings.json#encrypted#import`). Tools that use git's diff machinery, such as +The pattern ends in `*` because `#enc` need not be the last attribute +(`settings.json#enc#import`). Tools that use git's diff machinery, such as lazygit and tig, pick this up too. ## Diffs with an external tool @@ -273,7 +273,7 @@ include: ``` ```gotemplate -# kitty/.config/kitty/theme.conf#template +# kitty/.config/kitty/theme.conf#tmpl background #{{ .Vars.color.bg.default }} foreground #{{ .Vars.color.fg.default }} ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 90740e4..4f04c54 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,7 +9,7 @@ mate doctor It loads and validates the configuration, checks that every source directory exists, that your age identity and recipients work, which package managers are available, that every active hook matches at least one file, and that every -`#encrypted` file really is encrypted. It exits 1 if anything is an error. +`#enc` file really is encrypted. It exits 1 if anything is an error. Other read-only commands that narrow a problem down: @@ -82,12 +82,12 @@ filtered, so every variant is deployed and they collide. Profiles in the local config *replace* the repository's entirely, so a local `profiles:` block can cause this too. -### `#import cannot be combined with #template` +### `#import cannot be combined with #tmpl` Importing would write the rendered output over the template. Drop one of the two attributes; see [`#import`](attributes.md#import). -### `… is #encrypted but no age identity is configured` +### `… is #enc but no age identity is configured` The file can only be deployed after it is decrypted. Configure `age.identity` or `age.identity_command`, and check with `mate doctor`. See diff --git a/internal/cli/add.go b/internal/cli/add.go index 9da3833..473e9d5 100644 --- a/internal/cli/add.go +++ b/internal/cli/add.go @@ -280,10 +280,10 @@ func addFileToSource(targetPath string, info os.FileInfo, sourceDir string, cfg destName = destName + "#profile:" + addForProfile } if addEncrypt { - destName = destName + "#encrypted" + destName = destName + "#enc" } if addTemplate { - destName = destName + "#template" + destName = destName + "#tmpl" } destPath := filepath.Join(resolveAttrsPath(sourceDir, filepath.Dir(relPath)), destName) diff --git a/internal/cli/add_test.go b/internal/cli/add_test.go index f2883a1..113c548 100644 --- a/internal/cli/add_test.go +++ b/internal/cli/add_test.go @@ -239,7 +239,7 @@ func TestAddDirectoryAppliesFlagsToEachFile(t *testing.T) { t.Fatal(err) } for _, name := range []string{"one", "nested/two"} { - path := filepath.Join(filepath.Dir(cfgPath), "extra", "configs", name+"#profile:work#template") + path := filepath.Join(filepath.Dir(cfgPath), "extra", "configs", name+"#profile:work#tmpl") got, err := os.ReadFile(path) if err != nil || string(got) != "{{ .Profile }}\n" { t.Errorf("template %s: content = %q, err = %v", path, got, err) @@ -400,7 +400,7 @@ func TestAddDirectoryEncrypted(t *testing.T) { t.Fatal(err) } for _, name := range []string{"one", "nested/two"} { - path := filepath.Join(filepath.Dir(cfgPath), "base", "secrets", name+"#encrypted") + path := filepath.Join(filepath.Dir(cfgPath), "base", "secrets", name+"#enc") got, err := enc.DecryptFile(path) if err != nil || string(got) != "secret "+name { t.Fatalf("encrypted file %s: %q, %v", path, got, err) diff --git a/internal/cli/apply_test.go b/internal/cli/apply_test.go index 4dd4fb1..4eea3e2 100644 --- a/internal/cli/apply_test.go +++ b/internal/cli/apply_test.go @@ -158,7 +158,7 @@ func TestApply_HookPatternsIgnoreSourceAttributes(t *testing.T) { r := newHookRepo(t, `sources: [app] hooks: attributed: - match: ".config#perm-r:700/app/*.conf#template#perm:600" + match: ".config#perm-r:700/app/*.conf#tmpl#perm:600" do: - run: printf '%s' "$STATEMATE_HOOK_FILES" > "$MARKS/attributed" `) @@ -168,10 +168,10 @@ hooks: t.Fatal(err) } plainFile := filepath.Join(attrDir, "app", "app.conf") - if err := os.Rename(plainFile, plainFile+"#template#perm:600"); err != nil { + if err := os.Rename(plainFile, plainFile+"#tmpl#perm:600"); err != nil { t.Fatal(err) } - writeFile(t, plainFile+"#template#perm:600", `{{ "rendered" }}`, 0644) + writeFile(t, plainFile+"#tmpl#perm:600", `{{ "rendered" }}`, 0644) if err := r.apply(t, nil, ""); err != nil { t.Fatalf("apply failed: %v", err) diff --git a/internal/cli/cat.go b/internal/cli/cat.go index d4265b2..dfcfd8a 100644 --- a/internal/cli/cat.go +++ b/internal/cli/cat.go @@ -17,7 +17,7 @@ var catCmd = &cobra.Command{ Works like cat, but an age-encrypted file is decrypted with the configured identity. The path is used as given: absolute, starting with ~, or relative to the current directory.`, - Example: ` mate cat ssh/.ssh/config#encrypted + Example: ` mate cat ssh/.ssh/config#enc mate cat ~/.config/app/config.yaml`, Args: cobra.ExactArgs(1), RunE: runCat, diff --git a/internal/cli/decrypt.go b/internal/cli/decrypt.go index 76fffd4..6f90d70 100644 --- a/internal/cli/decrypt.go +++ b/internal/cli/decrypt.go @@ -20,19 +20,19 @@ var decryptCmd = &cobra.Command{ Long: `Decrypt a file in place. This reads the encrypted file, decrypts it using the configured age identity, -writes it back, and removes the #encrypted attribute from the filename -wherever it appears among the attributes (config#encrypted#template becomes -config#template). +writes it back, and removes the #enc attribute from the filename +wherever it appears among the attributes (config#enc#tmpl becomes +config#tmpl). The file can be a managed source file or any file path (e.g. a var_file in .matedata/). Paths are resolved relative to the current directory, -falling back to the source directory. The #encrypted suffix is optional. +falling back to the source directory. The #enc suffix is optional. The age identity must be configured in mate.yaml: age: identity: "~/.config/statemate/key.txt"`, - Example: ` mate decrypt nvim/secrets.yaml#encrypted + Example: ` mate decrypt nvim/secrets.yaml#enc mate decrypt .matedata/secrets.yaml`, Args: cobra.ExactArgs(1), RunE: runDecrypt, @@ -95,7 +95,7 @@ func runDecrypt(cmd *cobra.Command, args []string) error { return decryptFileAt(entry.SourcePath, entry.Mode.Perm(), enc) } - // Fall back to resolving as a path (try with #encrypted suffix too) + // Fall back to resolving as a path (try with #enc suffix too) filePath := resolveEncryptedFilePath(srcPattern, cfg.SourceDir()) if filePath == "" { return fmt.Errorf("file not found: %s", srcPattern) @@ -122,11 +122,11 @@ func decryptFileAt(path string, perm os.FileMode, enc *encrypt.AgeEncryptor) err return fmt.Errorf("decrypting: %w", err) } - newPath := filepath.Join(filepath.Dir(path), source.RemoveAttr(filepath.Base(path), "encrypted")) - // Without an #encrypted attribute to drop, the plaintext would overwrite the + newPath := filepath.Join(filepath.Dir(path), source.RemoveAttr(filepath.Base(path), "enc")) + // Without an #enc attribute to drop, the plaintext would overwrite the // ciphertext and the removal below would then delete it. if newPath == path { - return fmt.Errorf("%s has no #encrypted attribute to remove", util.ShortenPath(path)) + return fmt.Errorf("%s has no #enc attribute to remove", util.ShortenPath(path)) } if err := os.WriteFile(newPath, plaintext, perm); err != nil { @@ -141,10 +141,10 @@ func decryptFileAt(path string, perm os.FileMode, enc *encrypt.AgeEncryptor) err return nil } -// hasEncryptedAttr reports whether the file's own name carries the #encrypted +// hasEncryptedAttr reports whether the file's own name carries the #enc // attribute, in any position among its attributes. A suffix test misses -// x#encrypted#template; a substring test also matches x#encrypted-backup, or a -// file inside a directory whose name contains "#encrypted". +// x#enc#tmpl; a substring test also matches x#enc-backup, or a +// file inside a directory whose name contains "#enc". func hasEncryptedAttr(path string) bool { _, attrs := source.ParseAttrs(filepath.Base(path)) return attrs.Encrypted diff --git a/internal/cli/decrypt_test.go b/internal/cli/decrypt_test.go index 6f9877c..d7a3da4 100644 --- a/internal/cli/decrypt_test.go +++ b/internal/cli/decrypt_test.go @@ -22,13 +22,13 @@ func testEncryptor(t *testing.T) *encrypt.AgeEncryptor { return enc } -// #encrypted need not be the last attribute. Trimming it only as a suffix left -// the path unchanged for x#encrypted#template, so the plaintext was written over +// #enc need not be the last attribute. Trimming it only as a suffix left +// the path unchanged for x#enc#tmpl, so the plaintext was written over // the ciphertext and then the file was deleted. func TestDecryptFileAt_EncryptedNotLastAttribute(t *testing.T) { enc := testEncryptor(t) dir := t.TempDir() - path := filepath.Join(dir, "config#encrypted#template") + path := filepath.Join(dir, "config#enc#tmpl") ciphertext, err := enc.Encrypt([]byte("secret")) if err != nil { @@ -42,7 +42,7 @@ func TestDecryptFileAt_EncryptedNotLastAttribute(t *testing.T) { t.Fatalf("decryptFileAt: %v", err) } - got, err := os.ReadFile(filepath.Join(dir, "config#template")) + got, err := os.ReadFile(filepath.Join(dir, "config#tmpl")) if err != nil { t.Fatalf("decrypted file missing: %v", err) } @@ -54,12 +54,12 @@ func TestDecryptFileAt_EncryptedNotLastAttribute(t *testing.T) { } } -// A file without the #encrypted attribute has no other name to decrypt to, and +// A file without the #enc attribute has no other name to decrypt to, and // must never be deleted. func TestDecryptFileAt_KeepsFileWithoutEncryptedAttribute(t *testing.T) { enc := testEncryptor(t) dir := t.TempDir() - path := filepath.Join(dir, "config#template") + path := filepath.Join(dir, "config#tmpl") ciphertext, err := enc.Encrypt([]byte("secret")) if err != nil { @@ -70,7 +70,7 @@ func TestDecryptFileAt_KeepsFileWithoutEncryptedAttribute(t *testing.T) { } if err := decryptFileAt(path, 0600, enc); err == nil { - t.Fatal("expected an error for a file without the #encrypted attribute") + t.Fatal("expected an error for a file without the #enc attribute") } got, err := os.ReadFile(path) if err != nil { @@ -86,11 +86,11 @@ func TestHasEncryptedAttr(t *testing.T) { path string want bool }{ - {"/repo/.matedata/secrets.yaml#encrypted", true}, - {"/repo/nvim/config#encrypted#template", true}, - {"/repo/nvim/config#template", false}, - {"/repo/nvim/config#encrypted-backup", false}, - {"/repo/dir#encrypted/config", false}, + {"/repo/.matedata/secrets.yaml#enc", true}, + {"/repo/nvim/config#enc#tmpl", true}, + {"/repo/nvim/config#tmpl", false}, + {"/repo/nvim/config#enc-backup", false}, + {"/repo/dir#enc/config", false}, } for _, tc := range tests { if got := hasEncryptedAttr(tc.path); got != tc.want { diff --git a/internal/cli/diff.go b/internal/cli/diff.go index 4dbbe61..7903e46 100644 --- a/internal/cli/diff.go +++ b/internal/cli/diff.go @@ -216,7 +216,7 @@ func runDiff(cmd *cobra.Command, args []string) error { return nil } -// symlinkDiff describes a pending #symlink change as the link the target has +// symlinkDiff describes a pending #link change as the link the target has // now and the one apply will create. func symlinkDiff(entry *source.Entry) string { var lines []string diff --git a/internal/cli/diff_test.go b/internal/cli/diff_test.go index 7d78636..aa680b9 100644 --- a/internal/cli/diff_test.go +++ b/internal/cli/diff_test.go @@ -8,11 +8,11 @@ import ( "github.com/subbeh/statemate/internal/source" ) -// mate diff shows a #symlink change as link text. Diffing the destinations +// mate diff shows a #link change as link text. Diffing the destinations // failed for a link to a directory or to a path that does not exist. func TestSymlinkDiff(t *testing.T) { dir := t.TempDir() - src := filepath.Join(dir, "link#symlink") + src := filepath.Join(dir, "link#link") dst := filepath.Join(dir, "link") if err := os.Symlink(dir, src); err != nil { t.Fatal(err) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 731a64e..3a26bb4 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -18,7 +18,7 @@ var doctorCmd = &cobra.Command{ Use: "doctor", Short: "Check configuration and dependencies", Long: `Check that the configuration loads and validates, that every source directory -exists, that every active hook matches some managed file, that every #encrypted +exists, that every active hook matches some managed file, that every #enc file in any source really is age-encrypted, that the age identity and recipients work, and which package managers are available. @@ -154,7 +154,7 @@ func checkHooks(cmd *cobra.Command) int { // matters is whether the identity loads and both halves are present. func ageStatus(cfg *config.Config) (level, msg string) { if cfg.Age == nil || (cfg.Age.Identity == "" && cfg.Age.IdentityCommand == "" && len(cfg.Age.Recipients) == 0) { - return "OK", "age: not configured (no #encrypted files or secrets cache)" + return "OK", "age: not configured (no #enc files or secrets cache)" } enc, err := encrypt.NewAgeEncryptor(cfg.Age.Identity, cfg.Age.IdentityCommand, cfg.Age.Recipients) if err != nil { @@ -162,14 +162,14 @@ func ageStatus(cfg *config.Config) (level, msg string) { } switch { case !enc.CanDecrypt(): - return "WARN", "age: recipients but no identity (#encrypted files cannot be deployed, secrets cannot be fetched)" + return "WARN", "age: recipients but no identity (#enc files cannot be deployed, secrets cannot be fetched)" case !enc.CanEncrypt(): return "WARN", "age: identity but no recipients (files cannot be encrypted)" } return "OK", "age: identity and recipients" } -// checkEncrypted reports #encrypted files whose content is not age-encrypted, +// checkEncrypted reports #enc files whose content is not age-encrypted, // meaning the plaintext is committed. It covers every source of every profile, // since a plaintext secret is committed no matter which machine it is for, and it // needs no identity: the age header is enough. @@ -201,10 +201,10 @@ func checkEncrypted(cfg *config.Config) int { fmt.Println() fmt.Println("Encrypted files:") for _, p := range plain { - fmt.Printf("[ERROR] %s is marked #encrypted but is not encrypted\n", util.ShortenPath(p)) + fmt.Printf("[ERROR] %s is marked #enc but is not encrypted\n", util.ShortenPath(p)) } if len(plain) == 0 { - fmt.Printf("[OK] %d #encrypted file(s) encrypted\n", checked) + fmt.Printf("[OK] %d #enc file(s) encrypted\n", checked) } return len(plain) } diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 280abf4..f5def1e 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -91,18 +91,18 @@ func TestAgeStatus(t *testing.T) { } } -// mate doctor flags #encrypted files holding plaintext in any source, including +// mate doctor flags #enc files holding plaintext in any source, including // one only a profile uses, and accepts armored and binary age files alike. func TestCheckEncrypted_FlagsPlaintext(t *testing.T) { root := isolateHome(t) repo := filepath.Join(root, "repo") files := map[string]string{ "mate.yaml": "sources: [app]\nprofiles:\n work:\n sources: [work]\n", - "app/armored#encrypted": "-----BEGIN AGE ENCRYPTED FILE-----\n", - "app/binary#encrypted": "age-encryption.org/v1\n", - "app/leaked#encrypted": "password=hunter2\n", + "app/armored#enc": "-----BEGIN AGE ENCRYPTED FILE-----\n", + "app/binary#enc": "age-encryption.org/v1\n", + "app/leaked#enc": "password=hunter2\n", "app/plain.conf": "not marked, not checked\n", - "work/leaked-at-work#encrypted": "token=abc\n", + "work/leaked-at-work#enc": "token=abc\n", } for name, content := range files { path := filepath.Join(repo, name) @@ -133,7 +133,7 @@ func TestCheckEncrypted_FlagsPlaintext(t *testing.T) { if issues != 2 { t.Errorf("expected 2 issues, got %d:\n%s", issues, out) } - for _, want := range []string{"leaked#encrypted is marked", "leaked-at-work#encrypted is marked"} { + for _, want := range []string{"leaked#enc is marked", "leaked-at-work#enc is marked"} { if !strings.Contains(string(out), want) { t.Errorf("expected %q in output:\n%s", want, out) } diff --git a/internal/cli/edit.go b/internal/cli/edit.go index 0207a80..a27624b 100644 --- a/internal/cli/edit.go +++ b/internal/cli/edit.go @@ -27,7 +27,7 @@ Files under the source directory are opened directly. If you pass a target path (a deployed file), the corresponding source file is opened instead -- mate never edits deployed files in place. -For encrypted files (with the '#encrypted' attribute), the file is decrypted to a +For encrypted files (with the '#enc' attribute), the file is decrypted to a temporary location, opened in the editor, and re-encrypted after saving. The original file permissions are preserved. diff --git a/internal/cli/edit_test.go b/internal/cli/edit_test.go index ddea5ab..f5369a4 100644 --- a/internal/cli/edit_test.go +++ b/internal/cli/edit_test.go @@ -26,12 +26,12 @@ func editTestEnv(t *testing.T) (*config.Config, []*source.Entry, string) { t.Fatal(err) } - // an include file outside any source dir, with the #encrypted suffix + // an include file outside any source dir, with the #enc suffix dataDir := filepath.Join(repo, ".matedata") if err := os.MkdirAll(dataDir, 0755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dataDir, "secrets.yaml#encrypted"), []byte("cipher"), 0600); err != nil { + if err := os.WriteFile(filepath.Join(dataDir, "secrets.yaml#enc"), []byte("cipher"), 0600); err != nil { t.Fatal(err) } @@ -79,11 +79,11 @@ func TestResolveEditPath_IncludeFileRelative(t *testing.T) { t.Fatal(err) } - got, managed, err := resolveEditPath(".matedata/secrets.yaml#encrypted", cfg, entries) + got, managed, err := resolveEditPath(".matedata/secrets.yaml#enc", cfg, entries) if err != nil { t.Fatalf("expected include file to resolve, got error: %v", err) } - want := filepath.Join(repo, ".matedata", "secrets.yaml#encrypted") + want := filepath.Join(repo, ".matedata", "secrets.yaml#enc") if got != want { t.Errorf("got %q, want %q", got, want) } diff --git a/internal/cli/encrypt.go b/internal/cli/encrypt.go index 5f3d2dc..6115236 100644 --- a/internal/cli/encrypt.go +++ b/internal/cli/encrypt.go @@ -20,7 +20,7 @@ var encryptCmd = &cobra.Command{ Long: `Encrypt a file in place. This reads the file, encrypts it using the configured age recipients, -writes it back, and adds the #encrypted suffix to the filename. +writes it back, and adds the #enc suffix to the filename. The file can be a managed source file or any file path (e.g. a var_file in .matedata/). Paths are resolved relative to the current directory, @@ -122,39 +122,27 @@ func resolveFilePath(pattern string, sourceDir string) string { } func resolveEncryptedFilePath(pattern string, sourceDir string) string { - if filepath.IsAbs(pattern) { - if _, err := os.Stat(pattern); err == nil { - return pattern - } - if !strings.HasSuffix(pattern, "#encrypted") { - if _, err := os.Stat(pattern + "#encrypted"); err == nil { - return pattern + "#encrypted" - } - } - return "" - } - // Try relative to cwd (exact, then with #encrypted) - if abs, err := filepath.Abs(pattern); err == nil { - if _, err := os.Stat(abs); err == nil { - return abs + // The exact path, then its #enc variant. + find := func(p string) string { + if _, err := os.Stat(p); err == nil { + return p } - if !strings.HasSuffix(abs, "#encrypted") { - if _, err := os.Stat(abs + "#encrypted"); err == nil { - return abs + "#encrypted" - } + if hasEncryptedAttr(p) { + return "" } + return util.EncryptedVariant(p) } - // Try relative to source directory (exact, then with #encrypted) - p := filepath.Join(sourceDir, pattern) - if _, err := os.Stat(p); err == nil { - return p + + if filepath.IsAbs(pattern) { + return find(pattern) } - if !strings.HasSuffix(p, "#encrypted") { - if _, err := os.Stat(p + "#encrypted"); err == nil { - return p + "#encrypted" + // Relative to cwd first, then to the source directory. + if abs, err := filepath.Abs(pattern); err == nil { + if p := find(abs); p != "" { + return p } } - return "" + return find(filepath.Join(sourceDir, pattern)) } func encryptFileAt(path string, perm os.FileMode, enc *encrypt.AgeEncryptor) error { @@ -168,7 +156,7 @@ func encryptFileAt(path string, perm os.FileMode, enc *encrypt.AgeEncryptor) err return fmt.Errorf("encrypting: %w", err) } - newPath := path + "#encrypted" + newPath := path + "#enc" if err := os.WriteFile(newPath, ciphertext, perm); err != nil { return fmt.Errorf("writing encrypted file: %w", err) diff --git a/internal/cli/eval.go b/internal/cli/eval.go index 26834e6..de9d474 100644 --- a/internal/cli/eval.go +++ b/internal/cli/eval.go @@ -19,13 +19,13 @@ var evalCmd = &cobra.Command{ Long: `Render a file as a template and print the result. Useful for debugging a template, or previewing what apply would deploy. Any -file is rendered, whether or not it is marked #template. An encrypted file is +file is rendered, whether or not it is marked #tmpl. An encrypted file is decrypted first, which needs the age identity. The path is used as given: absolute, starting with ~, or relative to the current directory. Use --profile to render it as another profile would.`, - Example: ` mate eval git/.config/git/config#template - mate eval --profile work git/.config/git/config#template`, + Example: ` mate eval git/.config/git/config#tmpl + mate eval --profile work git/.config/git/config#tmpl`, Args: cobra.ExactArgs(1), RunE: runEval, ValidArgsFunction: completeFilePaths, diff --git a/internal/cli/hooks.go b/internal/cli/hooks.go index f1e8aca..64b0e78 100644 --- a/internal/cli/hooks.go +++ b/internal/cli/hooks.go @@ -41,7 +41,7 @@ auto-confirms them and --no-scripts skips them. Hooks declared in a source's .mate.yaml are named / and match only that source's files. Patterns match target paths; source attributes such -as #template and #perm:600 are ignored when included in a pattern.`, +as #tmpl and #perm:600 are ignored when included in a pattern.`, } var hooksListCmd = &cobra.Command{ diff --git a/internal/cli/managed.go b/internal/cli/managed.go index f9ded49..82a5090 100644 --- a/internal/cli/managed.go +++ b/internal/cli/managed.go @@ -211,13 +211,13 @@ func formatAttrs(a source.Attrs) string { parts = append(parts, "group:"+a.Group) } if a.Encrypted { - parts = append(parts, "encrypted") + parts = append(parts, "enc") } if a.Template { - parts = append(parts, "template") + parts = append(parts, "tmpl") } if a.Symlink { - parts = append(parts, "symlink") + parts = append(parts, "link") } if a.Import { parts = append(parts, "import") diff --git a/internal/cli/managed_test.go b/internal/cli/managed_test.go index fe8926e..d853254 100644 --- a/internal/cli/managed_test.go +++ b/internal/cli/managed_test.go @@ -41,19 +41,19 @@ func managedFilterEnv(t *testing.T) (targetDir string, ssh, git *source.Entry) { if err := os.MkdirAll(sshSrcDir, 0755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(sshSrcDir, "config#encrypted"), []byte("x"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(sshSrcDir, "config#enc"), []byte("x"), 0644); err != nil { t.Fatal(err) } ssh = &source.Entry{ - SourcePath: filepath.Join(sshSrcDir, "config#encrypted"), + SourcePath: filepath.Join(sshSrcDir, "config#enc"), TargetPath: filepath.Join(targetDir, ".ssh", "config"), - RelPath: ".ssh/config#encrypted", + RelPath: ".ssh/config#enc", } git = &source.Entry{ - SourcePath: filepath.Join(repo, "git", ".config", "git", "config#template"), + SourcePath: filepath.Join(repo, "git", ".config", "git", "config#tmpl"), TargetPath: filepath.Join(targetDir, ".config", "git", "config"), - RelPath: ".config/git/config#template", + RelPath: ".config/git/config#tmpl", } return targetDir, ssh, git } @@ -62,10 +62,10 @@ func TestManagedFilter_AbsolutePathMatchesExactlyOne(t *testing.T) { targetDir, ssh, git := managedFilterEnv(t) filter := filepath.Join(targetDir, ".ssh", "config") - if !matchesManagedFilter(ssh, "ssh/.ssh/config#encrypted", filter) { + if !matchesManagedFilter(ssh, "ssh/.ssh/config#enc", filter) { t.Error("absolute target path should match its own entry") } - if matchesManagedFilter(git, "git/.config/git/config#template", filter) { + if matchesManagedFilter(git, "git/.config/git/config#tmpl", filter) { t.Error("absolute target path must not match a different file with the same basename") } } @@ -80,10 +80,10 @@ func TestManagedFilter_BareNameResolvesAgainstCwd(t *testing.T) { } // "config" exists in cwd, so it is an exact path -- not a name fragment. - if !matchesManagedFilter(ssh, "ssh/.ssh/config#encrypted", "config") { + if !matchesManagedFilter(ssh, "ssh/.ssh/config#enc", "config") { t.Error("bare name should match the file in the current directory") } - if matchesManagedFilter(git, "git/.config/git/config#template", "config") { + if matchesManagedFilter(git, "git/.config/git/config#tmpl", "config") { t.Error("bare name resolved to a real path must not also match other basenames") } } @@ -99,10 +99,10 @@ func TestManagedFilter_BareNameFallsBackToLooseMatch(t *testing.T) { t.Fatal(err) } - if !matchesManagedFilter(ssh, "ssh/.ssh/config#encrypted", "config") { + if !matchesManagedFilter(ssh, "ssh/.ssh/config#enc", "config") { t.Error("expected loose match for ssh entry") } - if !matchesManagedFilter(git, "git/.config/git/config#template", "config") { + if !matchesManagedFilter(git, "git/.config/git/config#tmpl", "config") { t.Error("expected loose match for git entry") } } @@ -116,10 +116,10 @@ func TestManagedFilter_SourceNameStillFiltersLoosely(t *testing.T) { t.Fatal(err) } - if !matchesManagedFilter(ssh, "ssh/.ssh/config#encrypted", "ssh") { + if !matchesManagedFilter(ssh, "ssh/.ssh/config#enc", "ssh") { t.Error("source name should match entries in that source") } - if matchesManagedFilter(git, "git/.config/git/config#template", "ssh") { + if matchesManagedFilter(git, "git/.config/git/config#tmpl", "ssh") { t.Error("source name should not match an unrelated source") } } @@ -128,7 +128,7 @@ func TestManagedFilter_SourcePathMatches(t *testing.T) { _, ssh, _ := managedFilterEnv(t) // The absolute source path is also an unambiguous lookup. - if !matchesManagedFilter(ssh, "ssh/.ssh/config#encrypted", ssh.SourcePath) { + if !matchesManagedFilter(ssh, "ssh/.ssh/config#enc", ssh.SourcePath) { t.Error("absolute source path should match its entry") } } @@ -152,7 +152,7 @@ func TestFormatAttrsListsEveryAttribute(t *testing.T) { for _, want := range []string{ "profile:work", "perm:0600", "owner:root", "group:wheel", - "encrypted", "template", "symlink", "import", + "enc", "tmpl", "link", "import", } { if !strings.Contains(got, want) { t.Errorf("formatAttrs() = %q, missing %q", got, want) diff --git a/internal/cli/rename.go b/internal/cli/rename.go index cc49911..c1fd48e 100644 --- a/internal/cli/rename.go +++ b/internal/cli/rename.go @@ -25,15 +25,15 @@ This renames the source file, the target file, and updates tracking. Attributes belong to the source file only; the target is always named without them. If has no attributes, the source keeps the ones it already has. If it has any, they replace the source's attributes, so give the -full set. #encrypted cannot be added or dropped this way, since that would +full set. #enc cannot be added or dropped this way, since that would not change the content: use 'mate encrypt' or 'mate decrypt'. Hooks matching the old or the new target path run afterwards, with a confirmation prompt (see 'mate hooks').`, Example: ` mate rename nvim/init.lua init.vim mate rename zsh/.zshrc .zshrc.bak - mate rename git/.gitconfig#template .gitconfig.local - (source becomes .gitconfig.local#template, target .gitconfig.local) + mate rename git/.gitconfig#tmpl .gitconfig.local + (source becomes .gitconfig.local#tmpl, target .gitconfig.local) mate rename ssh/.ssh/config config#perm:600 (changes only the attributes; the target keeps its name)`, Args: cobra.ExactArgs(2), @@ -130,7 +130,7 @@ func runRename(cmd *cobra.Command, args []string) error { return fmt.Errorf("updating database: %w", err) } - // A #symlink is tracked by its link text, as status compares it. + // A #link is tracked by its link text, as status compares it. hashFn := state.HashFile if entry.Attrs.Symlink { hashFn = state.HashLink @@ -191,7 +191,7 @@ func runRename(cmd *cobra.Command, args []string) error { // a template or encrypted file into an ordinary one; a newName with attributes // replaces them. // -// #encrypted describes the content rather than how it is deployed, so it can +// #enc describes the content rather than how it is deployed, so it can // only be changed by mate encrypt and mate decrypt, which convert the content // to match. func renamedNames(sourceName, newName string) (newSourceName, newTargetName string, err error) { @@ -211,9 +211,9 @@ func renamedNames(sourceName, newName string) (newSourceName, newTargetName stri if newAttrs.Encrypted != oldAttrs.Encrypted { if oldAttrs.Encrypted { - return "", "", fmt.Errorf("%s drops #encrypted from %s: use 'mate decrypt' to decrypt it", newName, sourceName) + return "", "", fmt.Errorf("%s drops #enc from %s: use 'mate decrypt' to decrypt it", newName, sourceName) } - return "", "", fmt.Errorf("%s adds #encrypted to %s: use 'mate encrypt' to encrypt it", newName, sourceName) + return "", "", fmt.Errorf("%s adds #enc to %s: use 'mate encrypt' to encrypt it", newName, sourceName) } return newSourceName, newBase, nil diff --git a/internal/cli/rename_test.go b/internal/cli/rename_test.go index f6c582b..d797875 100644 --- a/internal/cli/rename_test.go +++ b/internal/cli/rename_test.go @@ -11,14 +11,14 @@ func TestRenamedNames(t *testing.T) { wantSource, wantTarget string }{ // No attributes given: keep the source's own. - {"init.lua#template", "init.vim", "init.vim#template", "init.vim"}, - {"secret#encrypted#perm:600", "token", "token#encrypted#perm:600", "token"}, + {"init.lua#tmpl", "init.vim", "init.vim#tmpl", "init.vim"}, + {"secret#enc#perm:600", "token", "token#enc#perm:600", "token"}, {"init.lua", "init.vim", "init.vim", "init.vim"}, // Attributes given: they replace the source's, and never reach the target. - {"init.lua#template", "init.vim#perm:600", "init.vim#perm:600", "init.vim"}, - {"secret#encrypted#template", "token#template#encrypted", "token#template#encrypted", "token"}, + {"init.lua#tmpl", "init.vim#perm:600", "init.vim#perm:600", "init.vim"}, + {"secret#enc#tmpl", "token#tmpl#enc", "token#tmpl#enc", "token"}, // Only the attributes change. - {"init.lua", "init.lua#template", "init.lua#template", "init.lua"}, + {"init.lua", "init.lua#tmpl", "init.lua#tmpl", "init.lua"}, } for _, tc := range tests { gotSource, gotTarget, err := renamedNames(tc.source, tc.newName) @@ -33,12 +33,12 @@ func TestRenamedNames(t *testing.T) { } } -// Adding or dropping #encrypted by renaming would not encrypt or decrypt the +// Adding or dropping #enc by renaming would not encrypt or decrypt the // content, leaving plaintext marked encrypted or ciphertext deployed as-is. func TestRenamedNamesRejectsEncryptedChange(t *testing.T) { for _, tc := range []struct{ source, newName string }{ - {"secret#encrypted", "token#template"}, - {"config", "config#encrypted"}, + {"secret#enc", "token#tmpl"}, + {"config", "config#enc"}, } { if _, _, err := renamedNames(tc.source, tc.newName); err == nil { t.Errorf("renamedNames(%q, %q): expected an error", tc.source, tc.newName) diff --git a/internal/cli/scripts.go b/internal/cli/scripts.go index f9a334b..3418a0f 100644 --- a/internal/cli/scripts.go +++ b/internal/cli/scripts.go @@ -113,7 +113,7 @@ func runScriptsList(cmd *cobra.Command, args []string) error { if profileName != "" { tree = tree.FilterByProfile(profileChain) } - // #encrypted files cannot be compared without the identity. + // #enc files cannot be compared without the identity. var enc *encrypt.AgeEncryptor if cfg.Age != nil { enc, _ = encrypt.NewAgeEncryptor(cfg.Age.Identity, cfg.Age.IdentityCommand, cfg.Age.Recipients) diff --git a/internal/cli/scripts_run_test.go b/internal/cli/scripts_run_test.go index 4b6f350..962d102 100644 --- a/internal/cli/scripts_run_test.go +++ b/internal/cli/scripts_run_test.go @@ -25,7 +25,7 @@ func isolateHome(t *testing.T) string { return root } -// A #template script run by hand renders with the same context mate apply gives +// A #tmpl script run by hand renders with the same context mate apply gives // it: cached secrets and encrypted var_files both resolve. func TestRunScript_TemplateSeesSecretsAndEncryptedVars(t *testing.T) { root := isolateHome(t) @@ -58,7 +58,7 @@ func TestRunScript_TemplateSeesSecretsAndEncryptedVars(t *testing.T) { if err := toKey.EncryptToFile(cache, cachePath); err != nil { t.Fatal(err) } - if err := toKey.EncryptToFile([]byte("greeting: hello\n"), filepath.Join(repo, "vars.yaml#encrypted")); err != nil { + if err := toKey.EncryptToFile([]byte("greeting: hello\n"), filepath.Join(repo, "vars.yaml#enc")); err != nil { t.Fatal(err) } @@ -73,7 +73,7 @@ func TestRunScript_TemplateSeesSecretsAndEncryptedVars(t *testing.T) { out := filepath.Join(root, "out.txt") script := "#!/bin/sh\necho '{{ .Vars.greeting }} {{ bitwarden \"github\" \"field\" \"token\" }}' > " + out + "\n" - if err := os.WriteFile(filepath.Join(repo, ".matescripts", "show.sh#template"), []byte(script), 0755); err != nil { + if err := os.WriteFile(filepath.Join(repo, ".matescripts", "show.sh#tmpl"), []byte(script), 0755); err != nil { t.Fatal(err) } @@ -83,7 +83,7 @@ func TestRunScript_TemplateSeesSecretsAndEncryptedVars(t *testing.T) { cmd.Flags().Bool("dry-run", false, "") cmd.Flags().Bool("verbose", false, "") - if err := runScript(cmd, []string{"show.sh#template"}); err != nil { + if err := runScript(cmd, []string{"show.sh#tmpl"}); err != nil { t.Fatalf("runScript: %v", err) } diff --git a/internal/cli/secrets_test.go b/internal/cli/secrets_test.go index 7fabbbb..3f43ae4 100644 --- a/internal/cli/secrets_test.go +++ b/internal/cli/secrets_test.go @@ -19,16 +19,16 @@ func TestDiscoverTemplateFiles(t *testing.T) { files := []string{ "mate.yaml", "common/plain.conf", - "common/app.conf#template", - "common/work.conf#template#profile:work", - "common/home.conf#template#profile:home", + "common/app.conf#tmpl", + "common/work.conf#tmpl#profile:work", + "common/home.conf#tmpl#profile:home", "common/.mate.toml", - "common/.matescripts/setup.sh#template", - "common/.matescripts/work.sh#template#profile:work", + "common/.matescripts/setup.sh#tmpl", + "common/.matescripts/work.sh#tmpl#profile:work", "common/.matescripts/raw.sh", - ".matescripts/root.sh#template", - ".matescripts/nested/deep.sh#template", - ".matescripts/home.sh#template#profile:home", + ".matescripts/root.sh#tmpl", + ".matescripts/nested/deep.sh#tmpl", + ".matescripts/home.sh#tmpl#profile:home", } for _, f := range files { path := filepath.Join(repo, f) @@ -72,13 +72,13 @@ func TestDiscoverTemplateFiles(t *testing.T) { // only its scripts; discovery must not fetch secrets for the rest. profile: "home", want: []string{ - ".matescripts/home.sh#template#profile:home", - ".matescripts/nested/deep.sh#template", - ".matescripts/root.sh#template", + ".matescripts/home.sh#tmpl#profile:home", + ".matescripts/nested/deep.sh#tmpl", + ".matescripts/root.sh#tmpl", "common/.mate.toml", - "common/.matescripts/setup.sh#template", - "common/app.conf#template", - "common/home.conf#template#profile:home", + "common/.matescripts/setup.sh#tmpl", + "common/app.conf#tmpl", + "common/home.conf#tmpl#profile:home", }, }, { @@ -86,13 +86,13 @@ func TestDiscoverTemplateFiles(t *testing.T) { // without a #profile: attribute. profile: "", want: []string{ - ".matescripts/nested/deep.sh#template", - ".matescripts/root.sh#template", + ".matescripts/nested/deep.sh#tmpl", + ".matescripts/root.sh#tmpl", "common/.mate.toml", - "common/.matescripts/setup.sh#template", - "common/app.conf#template", - "common/home.conf#template#profile:home", - "common/work.conf#template#profile:work", + "common/.matescripts/setup.sh#tmpl", + "common/app.conf#tmpl", + "common/home.conf#tmpl#profile:home", + "common/work.conf#tmpl#profile:work", }, }, } diff --git a/internal/config/parse.go b/internal/config/parse.go index 0c21529..c87c155 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -243,7 +243,7 @@ func LoadDirConfigRaw(dir string, render TemplateRenderer) (*DirConfig, error) { } setHookOrigin(cfg.Hooks, dir, false) - // ponytail: no age identity here, so an #encrypted source include fails; pass the encryptor in if that's needed. + // ponytail: no age identity here, so an #enc source include fails; pass the encryptor in if that's needed. src := &Config{sourceDir: dir} for _, f := range cfg.Include { inc, err := src.loadInclude(f, nil) @@ -345,21 +345,17 @@ func (c *Config) loadIncludes() error { func (c *Config) loadInclude(f string, dec *encrypt.AgeEncryptor) (*includeFile, error) { path := c.resolveRelPath(f) - isEncrypted := strings.HasSuffix(path, "#encrypted") + plainPath, isEncrypted := util.TrimEncryptedSuffix(path) if _, err := os.Stat(path); os.IsNotExist(err) { - // Try #encrypted variant - if !isEncrypted { - encPath := path + "#encrypted" - if _, err := os.Stat(encPath); err == nil { - path = encPath - isEncrypted = true - } else { - return nil, nil - } - } else { + // Try the #enc variant + if isEncrypted { + return nil, nil + } + if path = util.EncryptedVariant(path); path == "" { return nil, nil } + isEncrypted = true } data, err := os.ReadFile(path) @@ -375,7 +371,7 @@ func (c *Config) loadInclude(f string, dec *encrypt.AgeEncryptor) (*includeFile, if err != nil { return nil, fmt.Errorf("decrypting: %w", err) } - path = strings.TrimSuffix(path, "#encrypted") + path = plainPath } var inc includeFile diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index ce1f69e..21ffdbb 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -41,25 +41,25 @@ func TestMatch(t *testing.T) { {"tmux.conf/", "/home/u/.config/tmux/tmux.conf", false}, {"~", "/home/u/.zshrc", true}, // Source attributes in patterns do not appear in the deployed target. - {"*.service#template", "/etc/systemd/system/keyd.service", true}, - {"*.service#template", "/etc/systemd/system/keyd.timer", false}, - {".config#perm-r:700/tmux/*.conf#encrypted#template", "/home/u/.config/tmux/tmux.conf", true}, + {"*.service#tmpl", "/etc/systemd/system/keyd.service", true}, + {"*.service#tmpl", "/etc/systemd/system/keyd.timer", false}, + {".config#perm-r:700/tmux/*.conf#enc#tmpl", "/home/u/.config/tmux/tmux.conf", true}, {"~/.config/tmux#owner-r:root/**/*.conf#profile:work", "/home/u/.config/tmux/a/b/c.conf", true}, {"/etc#group-r:root/keyd/*.conf#tmpl#perm:600", "/etc/keyd/default.conf", true}, {".config/tmux#perm:700/", "/home/u/.config/tmux/tmux.conf", true}, - {"tmux.conf#template/", "/home/u/.config/tmux/tmux.conf", false}, - {".config/tmux#perm:700/*.conf#template", "/home/u/.config/tmux/sub/a.conf", false}, + {"tmux.conf#tmpl/", "/home/u/.config/tmux/tmux.conf", false}, + {".config/tmux#perm:700/*.conf#tmpl", "/home/u/.config/tmux/sub/a.conf", false}, // Unknown hashes, escaped hashes, and glob classes remain literal. - {"note#draft#template", "/home/u/note#draft", true}, + {"note#draft#tmpl", "/home/u/note#draft", true}, {"note#", "/home/u/note#", true}, {"note##draft", "/home/u/note##draft", true}, - {"note#template-copy", "/home/u/note#template-copy", true}, - {`note\#template`, "/home/u/note#template", true}, - {"note[#]template", "/home/u/note#template", true}, - {"note[#template]#template", "/home/u/note#", true}, - {`note[\]]#template`, "/home/u/note]", true}, + {"note#tmpl-copy", "/home/u/note#tmpl-copy", true}, + {`note\#tmpl`, "/home/u/note#tmpl", true}, + {"note[#]tmpl", "/home/u/note#tmpl", true}, + {"note[#tmpl]#tmpl", "/home/u/note#", true}, + {`note[\]]#tmpl`, "/home/u/note]", true}, // A literal attribute-like suffix in a real target is not stripped. - {"note", "/home/u/note#template", false}, + {"note", "/home/u/note#tmpl", false}, } for _, tc := range tests { if got := match(tc.pattern, tc.target, home); got != tc.want { diff --git a/internal/scripts/confirm_test.go b/internal/scripts/confirm_test.go index b9b9195..d4bede8 100644 --- a/internal/scripts/confirm_test.go +++ b/internal/scripts/confirm_test.go @@ -90,7 +90,7 @@ func TestDiscoverPopulatesDescription(t *testing.T) { if err := os.MkdirAll(scriptsDir, 0755); err != nil { t.Fatal(err) } - writeScript(t, scriptsDir, "01-setup#once#before.sh", + writeScript(t, scriptsDir, "01-setup.sh#once#before", "#!/usr/bin/env bash\n# Description: Set things up\n") found, err := NewDiscoverer(repo, nil).Discover() diff --git a/internal/scripts/discover.go b/internal/scripts/discover.go index 5255e5b..0d0ba80 100644 --- a/internal/scripts/discover.go +++ b/internal/scripts/discover.go @@ -1,6 +1,7 @@ package scripts import ( + "fmt" "io/fs" "os" "path/filepath" @@ -66,6 +67,9 @@ func (d *Discoverer) discoverDir(dir, sourceDir string) (Scripts, error) { return nil } + if err := CheckScriptName(entry.Name()); err != nil { + return fmt.Errorf("%s: %w", path, err) + } name, freq, timing, template, profile, order := ParseScriptName(entry.Name()) contentHash, err := state.HashFile(path) diff --git a/internal/scripts/scripts_test.go b/internal/scripts/scripts_test.go index 734d783..7af6d0f 100644 --- a/internal/scripts/scripts_test.go +++ b/internal/scripts/scripts_test.go @@ -3,6 +3,7 @@ package scripts import ( "os" "path/filepath" + "strings" "testing" ) @@ -18,7 +19,7 @@ func TestParseScriptName(t *testing.T) { }{ {"01-setup.sh#once#before", "setup.sh", FreqOnce, TimingBefore, false, "", 1}, {"02-cleanup.sh#always#after", "cleanup.sh", FreqAlways, TimingAfter, false, "", 2}, - {"03-render.sh#onchange#before#template", "render.sh", FreqOnchange, TimingBefore, true, "", 3}, + {"03-render.sh#onchange#before#tmpl", "render.sh", FreqOnchange, TimingBefore, true, "", 3}, {"10-install.sh#once#after", "install.sh", FreqOnce, TimingAfter, false, "", 10}, {"05-prep.sh#always#before", "prep.sh", FreqAlways, TimingBefore, false, "", 5}, {"manual-task.sh", "manual-task.sh", FreqManual, TimingBefore, false, "", 0}, @@ -244,3 +245,19 @@ func TestScriptIsExecutable(t *testing.T) { t.Error("expected non-executable script to not be executable") } } + +func TestCheckScriptName(t *testing.T) { + for name, wantErr := range map[string]string{ + "01-a.sh#once#before": "", + "01-a.sh#Monthly#after#tmpl": "", + "01-a.sh#once#profile:linux": "", + "manual.sh": "", + "01-a.sh#once#after#template": "#template was renamed to #tmpl", + "01-a.sh#onchnage#after": "unknown attribute #onchnage", + } { + err := CheckScriptName(name) + if (wantErr == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), wantErr)) { + t.Errorf("CheckScriptName(%q) = %v, want %q", name, err, wantErr) + } + } +} diff --git a/internal/scripts/types.go b/internal/scripts/types.go index 6c9e871..e35b8f5 100644 --- a/internal/scripts/types.go +++ b/internal/scripts/types.go @@ -1,9 +1,11 @@ package scripts import ( + "fmt" "os" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -81,15 +83,35 @@ func (s *Script) IsExecutable() bool { return info.Mode()&0111 != 0 } -// Script naming format: -.##[#template][#profile:] +// Script naming format: -.##[#tmpl][#profile:] // Examples: // 01-setup.sh#once#before // 02-cleanup.sh#always#after -// 03-render.sh#onchange#before#template +// 03-render.sh#onchange#before#tmpl // 04-init.sh#once#before#profile:arch // manual-task.sh (no attributes = manual) var orderPattern = regexp.MustCompile(`^(\d+)-(.+)$`) +// scriptAttrs are the attributes a script name may carry; profile: takes a value. +var scriptAttrs = []string{"once", "onchange", "always", "daily", "weekly", "monthly", "before", "after", "tmpl"} + +// CheckScriptName reports the first attribute in a script name that is not +// recognised. Like a file's, an unknown attribute was dropped silently, so a typo +// in #onchange left the script manual. +func CheckScriptName(filename string) error { + for _, attr := range strings.Split(filename, "#")[1:] { + lower := strings.ToLower(attr) + switch { + case lower == "", slices.Contains(scriptAttrs, lower), strings.HasPrefix(lower, "profile:"): + case lower == "template": + return fmt.Errorf("#template was renamed to #tmpl") + default: + return fmt.Errorf("unknown attribute #%s", attr) + } + } + return nil +} + func ParseScriptName(filename string) (name string, freq Frequency, timing Timing, template bool, profile string, order int) { parts := strings.Split(filename, "#") nameWithOrder := parts[0] @@ -126,7 +148,7 @@ func ParseScriptName(filename string) (name string, freq Frequency, timing Timin timing = TimingBefore case lower == "after": timing = TimingAfter - case lower == "template": + case lower == "tmpl": template = true case strings.HasPrefix(lower, "profile:"): profile = strings.TrimPrefix(attr, "profile:") diff --git a/internal/source/attrs.go b/internal/source/attrs.go index 58d1536..abfca81 100644 --- a/internal/source/attrs.go +++ b/internal/source/attrs.go @@ -1,6 +1,7 @@ package source import ( + "fmt" "strconv" "strings" ) @@ -31,11 +32,49 @@ type Attrs struct { // with ParseAttrs below. TestAttrNamesAreParsed checks that every name here // actually parses, and the docs test checks that every name is documented. var AttrNames = []string{ - "template", "tmpl", "encrypted", "symlink", "import", + "tmpl", "enc", "link", "import", "profile:", "perm:", "owner:", "group:", "perm-r:", "owner-r:", "group-r:", } +// renamedAttrs maps the long attribute names mate used to accept to their +// replacements, so the error for a file not yet renamed says what to do. +var renamedAttrs = map[string]string{ + "encrypted": "enc", + "template": "tmpl", + "symlink": "link", +} + +// CheckAttrs reports the first attribute in name that is not recognised, or +// whose mode does not parse. Unknown attributes used to be dropped silently, so a +// typo such as config#templte deployed an unrendered config. +func CheckAttrs(name string) error { + for _, part := range strings.Split(name, "#")[1:] { + if part == "" { + continue + } + if short, ok := renamedAttrs[part]; ok { + return fmt.Errorf("#%s was renamed to #%s", part, short) + } + known := false + for _, a := range AttrNames { + if part == a || (strings.HasSuffix(a, ":") && strings.HasPrefix(part, a)) { + known = true + break + } + } + if !known { + return fmt.Errorf("unknown attribute #%s", part) + } + if key, val, _ := strings.Cut(part, ":"); key == "perm" || key == "perm-r" { + if _, err := strconv.ParseUint(val, 8, 32); err != nil { + return fmt.Errorf("#%s: %q is not an octal mode", part, val) + } + } + } + return nil +} + func ParseAttrs(name string) (baseName string, attrs Attrs) { parts := strings.Split(name, "#") baseName = parts[0] @@ -71,11 +110,11 @@ func ParseAttrs(name string) (baseName string, attrs Attrs) { } } else { switch part { - case "encrypted": + case "enc": attrs.Encrypted = true - case "template", "tmpl": + case "tmpl": attrs.Template = true - case "symlink": + case "link": attrs.Symlink = true case "import": attrs.Import = true @@ -86,10 +125,10 @@ func ParseAttrs(name string) (baseName string, attrs Attrs) { return baseName, attrs } -// RemoveAttr returns name without the given flag attribute (e.g. "encrypted"), +// RemoveAttr returns name without the given flag attribute (e.g. "enc"), // wherever it appears among the attributes. Attributes are not ordered, so -// x#encrypted#template is as valid as x#template#encrypted, and trimming a -// suffix would miss the former. +// x#enc#tmpl is as valid as x#tmpl#enc, and trimming a suffix would miss the +// former. func RemoveAttr(name, attr string) string { parts := strings.Split(name, "#") kept := parts[:1] diff --git a/internal/source/scanner.go b/internal/source/scanner.go index 260ea2e..dd22098 100644 --- a/internal/source/scanner.go +++ b/internal/source/scanner.go @@ -80,6 +80,9 @@ func (s *Scanner) scanSource(sourceDir string, tree *Tree) error { } sourceName := filepath.Base(sourceDir) + if err := CheckAttrs(sourceName); err != nil { + return fmt.Errorf("%s: %w", sourceDir, err) + } return filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -116,6 +119,10 @@ func (s *Scanner) scanSource(sourceDir string, tree *Tree) error { return nil } + if err := CheckAttrs(d.Name()); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + info, err := d.Info() if err != nil { return err diff --git a/internal/source/source_test.go b/internal/source/source_test.go index 354f8e9..2ca43db 100644 --- a/internal/source/source_test.go +++ b/internal/source/source_test.go @@ -29,17 +29,27 @@ func TestParseAttrs(t *testing.T) { wantAttr: Attrs{Perm: 0600}, }, { - input: "config.yaml#encrypted", + input: "config.yaml#enc", wantName: "config.yaml", wantAttr: Attrs{Encrypted: true}, }, { - input: "config.yaml#template", + input: "config.yaml#tmpl", wantName: "config.yaml", wantAttr: Attrs{Template: true}, }, { - input: "config.yaml#profile:work#perm:600#encrypted#template", + input: "config.yaml#enc#tmpl", + wantName: "config.yaml", + wantAttr: Attrs{Encrypted: true, Template: true}, + }, + { + input: "config.yaml#link", + wantName: "config.yaml", + wantAttr: Attrs{Symlink: true}, + }, + { + input: "config.yaml#profile:work#perm:600#enc#tmpl", wantName: "config.yaml", wantAttr: Attrs{Profile: "work", Perm: 0600, Encrypted: true, Template: true}, }, @@ -49,7 +59,7 @@ func TestParseAttrs(t *testing.T) { wantAttr: Attrs{Owner: "root", Group: "wheel"}, }, { - input: "link#symlink", + input: "link#link", wantName: "link", wantAttr: Attrs{Symlink: true}, }, @@ -59,7 +69,7 @@ func TestParseAttrs(t *testing.T) { wantAttr: Attrs{Import: true}, }, { - input: "settings.json#profile:work#encrypted#import", + input: "settings.json#profile:work#enc#import", wantName: "settings.json", wantAttr: Attrs{Profile: "work", Encrypted: true, Import: true}, }, @@ -103,18 +113,20 @@ func TestAttrNamesAreParsed(t *testing.T) { } } -// mate decrypt drops the #encrypted attribute from a filename; it may sit +// mate decrypt drops the #enc attribute from a filename; it may sit // anywhere among the attributes, not only last. func TestRemoveAttr(t *testing.T) { tests := []struct { name, attr, want string }{ - {"x#encrypted", "encrypted", "x"}, - {"x#encrypted#template", "encrypted", "x#template"}, - {"x#perm:600#encrypted#template", "encrypted", "x#perm:600#template"}, - {"x#template", "encrypted", "x#template"}, - {"x#encrypted-backup", "encrypted", "x#encrypted-backup"}, - {"x", "encrypted", "x"}, + {"x#enc", "enc", "x"}, + {"x#enc#tmpl", "enc", "x#tmpl"}, + {"x#perm:600#enc#tmpl", "enc", "x#perm:600#tmpl"}, + {"x#tmpl", "enc", "x#tmpl"}, + {"x#enc-backup", "enc", "x#enc-backup"}, + {"x#enc#tmpl", "enc", "x#tmpl"}, + {"x#perm:600#enc", "enc", "x#perm:600"}, + {"x", "enc", "x"}, } for _, tc := range tests { if got := RemoveAttr(tc.name, tc.attr); got != tc.want { @@ -687,3 +699,44 @@ func TestEmptyDirTargets(t *testing.T) { t.Errorf("expected exactly 1 empty directory, got %v", empty) } } + +// An attribute mate does not know stops the scan instead of being dropped; the +// renamed long forms say what replaced them. +func TestCheckAttrs(t *testing.T) { + tests := []struct { + name, wantErr string + }{ + {"f", ""}, + {"f#enc#tmpl#link#import", ""}, + {"f#profile:work#perm:600#owner:root#group-r:wheel", ""}, + {"f##enc", ""}, + {"f#encrypted", "#encrypted was renamed to #enc"}, + {"f#template", "#template was renamed to #tmpl"}, + {"f#symlink", "#symlink was renamed to #link"}, + {"f#templte", "unknown attribute #templte"}, + {"f#perm:rw", `"rw" is not an octal mode`}, + } + for _, tc := range tests { + err := CheckAttrs(tc.name) + switch { + case tc.wantErr == "" && err != nil: + t.Errorf("CheckAttrs(%q) = %v, want nil", tc.name, err) + case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)): + t.Errorf("CheckAttrs(%q) = %v, want %q", tc.name, err, tc.wantErr) + } + } +} + +func TestScanRejectsUnknownAttribute(t *testing.T) { + src := filepath.Join(t.TempDir(), "app") + if err := os.MkdirAll(filepath.Join(src, ".config#typo"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, ".config#typo", "a.conf"), nil, 0644); err != nil { + t.Fatal(err) + } + _, err := NewScanner("/home/testuser", "").Scan([]string{src}) + if err == nil || !strings.Contains(err.Error(), ".config#typo: unknown attribute #typo") { + t.Errorf("Scan error = %v, want the directory and its unknown attribute", err) + } +} diff --git a/internal/state/db.go b/internal/state/db.go index 60e1bd8..d837956 100644 --- a/internal/state/db.go +++ b/internal/state/db.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" _ "modernc.org/sqlite" ) @@ -68,7 +69,86 @@ func Open(path string) (*DB, error) { return nil, fmt.Errorf("creating schema: %w", err) } - return &DB{db: db}, nil + d := &DB{db: db} + if err := d.renameLongAttrs(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("migrating state: %w", err) + } + return d, nil +} + +// renameLongAttrs rewrites recorded source and script paths from the long +// attribute names (#encrypted, #template, #symlink) to the short ones that +// replaced them. Scripts are looked up by path, so without this renaming the +// sources would make every #once script look unrun. It runs once per database, +// tracked by user_version. +func (d *DB) renameLongAttrs() error { + var version int + if err := d.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { + return err + } + if version >= 1 { + return nil + } + + tx, err := d.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + for _, col := range []struct{ table, column string }{ + {"script_runs", "script_path"}, + {"script_failures", "script_path"}, + {"managed_files", "source_path"}, + } { + rows, err := tx.Query(fmt.Sprintf(`SELECT DISTINCT %s FROM %s`, col.column, col.table)) + if err != nil { + return err + } + var paths []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + _ = rows.Close() + return err + } + paths = append(paths, p) + } + _ = rows.Close() + if err := rows.Err(); err != nil { + return err + } + + for _, old := range paths { + if renamed := shortAttrPath(old); renamed != old { + if _, err := tx.Exec(fmt.Sprintf(`UPDATE OR REPLACE %s SET %s = ? WHERE %s = ?`, col.table, col.column, col.column), renamed, old); err != nil { + return err + } + } + } + } + + if _, err := tx.Exec(`PRAGMA user_version = 1`); err != nil { + return err + } + return tx.Commit() +} + +// shortAttrPath replaces the long attribute names in every component of path. +func shortAttrPath(path string) string { + short := map[string]string{"encrypted": "enc", "template": "tmpl", "symlink": "link"} + components := strings.Split(path, "/") + for i, c := range components { + parts := strings.Split(c, "#") + for j := 1; j < len(parts); j++ { + if s, ok := short[parts[j]]; ok { + parts[j] = s + } + } + components[i] = strings.Join(parts, "#") + } + return strings.Join(components, "/") } func (d *DB) Close() error { diff --git a/internal/state/files.go b/internal/state/files.go index 993024c..cec4995 100644 --- a/internal/state/files.go +++ b/internal/state/files.go @@ -98,7 +98,7 @@ func HashFile(path string) (string, error) { } // HashLink hashes a symlink's own link text rather than whatever it points at. -// A #symlink entry is up to date as long as the link says the same thing, and +// A #link entry is up to date as long as the link says the same thing, and // its destination may be a directory or not exist at all -- neither of which // HashFile can read. The prefix keeps the hash distinct from that of a regular // file whose content happens to be the same path. diff --git a/internal/state/migrate_test.go b/internal/state/migrate_test.go new file mode 100644 index 0000000..b6564f1 --- /dev/null +++ b/internal/state/migrate_test.go @@ -0,0 +1,59 @@ +package state + +import ( + "path/filepath" + "testing" +) + +// Renaming sources from the long attributes to the short ones must not make +// scripts that already ran look unrun: their records follow the new names. +func TestOpenRenamesLongAttrsOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + old := "/repo/zsh/.matescripts/50-zsh-init.sh#once#after#template" + renamed := "/repo/zsh/.matescripts/50-zsh-init.sh#once#after#tmpl" + + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err := db.RecordScriptRun(old, "hash"); err != nil { + t.Fatal(err) + } + if err := db.RecordScriptFailure("/repo/x#template#encrypted"); err != nil { + t.Fatal(err) + } + // Stand in for a database written before the migration existed. + if _, err := db.db.Exec(`PRAGMA user_version = 0`); err != nil { + t.Fatal(err) + } + _ = db.Close() + + db, err = Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + + if run, err := db.GetScriptRun(renamed); err != nil || run == nil { + t.Errorf("run record not found under the new name: run=%v err=%v", run, err) + } + if failed, _ := db.HasScriptFailed("/repo/x#tmpl#enc"); !failed { + t.Error("adjacent long attributes should both be renamed") + } + if run, _ := db.GetScriptRun(old); run != nil { + t.Error("the old name should no longer have a record") + } +} + +func TestShortAttrPath(t *testing.T) { + for in, want := range map[string]string{ + "/r/ssh/.ssh/config#encrypted#perm:600": "/r/ssh/.ssh/config#enc#perm:600", + "/r/a#template/b#symlink": "/r/a#tmpl/b#link", + "/r/note#template-copy": "/r/note#template-copy", + "/r/plain": "/r/plain", + } { + if got := shortAttrPath(in); got != want { + t.Errorf("shortAttrPath(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/target/apply.go b/internal/target/apply.go index 7f13bc5..aa9bea1 100644 --- a/internal/target/apply.go +++ b/internal/target/apply.go @@ -397,7 +397,7 @@ func (a *Applier) promptConflict(change *Change) (string, error) { fmt.Printf("\nConflict: %s\n", change.Entry.TargetPath) fmt.Printf(" Target has been modified since last apply\n") - // Importing a #symlink would write the target's content through the source + // Importing a #link would write the target's content through the source // link, into whatever it points at. canImport := !change.Entry.Attrs.Template && !change.Entry.Attrs.Symlink var prompt string @@ -496,9 +496,9 @@ func (a *Applier) importFile(entry *source.Entry) error { if entry.Attrs.Encrypted { // Writing the target back unencrypted would put plaintext into the repo - // under an #encrypted name. + // under an #enc name. if a.enc == nil || !a.enc.CanEncrypt() { - return fmt.Errorf("%s is #encrypted but no age recipients are configured to re-encrypt it", entry.SourcePath) + return fmt.Errorf("%s is #enc but no age recipients are configured to re-encrypt it", entry.SourcePath) } content, err = a.enc.Encrypt(content) if err != nil { diff --git a/internal/target/apply_test.go b/internal/target/apply_test.go index 3c9b430..7933e3e 100644 --- a/internal/target/apply_test.go +++ b/internal/target/apply_test.go @@ -732,7 +732,7 @@ func TestApplier_VerboseListsWrittenFiles(t *testing.T) { } } -// Without an age identity an #encrypted file cannot be decrypted. Skipping the +// Without an age identity an #enc file cannot be decrypted. Skipping the // decryption deployed the ciphertext as the target, and status then reported it // as up to date. func TestEncryptedWithoutIdentityErrors(t *testing.T) { @@ -747,15 +747,15 @@ func TestEncryptedWithoutIdentityErrors(t *testing.T) { for name, enc := range map[string]*encrypt.AgeEncryptor{"no age config": nil, "recipients only": recipientsOnly} { t.Run(name, func(t *testing.T) { - f := newImportFixture(t, "token#encrypted", "-----BEGIN AGE ENCRYPTED FILE-----\n") + f := newImportFixture(t, "token#enc", "-----BEGIN AGE ENCRYPTED FILE-----\n") _, err := ComputeChanges(f.tree, f.db, ComputeOpts{Enc: enc}) - if err == nil || !strings.Contains(err.Error(), "is #encrypted but no age identity is configured") { + if err == nil || !strings.Contains(err.Error(), "is #enc but no age identity is configured") { t.Errorf("ComputeChanges: expected a missing-identity error, got %v", err) } _, err = NewApplier(f.db, nil, enc, false, false, 0).Apply(f.tree) - if err == nil || !strings.Contains(err.Error(), "is #encrypted but no age identity is configured") { + if err == nil || !strings.Contains(err.Error(), "is #enc but no age identity is configured") { t.Errorf("Apply: expected a missing-identity error, got %v", err) } if _, err := os.Stat(f.targetPath); !os.IsNotExist(err) { diff --git a/internal/target/diff.go b/internal/target/diff.go index 392fdd7..64f788e 100644 --- a/internal/target/diff.go +++ b/internal/target/diff.go @@ -121,7 +121,7 @@ func computeChange(entry *source.Entry, db *state.DB, opts *ComputeOpts) (*Chang // output, destroying the source. The conflict prompt already refuses this; // as an attribute it is a mistake worth naming. if entry.Attrs.Import && entry.Attrs.Template { - return nil, fmt.Errorf("%s: #import cannot be combined with #template -- importing would overwrite the template with its rendered output", entry.SourcePath) + return nil, fmt.Errorf("%s: #import cannot be combined with #tmpl -- importing would overwrite the template with its rendered output", entry.SourcePath) } if entry.Attrs.Symlink { @@ -293,7 +293,7 @@ func computeChange(entry *source.Entry, db *state.DB, opts *ComputeOpts) (*Chang return change, nil } -// computeSymlinkChange compares a #symlink entry by link text. What the link +// computeSymlinkChange compares a #link entry by link text. What the link // points at is irrelevant -- apply copies the link verbatim -- and following it // fails outright for a directory or a destination that does not exist. func computeSymlinkChange(entry *source.Entry, db *state.DB) (*Change, error) { @@ -475,7 +475,7 @@ func IsBinaryFile(path string) bool { } func errNoIdentity(entry *source.Entry) error { - return fmt.Errorf("%s is #encrypted but no age identity is configured", entry.SourcePath) + return fmt.Errorf("%s is #enc but no age identity is configured", entry.SourcePath) } func getRenderedHash(entry *source.Entry, opts *ComputeOpts) (string, error) { diff --git a/internal/target/import_test.go b/internal/target/import_test.go index 88ba6ba..3b641ab 100644 --- a/internal/target/import_test.go +++ b/internal/target/import_test.go @@ -214,18 +214,18 @@ func TestImport_UnchangedDoesNothing(t *testing.T) { // Importing a template would write the rendered output over the template, // destroying the source. Reject the combination rather than doing it. func TestImport_TemplateCombinationIsRejected(t *testing.T) { - f := newImportFixture(t, "settings.json#import#template", `{"a":1}`) + f := newImportFixture(t, "settings.json#import#tmpl", `{"a":1}`) _, err := ComputeChanges(f.tree, f.db) if err == nil { - t.Fatal("expected an error for #import#template") + t.Fatal("expected an error for #import#tmpl") } - if !strings.Contains(err.Error(), "#template") { + if !strings.Contains(err.Error(), "#tmpl") { t.Errorf("error should explain the conflict, got: %v", err) } } -// The motivating file (~/.claude/settings.json) is #encrypted, so an import must +// The motivating file (~/.claude/settings.json) is #enc, so an import must // encrypt what it writes back -- otherwise it would leave plaintext in the repo. func TestImport_EncryptedSourceStaysEncrypted(t *testing.T) { identity, err := age.GenerateX25519Identity() @@ -253,7 +253,7 @@ func TestImport_EncryptedSourceStaysEncrypted(t *testing.T) { if err != nil { t.Fatal(err) } - sourcePath := filepath.Join(sourceDir, "settings.json#encrypted#import") + sourcePath := filepath.Join(sourceDir, "settings.json#enc#import") if err := os.WriteFile(sourcePath, ciphertext, 0644); err != nil { t.Fatal(err) } @@ -316,9 +316,9 @@ func TestImport_EncryptedSourceStaysEncrypted(t *testing.T) { } // Without recipients an import cannot re-encrypt, and writing the target back -// as-is would put plaintext into the repo under an #encrypted name. +// as-is would put plaintext into the repo under an #enc name. func TestImport_EncryptedWithoutRecipientsErrors(t *testing.T) { - f := newImportFixture(t, "settings.json#encrypted#import", "ciphertext") + f := newImportFixture(t, "settings.json#enc#import", "ciphertext") f.write(f.targetPath, `{"secret":1}`) err := NewApplier(f.db, nil, nil, false, false, 0).importFile(f.tree.Files()[0]) diff --git a/internal/target/symlink_test.go b/internal/target/symlink_test.go index 753f015..62ce7ce 100644 --- a/internal/target/symlink_test.go +++ b/internal/target/symlink_test.go @@ -9,7 +9,7 @@ import ( "github.com/subbeh/statemate/internal/state" ) -// symlinkFixture is one #symlink source in a scanned tree. Change detection for +// symlinkFixture is one #link source in a scanned tree. Change detection for // these compares link text, so the destination may be anything -- a directory, // or nothing at all. type symlinkFixture struct { @@ -31,7 +31,7 @@ func newSymlinkFixture(t *testing.T, dest string) *symlinkFixture { if err := os.MkdirAll(targetDir, 0755); err != nil { t.Fatal(err) } - sourcePath := filepath.Join(sourceDir, "link#symlink") + sourcePath := filepath.Join(sourceDir, "link#link") if err := os.Symlink(dest, sourcePath); err != nil { t.Fatal(err) } diff --git a/internal/template/context.go b/internal/template/context.go index b8ab8f6..0b3d507 100644 --- a/internal/template/context.go +++ b/internal/template/context.go @@ -11,6 +11,7 @@ import ( "github.com/subbeh/statemate/internal/config" "github.com/subbeh/statemate/internal/profile" "github.com/subbeh/statemate/internal/source" + "github.com/subbeh/statemate/internal/util" ) type SecretLookup func(item, typ, field string) (string, error) @@ -128,15 +129,13 @@ func (c *Context) loadVarFile(path string) error { encrypted := false if _, err := os.Stat(path); os.IsNotExist(err) { - // Try the #encrypted variant - if _, err := os.Stat(path + "#encrypted"); err == nil { - path = path + "#encrypted" - encrypted = true - } else { + // Try the #enc variant + if path = util.EncryptedVariant(path); path == "" { return nil } + encrypted = true } else if _, attrs := source.ParseAttrs(filepath.Base(path)); attrs.Encrypted { - // #encrypted may be any of the attributes, not only the last. + // #enc may be any of the attributes, not only the last. encrypted = true } diff --git a/internal/template/template_test.go b/internal/template/template_test.go index c0b3414..b6e0887 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -341,11 +341,11 @@ nested: } } -// A var_file is encrypted when #encrypted is among its attributes, not only +// A var_file is encrypted when #enc is among its attributes, not only // when it is the last one. func TestLoadVarFileEncryptedNotLastAttribute(t *testing.T) { dir := t.TempDir() - varFile := dir + "/secrets.yaml#encrypted#perm:600" + varFile := dir + "/secrets.yaml#enc#perm:600" if err := os.WriteFile(varFile, []byte("ciphertext"), 0600); err != nil { t.Fatal(err) } diff --git a/internal/util/path.go b/internal/util/path.go index f207ffe..3eb3161 100644 --- a/internal/util/path.go +++ b/internal/util/path.go @@ -18,3 +18,18 @@ func ShortenPath(path string) string { } return path } + +// EncryptedVariant returns path#enc when that file exists, so an encrypted file +// can be named without its suffix, or "" when there is none. +func EncryptedVariant(path string) string { + if _, err := os.Stat(path + "#enc"); err == nil { + return path + "#enc" + } + return "" +} + +// TrimEncryptedSuffix strips a trailing #enc from path and reports whether there +// was one. +func TrimEncryptedSuffix(path string) (string, bool) { + return strings.CutSuffix(path, "#enc") +} diff --git a/internal/util/path_test.go b/internal/util/path_test.go new file mode 100644 index 0000000..da11e29 --- /dev/null +++ b/internal/util/path_test.go @@ -0,0 +1,33 @@ +package util + +import ( + "os" + "path/filepath" + "testing" +) + +// An encrypted file may be named without its #enc suffix; the old #encrypted +// form is not looked for. +func TestEncryptedVariant(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"long#encrypted", "short#enc"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + for base, want := range map[string]string{"long": "", "short": "short#enc", "none": ""} { + got := EncryptedVariant(filepath.Join(dir, base)) + if want != "" { + want = filepath.Join(dir, want) + } + if got != want { + t.Errorf("EncryptedVariant(%s) = %q, want %q", base, got, want) + } + } + + for path, want := range map[string]string{"a.yaml#encrypted": "a.yaml#encrypted", "a.yaml#enc": "a.yaml", "a.yaml": "a.yaml"} { + if got, _ := TrimEncryptedSuffix(path); got != want { + t.Errorf("TrimEncryptedSuffix(%q) = %q, want %q", path, got, want) + } + } +} From 7aeaa3222479ef3915599ecd9e61147cd4494439 Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 07:29:37 +1100 Subject: [PATCH 03/34] docs(claude): merge main back into develop after each release Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3561e49..3794e92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,17 @@ Active development happens on the `develop` branch. Commit directly there — no 6. **Push**: `git push` directly to develop When ready for a release, merge `develop` into `main` (via PR or direct merge). +Tag the merge commit on main (`git tag -a vX.Y.Z -m vX.Y.Z`) to run the release +workflow. Then merge `main` back into `develop` and push it: + +```bash +git checkout develop && git merge --no-ff origin/main && git push +``` + +The tag sits on main's merge commit, so without this step develop never contains +it and `git describe` -- the version `make build-all` stamps into `mate version` +-- keeps reporting the previous release. The repo deletes the head branch when a +PR merges, so `develop` may have to be pushed back first anyway. **No direct pushes to main** — main is the stable release branch. From 0b92a8f49b1373b352444a19317d6153eddd17c7 Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 07:37:20 +1100 Subject: [PATCH 04/34] feat(add): offer to create a missing source directory `mate add -s ` now asks to create the source when its directory does not exist, then adds the file to it. The config is left untouched; a warning reminds the user to list the source under `sources:`. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +++ docs/commands/mate_add.md | 4 +++- docs/getting-started.md | 5 +++-- docs/troubleshooting.md | 3 ++- internal/cli/add.go | 32 ++++++++++++++++++++++++++++++-- internal/cli/add_test.go | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c0590..6f84e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `mate add -s ` offers to create the source when its directory does not exist yet, then adds the file to it. The config is not changed: mate warns that the new source must still be listed under `sources:` before it is deployed + ### Changed - **Breaking:** the `#encrypted`, `#template` and `#symlink` attributes are renamed to `#enc`, `#tmpl` and `#link`, and the long forms are no longer recognised. Scripts use `#tmpl` too. Rename existing sources, for example `ssh/.ssh/config#encrypted` to `ssh/.ssh/config#enc`; mate stops with "#encrypted was renamed to #enc" until they are. `mate encrypt` and `mate add --encrypt`/`--template` write the short forms, and a `var_files` or `include` entry named without a suffix finds the `#enc` file. Recorded script runs follow the rename, so `#once` scripts do not run again - An unrecognised attribute in a file, directory or script name is now an error that names the file, and so is a `#perm:` mode that is not octal. Before, it was dropped silently, so a typo such as `config#templte` deployed an unrendered `config` diff --git a/docs/commands/mate_add.md b/docs/commands/mate_add.md index 22cf19e..c1c3635 100644 --- a/docs/commands/mate_add.md +++ b/docs/commands/mate_add.md @@ -16,7 +16,9 @@ Unreadable files, broken symlinks, and special files stop the command. The source is the one named with --source, else 'default_source' from the config, else one you pick from a list of the configured sources. The source must -already be listed under 'sources:' and exist as a directory. +already be listed under 'sources:' and exist as a directory. A named source +whose directory does not exist yet is offered for creation: mate creates the +directory and adds the file, but you still list it under 'sources:' yourself. A file outside your home directory, such as one under /etc, needs a source that maps that location (see 'targets:' in a source's .mate.yaml); for a source diff --git a/docs/getting-started.md b/docs/getting-started.md index c41c6ba..eb8e0ca 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -89,8 +89,9 @@ the arrow keys, or press `/` to search: Skip the question with `--source zsh`, or set [`default_source`](configuration.md#keys) in `mate.yaml`. The source must -already exist as a directory and be listed under `sources:`. `mate add` will -not create one. +be listed under `sources:`. If `--source` names one whose directory does not +exist yet, `mate add` offers to create the directory and adds the file to it; +it does not edit `mate.yaml`, so list the new source under `sources:` yourself. To add all files in a directory, including hidden files and nested directories: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4f04c54..c8f17d6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -55,7 +55,8 @@ be missing. `mate add --source` names a source that is not in the active list. Under a profile, the active list is the top-level `sources` plus the profile's own. Use -`-p ` to add to a source only another profile uses. +`-p ` to add to a source only another profile uses. A source that +`mate add` created for you shows this until you list it under `sources:`. ### `no files match "zsh"; did you mean --source zsh?` diff --git a/internal/cli/add.go b/internal/cli/add.go index 473e9d5..3ee592f 100644 --- a/internal/cli/add.go +++ b/internal/cli/add.go @@ -14,6 +14,7 @@ import ( "github.com/subbeh/statemate/internal/profile" "github.com/subbeh/statemate/internal/source" "github.com/subbeh/statemate/internal/template" + "github.com/subbeh/statemate/internal/util" ) var addCmd = &cobra.Command{ @@ -31,7 +32,9 @@ Unreadable files, broken symlinks, and special files stop the command. The source is the one named with --source, else 'default_source' from the config, else one you pick from a list of the configured sources. The source must -already be listed under 'sources:' and exist as a directory. +already be listed under 'sources:' and exist as a directory. A named source +whose directory does not exist yet is offered for creation: mate creates the +directory and adds the file, but you still list it under 'sources:' yourself. A file outside your home directory, such as one under /etc, needs a source that maps that location (see 'targets:' in a source's .mate.yaml); for a source @@ -132,7 +135,14 @@ func runAdd(cmd *cobra.Command, args []string) error { } } if !found { - return fmt.Errorf("source %q not in configured sources", sourceName) + notConfigured := fmt.Errorf("source %q not in configured sources", sourceName) + if _, err := os.Stat(sourceDir); !os.IsNotExist(err) { + return notConfigured + } + if err := promptCreateSource(sourceDir, notConfigured); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Warning: source %q is not in the config; add it to 'sources:' so mate deploys it\n", sourceName) } } else { // Must offer the same list that absSources is indexed from: cfg.Sources @@ -506,6 +516,24 @@ func promptCreateDirConfig(sourceDir, targetPath string) (string, error) { return targetBase, nil } +// Only the directory is created; the config is left for the user to edit. +func promptCreateSource(sourceDir string, declined error) error { + ok, err := util.Confirm(fmt.Sprintf("Create source directory %s? [y/N] ", util.ShortenPath(sourceDir)), false) + if err != nil { + return err + } + if !ok { + return declined + } + + if err := os.MkdirAll(sourceDir, 0755); err != nil { + return fmt.Errorf("creating source directory: %w", err) + } + + fmt.Printf("Created %s\n", sourceDir) + return nil +} + func promptAddTargetBase(sourceDir, targetPath string) (string, error) { targetBase := inferTargetBase(targetPath) diff --git a/internal/cli/add_test.go b/internal/cli/add_test.go index 113c548..732201e 100644 --- a/internal/cli/add_test.go +++ b/internal/cli/add_test.go @@ -454,3 +454,39 @@ func writeAddTestFile(t *testing.T, path, content string) { func osName() string { return runtime.GOOS } + +// A source named with --source that does not exist yet is offered for creation. +// mate only creates its directory: registering it in the config stays manual. +func TestAddCreatesMissingSource(t *testing.T) { + cfgPath, file := addRepo(t) + withStdin(t, "y\n") + + if err := executeAdd(t, "-c", cfgPath, "-s", "newsource", file); err != nil { + t.Fatalf("mate add -s newsource: %v", err) + } + + want := filepath.Join(filepath.Dir(cfgPath), "newsource", ".gitconfig") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected %s: %v", want, err) + } + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "newsource") { + t.Errorf("config was modified: %s", data) + } +} + +func TestAddMissingSourceDeclined(t *testing.T) { + cfgPath, file := addRepo(t) + withStdin(t, "n\n") + + err := executeAdd(t, "-c", cfgPath, "-s", "newsource", file) + if err == nil || !strings.Contains(err.Error(), "not in configured sources") { + t.Errorf("expected a not-configured error, got %v", err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(cfgPath), "newsource")); !os.IsNotExist(err) { + t.Errorf("source directory created after declining: %v", err) + } +} From 40d56e705490519182e234b7e12715bea02d10af Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 07:40:52 +1100 Subject: [PATCH 05/34] feat(config)!: rename the local config to config.yaml The machine-local config moves from ~/.config/statemate/mate.yaml to ~/.config/statemate/config.yaml; config.yml is accepted too. The old name is no longer read. Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/skills/run-statemate/SKILL.md | 2 +- CHANGELOG.md | 1 + docs/commands/mate_config_source-dir.md | 2 +- docs/commands/mate_init.md | 2 +- docs/configuration.md | 13 +++++----- docs/getting-started.md | 2 +- docs/hooks.md | 4 +-- docs/new-machine.md | 2 +- docs/troubleshooting.md | 2 +- internal/cli/cli_test.go | 4 +-- internal/cli/config.go | 2 +- internal/cli/init.go | 2 +- internal/config/config_test.go | 33 ++++++++++++++++++++++++- internal/config/parse.go | 10 +++++++- 14 files changed, 61 insertions(+), 20 deletions(-) diff --git a/.claude/skills/run-statemate/SKILL.md b/.claude/skills/run-statemate/SKILL.md index 31909a3..08be6d7 100644 --- a/.claude/skills/run-statemate/SKILL.md +++ b/.claude/skills/run-statemate/SKILL.md @@ -164,7 +164,7 @@ Use the driver. - **A source's packages ignore its `profile:` key.** `internal/packages/sync.go` collects `dirCfg.Packages` unconditionally; `profile:` in `.mate.yaml` gates *files* only. You cannot hide a package behind a non-matching profile. -- **`mate` resolves the repo from config, not cwd.** `~/.config/statemate/mate.yaml` +- **`mate` resolves the repo from config, not cwd.** `~/.config/statemate/config.yaml` sets `source_dir`, which beats the current directory. `cd`-ing into a scratch repo is **not** enough — you must set `STATEMATE_DIR` (the driver does). - **Resolve symlinks in scratch paths.** On macOS `mktemp -d` returns diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f84e56..b471d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mate add -s ` offers to create the source when its directory does not exist yet, then adds the file to it. The config is not changed: mate warns that the new source must still be listed under `sources:` before it is deployed ### Changed +- **Breaking:** the local config is renamed from `~/.config/statemate/mate.yaml` to `~/.config/statemate/config.yaml` (`config.yml` works too). The old file is not read anymore: until you rename it with `mv ~/.config/statemate/mate.yaml ~/.config/statemate/config.yaml`, its `source_dir`, profile and other overrides are ignored and `mate` looks for `mate.yaml` in the current directory - **Breaking:** the `#encrypted`, `#template` and `#symlink` attributes are renamed to `#enc`, `#tmpl` and `#link`, and the long forms are no longer recognised. Scripts use `#tmpl` too. Rename existing sources, for example `ssh/.ssh/config#encrypted` to `ssh/.ssh/config#enc`; mate stops with "#encrypted was renamed to #enc" until they are. `mate encrypt` and `mate add --encrypt`/`--template` write the short forms, and a `var_files` or `include` entry named without a suffix finds the `#enc` file. Recorded script runs follow the rename, so `#once` scripts do not run again - An unrecognised attribute in a file, directory or script name is now an error that names the file, and so is a `#perm:` mode that is not octal. Before, it was dropped silently, so a typo such as `config#templte` deployed an unrendered `config` diff --git a/docs/commands/mate_config_source-dir.md b/docs/commands/mate_config_source-dir.md index 6b50bfc..ed81015 100644 --- a/docs/commands/mate_config_source-dir.md +++ b/docs/commands/mate_config_source-dir.md @@ -14,7 +14,7 @@ cd "$(mate config source-dir)" Resolution order matches the rest of mate: the --config flag, then the STATEMATE_DIR environment variable, then source_dir in the local config -(~/.config/statemate/mate.yaml), then the current directory. +(~/.config/statemate/config.yaml), then the current directory. ``` mate config source-dir [flags] diff --git a/docs/commands/mate_init.md b/docs/commands/mate_init.md index d99eb47..e6cac7e 100644 --- a/docs/commands/mate_init.md +++ b/docs/commands/mate_init.md @@ -15,7 +15,7 @@ In a directory that already has a mate.yaml, mate.yml or mate.toml, such as a fresh clone on a new machine, it only registers the directory. Registering writes source_dir to the local config -(~/.config/statemate/mate.yaml), so mate works from any directory. Other +(~/.config/statemate/config.yaml), so mate works from any directory. Other settings in that file are kept. ``` diff --git a/docs/configuration.md b/docs/configuration.md index 4f15d0a..2123003 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,15 +5,16 @@ Statemate reads three levels of configuration: | File | Purpose | |------|---------| | `mate.yaml` in the repository | The configuration itself, committed and shared | -| `~/.config/statemate/mate.yaml` | Machine-local overrides, not committed | +| `~/.config/statemate/config.yaml` | Machine-local overrides, not committed | | `.mate.yaml` in a source directory | Settings scoped to one source | The repository config may be YAML or TOML: `mate.yaml`, `mate.yml` and `mate.toml` are looked for in that order, and the same goes for `.mate.yaml` in a -source. The local config is always `mate.yaml`. The **directory containing the -config file is the source directory**, and relative paths in `sources`, -`var_files` and `include` resolve against it. Other paths, such as -`age.identity` and `secrets_cache`, should be absolute or start with `~/`. +source. The local config is YAML only: `config.yaml`, or `config.yml`. The +**directory containing the config file is the source directory**, and relative +paths in `sources`, `var_files` and `include` resolve against it. Other paths, +such as `age.identity` and `secrets_cache`, should be absolute or start with +`~/`. Unknown keys are ignored without a warning, so a misspelt key simply has no effect. `mate doctor` and `mate check` catch most of the mistakes that matter. @@ -299,7 +300,7 @@ sources or variables, and only files tagged with that exact name pass the ## Local config -`~/.config/statemate/mate.yaml` (or `$XDG_CONFIG_HOME/statemate/mate.yaml`) holds +`~/.config/statemate/config.yaml` (or `$XDG_CONFIG_HOME/statemate/config.yaml`) holds machine-specific settings that should not be committed: ```yaml diff --git a/docs/getting-started.md b/docs/getting-started.md index eb8e0ca..20bebb7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -42,7 +42,7 @@ mate init ``` Register this directory as your dotfiles location? [Y/n]: y -Saved to ~/.config/statemate/mate.yaml +Saved to ~/.config/statemate/config.yaml ``` Registering writes `source_dir: "~/dotfiles"` to the diff --git a/docs/hooks.md b/docs/hooks.md index 98875c5..0522812 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -56,7 +56,7 @@ In TOML the same structure is `[hooks.keyd]` with `[[hooks.keyd.do]]` steps. | File | Matches | |------|---------| | `mate.yaml` | Files from every source | -| Local config (`~/.config/statemate/mate.yaml`) | Files from every source | +| Local config (`~/.config/statemate/config.yaml`) | Files from every source | | A source's `.mate.yaml` | Only that source's files, and only while the source is active | Hooks from `mate.yaml` and the local config share one namespace. A local hook @@ -64,7 +64,7 @@ with the same name **replaces** the repository one entirely; fields are not merged. To switch a repository hook off on one machine: ```yaml -# ~/.config/statemate/mate.yaml +# ~/.config/statemate/config.yaml hooks: systemd-reload: { enabled: false } ``` diff --git a/docs/new-machine.md b/docs/new-machine.md index 2dfe85c..c220393 100644 --- a/docs/new-machine.md +++ b/docs/new-machine.md @@ -44,7 +44,7 @@ matches, `#profile:` files are not filtered and every variant is deployed (see for this machine in the local config: ```yaml -# ~/.config/statemate/mate.yaml (written by mate init) +# ~/.config/statemate/config.yaml (written by mate init) source_dir: "~/dotfiles" profile: work ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c8f17d6..4b9b989 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -158,7 +158,7 @@ There is one state database per user, shared by every repository. Files the other repository deployed have no source in this one, so they look orphaned. **Do not run `mate clean --all` there.** -### A setting in `~/.config/statemate/mate.yaml` has no effect +### A setting in `~/.config/statemate/config.yaml` has no effect The local config honours only some keys, and a file that fails to parse is ignored silently. See [Local config](configuration.md#local-config) for the list, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index f4834a6..9a661fc 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -18,7 +18,7 @@ func TestInitCommand(t *testing.T) { cwd, _ := os.Getwd() _ = os.MkdirAll(filepath.Join(tmpDir, ".config", "statemate"), 0755) - _ = os.WriteFile(filepath.Join(tmpDir, ".config", "statemate", "mate.yaml"), []byte("source_dir: "+cwd+"\n"), 0644) + _ = os.WriteFile(filepath.Join(tmpDir, ".config", "statemate", "config.yaml"), []byte("source_dir: "+cwd+"\n"), 0644) initFormat = "yaml" defer func() { initFormat = "" }() @@ -51,7 +51,7 @@ func TestInitCommandTOML(t *testing.T) { cwd, _ := os.Getwd() _ = os.MkdirAll(filepath.Join(tmpDir, ".config", "statemate"), 0755) - _ = os.WriteFile(filepath.Join(tmpDir, ".config", "statemate", "mate.yaml"), []byte("source_dir: "+cwd+"\n"), 0644) + _ = os.WriteFile(filepath.Join(tmpDir, ".config", "statemate", "config.yaml"), []byte("source_dir: "+cwd+"\n"), 0644) initFormat = "toml" defer func() { initFormat = "" }() diff --git a/internal/cli/config.go b/internal/cli/config.go index 197d4f2..fe96042 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -24,7 +24,7 @@ The path is printed bare, with no label, so it can be used directly: Resolution order matches the rest of mate: the --config flag, then the STATEMATE_DIR environment variable, then source_dir in the local config -(~/.config/statemate/mate.yaml), then the current directory.`, +(~/.config/statemate/config.yaml), then the current directory.`, Args: cobra.NoArgs, RunE: runConfigSourceDir, } diff --git a/internal/cli/init.go b/internal/cli/init.go index 411701b..09028f1 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -26,7 +26,7 @@ In a directory that already has a mate.yaml, mate.yml or mate.toml, such as a fresh clone on a new machine, it only registers the directory. Registering writes source_dir to the local config -(~/.config/statemate/mate.yaml), so mate works from any directory. Other +(~/.config/statemate/config.yaml), so mate works from any directory. Other settings in that file are kept.`, RunE: runInit, } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3be18ca..e224d64 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -297,7 +297,7 @@ hooks: if err := os.MkdirAll(filepath.Join(xdg, "statemate"), 0755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(xdg, "statemate", "mate.yaml"), []byte(local), 0644); err != nil { + if err := os.WriteFile(filepath.Join(xdg, "statemate", "config.yaml"), []byte(local), 0644); err != nil { t.Fatal(err) } @@ -430,6 +430,37 @@ func TestSaveLocalSourceDirAddsKey(t *testing.T) { } } +// The local config is config.yaml, or config.yml when only that exists. The +// old mate.yaml name is not read. +func TestLocalConfigPathNames(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + dir := filepath.Join(xdg, "statemate") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + + write := func(name, sourceDir string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("source_dir: "+sourceDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + } + + write("mate.yaml", "/old") + if got := LocalSourceDir(); got != "" { + t.Errorf("mate.yaml was read: source_dir %q", got) + } + write("config.yml", "/yml") + if got := LocalSourceDir(); got != "/yml" { + t.Errorf("with config.yml: source_dir %q, want /yml", got) + } + write("config.yaml", "/yaml") + if got := LocalSourceDir(); got != "/yaml" { + t.Errorf("with config.yaml and config.yml: source_dir %q, want /yaml", got) + } +} + // A local config that does not parse is left alone rather than replaced. func TestSaveLocalSourceDirRefusesUnparseableConfig(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) diff --git a/internal/config/parse.go b/internal/config/parse.go index c87c155..4ad269e 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -100,13 +100,21 @@ func LocalSourceDir() string { return "" } +// LocalConfigPath returns statemate/config.yaml in the XDG config dir, or +// config.yml when only that one exists. func LocalConfigPath() string { configDir := os.Getenv("XDG_CONFIG_HOME") if configDir == "" { home, _ := os.UserHomeDir() configDir = filepath.Join(home, ".config") } - return filepath.Join(configDir, "statemate", "mate.yaml") + dir := filepath.Join(configDir, "statemate") + if _, err := os.Stat(filepath.Join(dir, "config.yaml")); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(dir, "config.yml")); err == nil { + return filepath.Join(dir, "config.yml") + } + } + return filepath.Join(dir, "config.yaml") } func loadLocalConfig() *Config { From d0b14f2583238a0829cf52f3dc5fec97c9ff74c4 Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 07:50:24 +1100 Subject: [PATCH 06/34] feat(config)!: replace variable_commands with templated variables Variable values containing {{ are rendered as templates, in every layer and inside lists and maps, so '{{ cmd "..." }}' replaces the variable_commands key, which is removed. cmd now fails rendering on a non-zero exit, showing stderr, instead of returning an empty string. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 + docs/configuration.md | 33 ++++++++------ docs/templates.md | 8 ++-- internal/config/config.go | 4 -- internal/config/parse.go | 3 -- internal/template/context.go | 69 ++++++++++++++++++++++-------- internal/template/render.go | 2 +- internal/template/template_test.go | 27 +++++++++--- 8 files changed, 101 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b471d29..082b5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mate add -s ` offers to create the source when its directory does not exist yet, then adds the file to it. The config is not changed: mate warns that the new source must still be listed under `sources:` before it is deployed ### Changed +- **Breaking:** `variable_commands` is removed. A variable's value may now be a template instead, so `variable_commands: {key: "gpg ..."}` becomes `variables: {key: '{{ cmd "gpg ..." }}'}`. Template values are rendered in `variables`, `include`, `var_files` and profile variables, including list items and nested keys, so a list such as `gpg_keys` can come from commands too. A leftover `variable_commands` key is ignored, leaving those variables undefined +- **Breaking:** `{{ cmd "..." }}` now fails rendering, showing the command's stderr, when the command exits non-zero. Before, it returned an empty string, so a broken command deployed a file with the value missing. Append `|| true` to the command to accept a failure - **Breaking:** the local config is renamed from `~/.config/statemate/mate.yaml` to `~/.config/statemate/config.yaml` (`config.yml` works too). The old file is not read anymore: until you rename it with `mv ~/.config/statemate/mate.yaml ~/.config/statemate/config.yaml`, its `source_dir`, profile and other overrides are ignored and `mate` looks for `mate.yaml` in the current directory - **Breaking:** the `#encrypted`, `#template` and `#symlink` attributes are renamed to `#enc`, `#tmpl` and `#link`, and the long forms are no longer recognised. Scripts use `#tmpl` too. Rename existing sources, for example `ssh/.ssh/config#encrypted` to `ssh/.ssh/config#enc`; mate stops with "#encrypted was renamed to #enc" until they are. `mate encrypt` and `mate add --encrypt`/`--template` write the short forms, and a `var_files` or `include` entry named without a suffix finds the `#enc` file. Recorded script runs follow the rename, so `#once` scripts do not run again - An unrecognised attribute in a file, directory or script name is now an error that names the file, and so is a `#perm:` mode that is not octal. Before, it was dropped silently, so a typo such as `config#templte` deployed an unrendered `config` diff --git a/docs/configuration.md b/docs/configuration.md index 2123003..5739376 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,9 +50,7 @@ age: variables: email: "you@example.com" - -variable_commands: - gpg_key: "gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}'" + gpg_key: '{{ cmd "gpg --list-secret-keys --with-colons | awk -F: ''/^fpr/{print $10; exit}''" }}' var_files: - .matedata/secrets.yaml @@ -86,8 +84,7 @@ secrets_cache: "~/.local/state/statemate/secrets.age" | `editor` | string | Editor for `mate edit`, with any arguments (`code --wait`). Overrides `$VISUAL` and `$EDITOR`; falls back to `vi` | | `diff_tool` | string | External diff tool for `mate diff`, with any arguments. Run as ` ` | | `age` | map | Encryption settings — see [age](#age) | -| `variables` | map | Values available to templates as `.Vars` | -| `variable_commands` | map | Variables whose values come from shell commands, run at load | +| `variables` | map | Values available to templates as `.Vars`. Values may be templates — see [variables](#variables) | | `var_files` | list | YAML/TOML files to load variables from | | `packages` | map | Packages to install — see [Packages](packages.md) | | `hooks` | map | Commands to run when matching files change — see [Hooks](hooks.md) | @@ -117,18 +114,28 @@ key that needs to read them. SSH keys are not supported as recipients. [Encryption](encryption.md) walks through creating a key and storing it. -### `variable_commands` +### `variables` -Runs a shell command and stores its trimmed output as a variable: +A variable's value may be a template, which is how a value comes from a shell +command: ```yaml -variable_commands: - hostname_short: "hostname -s" - gpg_key: "gpg --card-status | awk '/Signature key/{print $NF}'" +variables: + hostname_short: '{{ cmd "hostname -s" }}' + gpg_keys: + - '{{ cmd "gpg --card-status | awk ''/Signature key/{print $NF}''" }}' ``` -Each command runs through `sh -c` whenever templates are rendered, which is most -commands, so keep them fast. A failing command aborts the command you ran. +Quote the value: an unquoted `{` starts a YAML map. Any string containing `{{` is +rendered, including list items and nested keys, and so are the values from +`include`, `var_files` and profile `variables`. All the +[template functions](templates.md#functions) and variables such as `.OS` and +`.Hostname` are available, except `bitwarden`. `.Vars` holds the unrendered +values, so one templated variable cannot use another's result. + +Variables render whenever templates do, which is most commands, so keep commands +fast. A failing `cmd` aborts the command you ran. To keep a literal `{{` in a +value, write `{{ "{{" }}`. ### `var_files` @@ -314,7 +321,7 @@ looks for `mate.yaml` in the current directory. Only a subset of keys is honoured here: `sources`, `default_source`, `target_base`, `profile`, `editor`, `age`, `variables`, `var_files`, -`variable_commands`, `packages`, `profiles`, `diff_tool`, and `hooks`. Others, +`packages`, `profiles`, `diff_tool`, and `hooks`. Others, including `include`, `ignore`, `aur_helper` and `secrets_cache`, are ignored. Each key **replaces** the repository's value; nothing is merged. A local diff --git a/docs/templates.md b/docs/templates.md index 1d38598..0ef3484 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -59,8 +59,7 @@ values inside a block scalar, which need [`indent`](#statemates-own). 1. [`variables`](configuration.md#keys), and variables from [`include`](configuration.md#include) files 2. [`var_files`](configuration.md#var_files), in order -3. [`variable_commands`](configuration.md#variable_commands) -4. profile `variables`, parent first, so the active profile wins +3. profile `variables`, parent first, so the active profile wins Each layer replaces top-level keys rather than merging into them: a profile that sets `color:` replaces the whole `color` map, not just the keys it names. @@ -129,8 +128,9 @@ These take precedence over sprig where a name collides. {{ var "some-key-with-dashes" }} ``` -`cmd` returns an empty string if the command fails, so use `required` around it -when the value matters. +`cmd` fails rendering if the command exits non-zero, showing its stderr. Append +`|| true` to the command to accept a failure and get its output, possibly empty. +A variable can hold a `cmd` call too — see [variables](configuration.md#variables). `indent` is written for embedding a block inside structured output: diff --git a/internal/config/config.go b/internal/config/config.go index 9ee51e6..fcbce81 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,7 +13,6 @@ type Config struct { Age *AgeConfig `yaml:"age" toml:"age"` Variables map[string]any `yaml:"variables" toml:"variables"` VarFiles []string `yaml:"var_files" toml:"var_files"` - VarCmds map[string]string `yaml:"variable_commands" toml:"variable_commands"` Packages *PackageList `yaml:"packages" toml:"packages"` Include []string `yaml:"include" toml:"include"` Ignore []string `yaml:"ignore" toml:"ignore"` @@ -53,9 +52,6 @@ func (c *Config) ApplyOverrides(override *Config) { if len(override.VarFiles) > 0 { c.VarFiles = override.VarFiles } - if override.VarCmds != nil { - c.VarCmds = override.VarCmds - } if override.Packages != nil { c.Packages = override.Packages } diff --git a/internal/config/parse.go b/internal/config/parse.go index 4ad269e..8c993dd 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -304,9 +304,6 @@ func (c *Config) setDefaults() error { if c.Variables == nil { c.Variables = make(map[string]any) } - if c.VarCmds == nil { - c.VarCmds = make(map[string]string) - } return nil } diff --git a/internal/template/context.go b/internal/template/context.go index 0b3d507..ec4ffc6 100644 --- a/internal/template/context.go +++ b/internal/template/context.go @@ -1,6 +1,7 @@ package template import ( + "errors" "fmt" "os" "os/exec" @@ -85,14 +86,6 @@ func NewContext(cfg *config.Config, profileName string, opts ...ContextOption) ( } } - for name, cmd := range cfg.VarCmds { - val, err := runCommand(cmd) - if err != nil { - return nil, err - } - ctx.Vars[name] = strings.TrimSpace(val) - } - if profileName != "" { resolved := profile.Resolve(cfg, profileName) if resolved != nil { @@ -102,6 +95,18 @@ func NewContext(cfg *config.Config, profileName string, opts ...ContextOption) ( } } + // Variables may hold templates, such as '{{ cmd "..." }}'. They render against + // the unrendered variables, so one templated variable cannot use another. + vars := make(map[string]any, len(ctx.Vars)) + for k, v := range ctx.Vars { + r, err := renderValue(v, ctx) + if err != nil { + return nil, fmt.Errorf("variable %s: %w", k, err) + } + vars[k] = r + } + ctx.Vars = vars + // A source's .mate.yaml must be rendered to read its include, and its template // may use what that include provides, so this first render leaves those empty. // Secrets aren't wired up yet and stay empty too. A file that fails here is @@ -182,18 +187,48 @@ func parseVarFile(data []byte, path string) (map[string]any, error) { return vars, nil } -func runCommand(cmd string) (string, error) { - out, err := exec.Command("sh", "-c", cmd).Output() - if err != nil { - return "", err +// renderValue renders the strings in a variable value, descending into lists and +// maps, so a list item or nested key can be a template too. +func renderValue(v any, ctx *Context) (any, error) { + switch v := v.(type) { + case string: + if !strings.Contains(v, "{{") { + return v, nil + } + out, err := Render([]byte(v), ctx) + return string(out), err + case []any: + list := make([]any, len(v)) + for i, item := range v { + r, err := renderValue(item, ctx) + if err != nil { + return nil, err + } + list[i] = r + } + return list, nil + case map[string]any: + m := make(map[string]any, len(v)) + for k, item := range v { + r, err := renderValue(item, ctx) + if err != nil { + return nil, err + } + m[k] = r + } + return m, nil } - return string(out), nil + return v, nil } -func (c *Context) Cmd(cmd string) string { - out, err := runCommand(cmd) +func (c *Context) Cmd(cmd string) (string, error) { + out, err := exec.Command("sh", "-c", cmd).Output() if err != nil { - return "" + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { + err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(exitErr.Stderr))) + } + return "", fmt.Errorf("cmd %q: %w", cmd, err) } - return strings.TrimSpace(out) + return strings.TrimSpace(string(out)), nil } diff --git a/internal/template/render.go b/internal/template/render.go index e6ccb07..8af9ccc 100644 --- a/internal/template/render.go +++ b/internal/template/render.go @@ -75,7 +75,7 @@ func mateFuncs(ctx *Context) template.FuncMap { "var": func(name string) any { return ctx.Vars[name] }, - "cmd": func(cmd string) string { + "cmd": func(cmd string) (string, error) { return ctx.Cmd(cmd) }, "default": func(def, val any) any { diff --git a/internal/template/template_test.go b/internal/template/template_test.go index b6e0887..146d6d2 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -118,10 +118,13 @@ generate: } } -func TestContextVarCommands(t *testing.T) { +func TestContextTemplatedVars(t *testing.T) { cfg := &config.Config{ - VarCmds: map[string]string{ - "test_var": "echo hello", + Variables: map[string]any{ + "plain": "{not a template}", + "host": `{{ cmd "echo hello" }}`, + "keys": []any{`{{ cmd "echo k1" }}`, "k2"}, + "nested": map[string]any{"os": "{{ .OS }}"}, }, } @@ -130,8 +133,22 @@ func TestContextVarCommands(t *testing.T) { t.Fatalf("NewContext failed: %v", err) } - if ctx.Vars["test_var"] != "hello" { - t.Errorf("expected test_var=hello, got %v", ctx.Vars["test_var"]) + if ctx.Vars["plain"] != "{not a template}" || ctx.Vars["host"] != "hello" { + t.Errorf("plain=%v host=%v", ctx.Vars["plain"], ctx.Vars["host"]) + } + if keys, _ := ctx.Vars["keys"].([]any); len(keys) != 2 || keys[0] != "k1" || keys[1] != "k2" { + t.Errorf("keys=%v", ctx.Vars["keys"]) + } + if nested, _ := ctx.Vars["nested"].(map[string]any); nested["os"] != runtime.GOOS { + t.Errorf("nested=%v", ctx.Vars["nested"]) + } + if cfg.Variables["host"] != `{{ cmd "echo hello" }}` { + t.Errorf("rendering modified the config: %v", cfg.Variables["host"]) + } + + cfg.Variables = map[string]any{"bad": []any{`{{ cmd "echo oops >&2; false" }}`}} + if _, err := NewContext(cfg, ""); err == nil || !strings.Contains(err.Error(), "variable bad") || !strings.Contains(err.Error(), "oops") { + t.Errorf("expected a failing cmd to name the variable and its stderr, got %v", err) } } From 3bed86fd782ecafc728105981d631b802887b5f2 Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 07:54:56 +1100 Subject: [PATCH 07/34] feat(unmanaged): list files in a directory that no source deploys Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + docs/commands/mate.md | 1 + docs/commands/mate_unmanaged.md | 45 +++++++++++ docs/troubleshooting.md | 1 + internal/cli/unmanaged.go | 136 ++++++++++++++++++++++++++++++++ internal/cli/unmanaged_test.go | 48 +++++++++++ 6 files changed, 232 insertions(+) create mode 100644 docs/commands/mate_unmanaged.md create mode 100644 internal/cli/unmanaged.go create mode 100644 internal/cli/unmanaged_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 082b5c3..a6070d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `mate add -s ` offers to create the source when its directory does not exist yet, then adds the file to it. The config is not changed: mate warns that the new source must still be listed under `sources:` before it is deployed +- `mate unmanaged [dir]` lists the files under the current directory, or the given one, that no source deploys for any profile. A directory holding nothing managed is shown once as `dir/` instead of file by file, and paths are relative to the current directory so they can be passed to `mate add` ### Changed - **Breaking:** `variable_commands` is removed. A variable's value may now be a template instead, so `variable_commands: {key: "gpg ..."}` becomes `variables: {key: '{{ cmd "gpg ..." }}'}`. Template values are rendered in `variables`, `include`, `var_files` and profile variables, including list items and nested keys, so a list such as `gpg_keys` can come from commands too. A leftover `variable_commands` key is ignored, leaving those variables undefined diff --git a/docs/commands/mate.md b/docs/commands/mate.md index c7ac270..55dddf2 100644 --- a/docs/commands/mate.md +++ b/docs/commands/mate.md @@ -50,5 +50,6 @@ Use "mate [command] --help" for more information about a command. * [mate scripts](mate_scripts.md) - Manage scripts * [mate secrets](mate_secrets.md) - Manage secrets * [mate status](mate_status.md) - Show what apply would change +* [mate unmanaged](mate_unmanaged.md) - List files that are not managed * [mate version](mate_version.md) - Print version information diff --git a/docs/commands/mate_unmanaged.md b/docs/commands/mate_unmanaged.md new file mode 100644 index 0000000..8320579 --- /dev/null +++ b/docs/commands/mate_unmanaged.md @@ -0,0 +1,45 @@ +# mate unmanaged + +List files that are not managed + +## Synopsis + +List files under a directory that no source deploys. + +With no argument, lists the current directory; otherwise the given directory. +A file counts as managed when any source deploys it, for any profile, matching +what 'mate managed' lists. + +Only directories that contain managed files are descended into. A directory +holding nothing managed is listed once, with a trailing slash, rather than +file by file. Paths are relative to the current directory, so they can be passed +to 'mate add'. + +``` +mate unmanaged [path] [flags] +``` + +## Examples + +``` + mate unmanaged # unmanaged files here + mate unmanaged ~/.config # unmanaged files in ~/.config +``` + +## Options + +``` + -h, --help help for unmanaged +``` + +## Options inherited from parent commands + +``` + -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + -p, --profile string override auto-detected profile +``` + +## SEE ALSO + +* [mate](mate.md) - Statemate - system configuration management + diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4b9b989..4232a17 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -18,6 +18,7 @@ Other read-only commands that narrow a problem down: | `mate profile` | Which profile is active, why, and which sources it uses | | `mate config source-dir` | Which repository `mate` is using | | `mate managed ` | Which source a deployed file comes from, and whether it is active | +| `mate unmanaged [dir]` | Which files in a directory no source deploys | | `mate eval ` | What a template renders to | | `mate apply --dry-run -V` | Everything an apply would do, without doing it | | `mate scripts list`, `mate hooks list` | Scripts and hooks, and why they would or would not run | diff --git a/internal/cli/unmanaged.go b/internal/cli/unmanaged.go new file mode 100644 index 0000000..848b0c9 --- /dev/null +++ b/internal/cli/unmanaged.go @@ -0,0 +1,136 @@ +package cli + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/subbeh/statemate/internal/config" + "github.com/subbeh/statemate/internal/profile" +) + +var unmanagedCmd = &cobra.Command{ + Use: "unmanaged [path]", + Short: "List files that are not managed", + Long: `List files under a directory that no source deploys. + +With no argument, lists the current directory; otherwise the given directory. +A file counts as managed when any source deploys it, for any profile, matching +what 'mate managed' lists. + +Only directories that contain managed files are descended into. A directory +holding nothing managed is listed once, with a trailing slash, rather than +file by file. Paths are relative to the current directory, so they can be passed +to 'mate add'.`, + Example: ` mate unmanaged # unmanaged files here + mate unmanaged ~/.config # unmanaged files in ~/.config`, + Args: cobra.MaximumNArgs(1), + RunE: runUnmanaged, +} + +func init() { + rootCmd.AddCommand(unmanagedCmd) +} + +func runUnmanaged(cmd *cobra.Command, args []string) error { + cfgPath, _ := cmd.Flags().GetString("config") + + cfg, err := config.Load(cfgPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if err := cfg.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + profileName, _ := cmd.Flags().GetString("profile") + if profileName == "" { + profileName = profile.Detect(cfg) + } + + scanner, err := newScanner(cfg, profileName) + if err != nil { + return fmt.Errorf("creating scanner: %w", err) + } + tree, err := scanner.Scan(cfg.ResolveSourcePaths(profile.AllSources(cfg))) + if err != nil { + return fmt.Errorf("scanning sources: %w", err) + } + + root := "." + if len(args) > 0 { + root = args[0] + } + root, err = expandToAbs(root) + if err != nil { + return err + } + + targets := make([]string, 0, len(tree.Entries)) + for _, e := range tree.Entries { + targets = append(targets, e.TargetPath) + } + + paths, err := findUnmanaged(root, targets) + if err != nil { + return err + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + for _, p := range paths { + // Rel drops the trailing slash that marks a directory. + if rel, err := filepath.Rel(cwd, p); err == nil { + if strings.HasSuffix(p, string(filepath.Separator)) { + rel += string(filepath.Separator) + } + p = rel + } + fmt.Println(p) + } + return nil +} + +// findUnmanaged walks root and returns the absolute paths of everything no +// target accounts for. It only descends into directories that are a target or +// hold one, so an unmanaged directory is returned once, with a trailing slash. +// +// ponytail: symlinked directories are not followed, so managed files reached +// through one (a symlinked ~/.config) are neither listed nor descended into. +func findUnmanaged(root string, targets []string) ([]string, error) { + // A target and all its ancestors are kept: files are managed, directories + // are descended into. + keep := make(map[string]bool) + for _, t := range targets { + for p := t; !keep[p]; p = filepath.Dir(p) { + keep[p] = true + } + } + + var unmanaged []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if path == root { + return err + } + // An unreadable directory below root is skipped, not fatal. + return nil + } + if path == root || keep[path] { + return nil + } + if d.IsDir() { + unmanaged = append(unmanaged, path+string(filepath.Separator)) + return filepath.SkipDir + } + unmanaged = append(unmanaged, path) + return nil + }) + return unmanaged, err +} diff --git a/internal/cli/unmanaged_test.go b/internal/cli/unmanaged_test.go new file mode 100644 index 0000000..8414762 --- /dev/null +++ b/internal/cli/unmanaged_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +func TestFindUnmanaged(t *testing.T) { + root := t.TempDir() + for _, rel := range []string{ + ".zshrc", // managed + ".bashrc", // unmanaged file beside a managed one + ".config/nvim/init.lua", // managed, two levels down + ".config/nvim/lazy.json", + ".config/htop/htoprc", // whole directory unmanaged + "empty/stray", // inside a declared empty directory + } { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, nil, 0644); err != nil { + t.Fatal(err) + } + } + + targets := []string{ + filepath.Join(root, ".zshrc"), + filepath.Join(root, ".config/nvim/init.lua"), + filepath.Join(root, "empty"), + } + got, err := findUnmanaged(root, targets) + if err != nil { + t.Fatal(err) + } + + want := []string{ + filepath.Join(root, ".bashrc"), + filepath.Join(root, ".config/htop") + "/", + filepath.Join(root, ".config/nvim/lazy.json"), + filepath.Join(root, "empty/stray"), + } + if !slices.Equal(got, want) { + t.Errorf("findUnmanaged =\n%v\nwant\n%v", got, want) + } +} From e2f718562c3513a83884f36c27c73df9dcf54d45 Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 08:00:07 +1100 Subject: [PATCH 08/34] feat(unmanaged): list only files, not directories Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- docs/commands/mate_unmanaged.md | 7 +++--- internal/cli/unmanaged.go | 38 ++++++++++----------------------- internal/cli/unmanaged_test.go | 2 +- 4 files changed, 16 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6070d5..604ee12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `mate add -s ` offers to create the source when its directory does not exist yet, then adds the file to it. The config is not changed: mate warns that the new source must still be listed under `sources:` before it is deployed -- `mate unmanaged [dir]` lists the files under the current directory, or the given one, that no source deploys for any profile. A directory holding nothing managed is shown once as `dir/` instead of file by file, and paths are relative to the current directory so they can be passed to `mate add` +- `mate unmanaged [dir]` lists the files under the current directory, or the given one, that no source deploys for any profile. Only files are listed, not directories, and paths are relative to the current directory so they can be passed to `mate add` ### Changed - **Breaking:** `variable_commands` is removed. A variable's value may now be a template instead, so `variable_commands: {key: "gpg ..."}` becomes `variables: {key: '{{ cmd "gpg ..." }}'}`. Template values are rendered in `variables`, `include`, `var_files` and profile variables, including list items and nested keys, so a list such as `gpg_keys` can come from commands too. A leftover `variable_commands` key is ignored, leaving those variables undefined diff --git a/docs/commands/mate_unmanaged.md b/docs/commands/mate_unmanaged.md index 8320579..911fd02 100644 --- a/docs/commands/mate_unmanaged.md +++ b/docs/commands/mate_unmanaged.md @@ -10,10 +10,9 @@ With no argument, lists the current directory; otherwise the given directory. A file counts as managed when any source deploys it, for any profile, matching what 'mate managed' lists. -Only directories that contain managed files are descended into. A directory -holding nothing managed is listed once, with a trailing slash, rather than -file by file. Paths are relative to the current directory, so they can be passed -to 'mate add'. +Only files are listed, never directories, so a directory holding nothing +managed shows every file inside it. Paths are relative to the current +directory, so they can be passed to 'mate add'. ``` mate unmanaged [path] [flags] diff --git a/internal/cli/unmanaged.go b/internal/cli/unmanaged.go index 848b0c9..a4e5c32 100644 --- a/internal/cli/unmanaged.go +++ b/internal/cli/unmanaged.go @@ -5,7 +5,6 @@ import ( "io/fs" "os" "path/filepath" - "strings" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/config" @@ -21,10 +20,9 @@ With no argument, lists the current directory; otherwise the given directory. A file counts as managed when any source deploys it, for any profile, matching what 'mate managed' lists. -Only directories that contain managed files are descended into. A directory -holding nothing managed is listed once, with a trailing slash, rather than -file by file. Paths are relative to the current directory, so they can be passed -to 'mate add'.`, +Only files are listed, never directories, so a directory holding nothing +managed shows every file inside it. Paths are relative to the current +directory, so they can be passed to 'mate add'.`, Example: ` mate unmanaged # unmanaged files here mate unmanaged ~/.config # unmanaged files in ~/.config`, Args: cobra.MaximumNArgs(1), @@ -85,11 +83,7 @@ func runUnmanaged(cmd *cobra.Command, args []string) error { return err } for _, p := range paths { - // Rel drops the trailing slash that marks a directory. if rel, err := filepath.Rel(cwd, p); err == nil { - if strings.HasSuffix(p, string(filepath.Separator)) { - rel += string(filepath.Separator) - } p = rel } fmt.Println(p) @@ -97,20 +91,15 @@ func runUnmanaged(cmd *cobra.Command, args []string) error { return nil } -// findUnmanaged walks root and returns the absolute paths of everything no -// target accounts for. It only descends into directories that are a target or -// hold one, so an unmanaged directory is returned once, with a trailing slash. +// findUnmanaged walks root and returns the absolute paths of the files no +// target accounts for. Directories are walked but never returned. // -// ponytail: symlinked directories are not followed, so managed files reached -// through one (a symlinked ~/.config) are neither listed nor descended into. +// ponytail: symlinked directories are not followed, so files reached through +// one (a symlinked ~/.config) are not listed. func findUnmanaged(root string, targets []string) ([]string, error) { - // A target and all its ancestors are kept: files are managed, directories - // are descended into. - keep := make(map[string]bool) + managed := make(map[string]bool, len(targets)) for _, t := range targets { - for p := t; !keep[p]; p = filepath.Dir(p) { - keep[p] = true - } + managed[t] = true } var unmanaged []string @@ -122,14 +111,9 @@ func findUnmanaged(root string, targets []string) ([]string, error) { // An unreadable directory below root is skipped, not fatal. return nil } - if path == root || keep[path] { - return nil - } - if d.IsDir() { - unmanaged = append(unmanaged, path+string(filepath.Separator)) - return filepath.SkipDir + if !d.IsDir() && !managed[path] { + unmanaged = append(unmanaged, path) } - unmanaged = append(unmanaged, path) return nil }) return unmanaged, err diff --git a/internal/cli/unmanaged_test.go b/internal/cli/unmanaged_test.go index 8414762..610b098 100644 --- a/internal/cli/unmanaged_test.go +++ b/internal/cli/unmanaged_test.go @@ -38,7 +38,7 @@ func TestFindUnmanaged(t *testing.T) { want := []string{ filepath.Join(root, ".bashrc"), - filepath.Join(root, ".config/htop") + "/", + filepath.Join(root, ".config/htop/htoprc"), filepath.Join(root, ".config/nvim/lazy.json"), filepath.Join(root, "empty/stray"), } From 107a2f3fd908ad23d7628b9b4310569461705e28 Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 08:08:37 +1100 Subject: [PATCH 09/34] feat(cli): color mate's output Change markers, doctor check results, warnings and errors, script and hook progress, and section headers are colored. fatih/color turns color off when stdout is not a terminal or NO_COLOR is set. The three copies of the marker switch become ChangeStatus.Marker, which also gives mate check its missing import marker. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++ docs/configuration.md | 1 + internal/cli/add.go | 3 +- internal/cli/apply.go | 15 ++++---- internal/cli/check.go | 20 +++------- internal/cli/clean.go | 5 ++- internal/cli/delete.go | 2 +- internal/cli/diff.go | 13 ++++--- internal/cli/doctor.go | 69 +++++++++++++++++++++-------------- internal/cli/hooks.go | 5 ++- internal/cli/init.go | 2 +- internal/cli/packages.go | 6 +-- internal/cli/profile.go | 6 +-- internal/cli/rename.go | 2 +- internal/cli/root.go | 7 +++- internal/cli/secrets.go | 6 +-- internal/cli/status.go | 33 ++++++----------- internal/hooks/run.go | 11 +++--- internal/scripts/execute.go | 7 ++-- internal/secrets/secrets.go | 3 +- internal/target/apply.go | 41 ++++++++++++--------- internal/target/apply_test.go | 20 ++++++++++ 22 files changed, 160 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 604ee12..69d904b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `mate add -s ` offers to create the source when its directory does not exist yet, then adds the file to it. The config is not changed: mate warns that the new source must still be listed under `sources:` before it is deployed - `mate unmanaged [dir]` lists the files under the current directory, or the given one, that no source deploys for any profile. Only files are listed, not directories, and paths are relative to the current directory so they can be passed to `mate add` +- Colored output: change markers (`+` `~` `!` `<`), `mate doctor`'s `[OK]`/`[WARN]`/`[ERROR]`, warnings, errors, script and hook progress, and section headers. Color is off when stdout is not a terminal or `NO_COLOR` is set ### Changed - **Breaking:** `variable_commands` is removed. A variable's value may now be a template instead, so `variable_commands: {key: "gpg ..."}` becomes `variables: {key: '{{ cmd "gpg ..." }}'}`. Template values are rendered in `variables`, `include`, `var_files` and profile variables, including list items and nested keys, so a list such as `gpg_keys` can come from commands too. A leftover `variable_commands` key is ignored, leaving those variables undefined @@ -18,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** the `#encrypted`, `#template` and `#symlink` attributes are renamed to `#enc`, `#tmpl` and `#link`, and the long forms are no longer recognised. Scripts use `#tmpl` too. Rename existing sources, for example `ssh/.ssh/config#encrypted` to `ssh/.ssh/config#enc`; mate stops with "#encrypted was renamed to #enc" until they are. `mate encrypt` and `mate add --encrypt`/`--template` write the short forms, and a `var_files` or `include` entry named without a suffix finds the `#enc` file. Recorded script runs follow the rename, so `#once` scripts do not run again - An unrecognised attribute in a file, directory or script name is now an error that names the file, and so is a `#perm:` mode that is not octal. Before, it was dropped silently, so a typo such as `config#templte` deployed an unrendered `config` +### Fixed +- `mate check` marks an `#import` change with `<`, as `mate status` does, instead of leaving the marker blank + ## [0.6.0] - 2026-10-17 ### Added diff --git a/docs/configuration.md b/docs/configuration.md index 5739376..617c104 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -484,6 +484,7 @@ content assembled from variables or secrets. | `XDG_STATE_HOME` | Where the secrets cache lives. Default `~/.local/state` | | `VISUAL`, `EDITOR` | Editor for `mate edit`, if `editor:` is unset | | `USER`, `USERNAME` | The user for profile detection and `.Username` | +| `NO_COLOR` | Set to any value to turn off colored output. Color is also off when stdout is not a terminal | `STATEMATE_DIR` is handy for working against a second repository without touching your config: diff --git a/internal/cli/add.go b/internal/cli/add.go index 3ee592f..5b76716 100644 --- a/internal/cli/add.go +++ b/internal/cli/add.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/config" @@ -142,7 +143,7 @@ func runAdd(cmd *cobra.Command, args []string) error { if err := promptCreateSource(sourceDir, notConfigured); err != nil { return err } - fmt.Fprintf(os.Stderr, "Warning: source %q is not in the config; add it to 'sources:' so mate deploys it\n", sourceName) + fmt.Fprintf(os.Stderr, "%s source %q is not in the config; add it to 'sources:' so mate deploys it\n", color.YellowString("Warning:"), sourceName) } } else { // Must offer the same list that absSources is indexed from: cfg.Sources diff --git a/internal/cli/apply.go b/internal/cli/apply.go index b703844..5a5ad0b 100644 --- a/internal/cli/apply.go +++ b/internal/cli/apply.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/config" "github.com/subbeh/statemate/internal/encrypt" @@ -162,7 +163,7 @@ func runApply(cmd *cobra.Command, args []string) error { } if tree.HasConflicts() { - fmt.Fprintln(os.Stderr, "Error: conflicting targets detected") + fmt.Fprintf(os.Stderr, "%s conflicting targets detected\n", color.RedString("Error:")) for _, c := range tree.Conflicts { fmt.Fprintf(os.Stderr, " %s defined in:\n", util.ShortenPath(c.TargetPath)) for _, s := range c.Sources { @@ -252,7 +253,7 @@ func runApply(cmd *cobra.Command, args []string) error { if len(beforeScripts) > 0 { if dryRun { - fmt.Println("Running before scripts...") + fmt.Println(bold("Running before scripts...")) } res, err := executor.Execute(beforeScripts) if err != nil { @@ -328,7 +329,7 @@ func runApply(cmd *cobra.Command, args []string) error { if len(afterScripts) > 0 { if dryRun { - fmt.Println("Running after scripts...") + fmt.Println(bold("Running after scripts...")) } res, err := executor.Execute(afterScripts) if err != nil { @@ -357,7 +358,7 @@ func runApply(cmd *cobra.Command, args []string) error { if len(parts) == 0 { fmt.Println("Nothing to do") } else { - fmt.Printf("%s\n", strings.Join(parts, ", ")) + fmt.Println(color.GreenString(strings.Join(parts, ", "))) } } @@ -428,7 +429,7 @@ func warnSkippedScripts(res *scripts.ExecuteResult) { if res == nil || len(res.SkippedNoTTY) == 0 { return } - fmt.Fprintf(os.Stderr, "\nWarning: %d script(s) skipped (no terminal to confirm on):\n", len(res.SkippedNoTTY)) + fmt.Fprintf(os.Stderr, "\n%s %d script(s) skipped (no terminal to confirm on):\n", color.YellowString("Warning:"), len(res.SkippedNoTTY)) for _, s := range res.SkippedNoTTY { fmt.Fprintf(os.Stderr, " - %s\n", s) } @@ -446,7 +447,7 @@ func promptMissingPackages(cfg *config.Config, profileName string, sourcePaths [ results, err := packages.ComputeSync(cfg, profileName, sourcePaths, packages.WithDirConfigRenderer(dirConfigRenderer(tmplCtx))) if err != nil { - fmt.Fprintf(os.Stderr, "\nWarning: skipping packages: %v\n", err) + fmt.Fprintf(os.Stderr, "\n%s skipping packages: %v\n", color.YellowString("Warning:"), err) return nil } @@ -512,7 +513,7 @@ func warnSkippedPackages(skipped []string) { if len(skipped) == 0 { return } - fmt.Fprintf(os.Stderr, "\nWarning: %d package install(s) skipped (no terminal to confirm on):\n", len(skipped)) + fmt.Fprintf(os.Stderr, "\n%s %d package install(s) skipped (no terminal to confirm on):\n", color.YellowString("Warning:"), len(skipped)) for _, s := range skipped { fmt.Fprintf(os.Stderr, " - %s\n", s) } diff --git a/internal/cli/check.go b/internal/cli/check.go index 00b1b99..c826206 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/config" "github.com/subbeh/statemate/internal/encrypt" @@ -75,7 +76,7 @@ func runCheck(cmd *cobra.Command, args []string) error { if tree.HasConflicts() { if !checkQuiet { - fmt.Fprintln(os.Stderr, "Error: conflicting targets detected") + fmt.Fprintf(os.Stderr, "%s conflicting targets detected\n", color.RedString("Error:")) for _, c := range tree.Conflicts { fmt.Fprintf(os.Stderr, " %s defined in:\n", util.ShortenPath(c.TargetPath)) for _, s := range c.Sources { @@ -123,27 +124,18 @@ func runCheck(cmd *cobra.Command, args []string) error { if len(changes) == 0 { if !checkQuiet { - fmt.Println("OK: configuration is in sync") + fmt.Println(color.GreenString("OK:"), "configuration is in sync") if len(result.Skipped) > 0 { - fmt.Fprintf(os.Stderr, "Warning: %d file(s) skipped (permission denied, use --sudo to check)\n", len(result.Skipped)) + fmt.Fprintf(os.Stderr, "%s %d file(s) skipped (permission denied, use --sudo to check)\n", color.YellowString("Warning:"), len(result.Skipped)) } } return nil } if !checkQuiet { - fmt.Printf("DRIFT: %d file(s) would change\n", len(changes)) + fmt.Printf("%s %d file(s) would change\n", color.YellowString("DRIFT:"), len(changes)) for _, change := range changes { - var prefix string - switch change.Status { - case target.StatusNew: - prefix = "+" - case target.StatusModified: - prefix = "~" - case target.StatusConflict: - prefix = "!" - } - fmt.Printf(" %s %s\n", prefix, util.ShortenPath(change.Entry.TargetPath)) + fmt.Printf(" %s %s\n", change.Status.Marker(), util.ShortenPath(change.Entry.TargetPath)) } } diff --git a/internal/cli/clean.go b/internal/cli/clean.go index 2e547c0..88a2331 100644 --- a/internal/cli/clean.go +++ b/internal/cli/clean.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/config" "github.com/subbeh/statemate/internal/hooks" @@ -87,7 +88,7 @@ func runClean(cmd *cobra.Command, args []string) error { } if len(orphans) == 0 { - fmt.Println("No orphaned files") + fmt.Println(color.GreenString("No orphaned files")) return nil } @@ -97,7 +98,7 @@ func runClean(cmd *cobra.Command, args []string) error { all, _ := cmd.Flags().GetBool("all") if len(args) == 0 && !all { - fmt.Println("Orphaned files:") + fmt.Println(bold("Orphaned files:")) for _, o := range orphans { fmt.Printf(" %s\n", util.ShortenPath(o)) } diff --git a/internal/cli/delete.go b/internal/cli/delete.go index feda3f0..54540ac 100644 --- a/internal/cli/delete.go +++ b/internal/cli/delete.go @@ -85,7 +85,7 @@ func runDelete(cmd *cobra.Command, args []string) error { } if !deleteForce { - fmt.Printf("Will remove:\n") + fmt.Println(bold("Will remove:")) fmt.Printf(" Source: %s\n", util.ShortenPath(entry.SourcePath)) if !deleteKeepTarget { fmt.Printf(" Target: %s\n", util.ShortenPath(entry.TargetPath)) diff --git a/internal/cli/diff.go b/internal/cli/diff.go index 7903e46..edcd6b5 100644 --- a/internal/cli/diff.go +++ b/internal/cli/diff.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/config" "github.com/subbeh/statemate/internal/encrypt" @@ -74,7 +75,7 @@ func runDiff(cmd *cobra.Command, args []string) error { } if tree.HasConflicts() { - fmt.Fprintln(os.Stderr, "Error: conflicting targets detected") + fmt.Fprintf(os.Stderr, "%s conflicting targets detected\n", color.RedString("Error:")) for _, c := range tree.Conflicts { fmt.Fprintf(os.Stderr, " %s defined in:\n", util.ShortenPath(c.TargetPath)) for _, s := range c.Sources { @@ -148,9 +149,9 @@ func runDiff(cmd *cobra.Command, args []string) error { if len(changes) == 0 { if len(result.Skipped) > 0 { - fmt.Fprintf(os.Stderr, "Warning: %d file(s) skipped (permission denied, use --sudo to check)\n", len(result.Skipped)) + fmt.Fprintf(os.Stderr, "%s %d file(s) skipped (permission denied, use --sudo to check)\n", color.YellowString("Warning:"), len(result.Skipped)) } - fmt.Println("No changes") + fmt.Println(color.GreenString("No changes")) return nil } @@ -162,14 +163,14 @@ func runDiff(cmd *cobra.Command, args []string) error { for _, change := range changes { if change.Entry.IsDir { // Directories have no content to compare; status reports them. - fmt.Printf("=== %s ===\n(directory)\n", util.ShortenPath(change.Entry.TargetPath)) + fmt.Printf("%s\n(directory)\n", bold("=== "+util.ShortenPath(change.Entry.TargetPath)+" ===")) continue } if change.Entry.Attrs.Symlink { // What changes is the link text; diffing what it points at would fail // for a directory or a destination that does not exist. - fmt.Printf("=== %s ===\n", util.ShortenPath(change.Entry.TargetPath)) + fmt.Println(bold("=== " + util.ShortenPath(change.Entry.TargetPath) + " ===")) fmt.Println(target.ColorizeDiff(symlinkDiff(change.Entry))) continue } @@ -195,7 +196,7 @@ func runDiff(cmd *cobra.Command, args []string) error { } else { diff, err = target.GenerateDiffWithTool(change.Entry.SourcePath, change.Entry.TargetPath, diffTool) } - fmt.Printf("=== %s ===\n", util.ShortenPath(change.Entry.TargetPath)) + fmt.Println(bold("=== " + util.ShortenPath(change.Entry.TargetPath) + " ===")) if err != nil { if errors.As(err, new(*secrets.NotCachedError)) { fmt.Fprintf(os.Stderr, " (secrets not cached, run 'mate secrets fetch')\n") diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 3a26bb4..0c98169 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/config" "github.com/subbeh/statemate/internal/encrypt" @@ -32,8 +33,8 @@ func init() { } func runDoctor(cmd *cobra.Command, args []string) error { - fmt.Println("Statemate Doctor") - fmt.Println("================") + fmt.Println(bold("Statemate Doctor")) + fmt.Println(bold("================")) fmt.Println() issues := 0 @@ -41,27 +42,27 @@ func runDoctor(cmd *cobra.Command, args []string) error { cfgPath, _ := cmd.Flags().GetString("config") cfg, err := config.Load(cfgPath) if err != nil { - fmt.Printf("[ERROR] Configuration: %v\n", err) + fmt.Printf("%s Configuration: %v\n", tag("ERROR"), err) issues++ } else { - fmt.Println("[OK] Configuration loaded") + fmt.Println(tag("OK"), "Configuration loaded") if err := cfg.Validate(); err != nil { - fmt.Printf("[ERROR] Configuration validation: %v\n", err) + fmt.Printf("%s Configuration validation: %v\n", tag("ERROR"), err) issues++ } else { - fmt.Println("[OK] Configuration valid") + fmt.Println(tag("OK"), "Configuration valid") } for _, source := range cfg.AbsoluteSources() { if info, err := os.Stat(source); err != nil { - fmt.Printf("[ERROR] Source directory missing: %s\n", source) + fmt.Printf("%s Source directory missing: %s\n", tag("ERROR"), source) issues++ } else if !info.IsDir() { - fmt.Printf("[ERROR] Source is not a directory: %s\n", source) + fmt.Printf("%s Source is not a directory: %s\n", tag("ERROR"), source) issues++ } else { - fmt.Printf("[OK] Source directory: %s\n", source) + fmt.Printf("%s Source directory: %s\n", tag("OK"), source) } } } @@ -72,24 +73,24 @@ func runDoctor(cmd *cobra.Command, args []string) error { } fmt.Println() - fmt.Println("Dependencies:") + fmt.Println(bold("Dependencies:")) if cfg != nil { level, msg := ageStatus(cfg) - fmt.Printf("[%s] %s\n", level, msg) + fmt.Println(tag(level), msg) if level == "ERROR" { issues++ } } if _, err := exec.LookPath("diff"); err != nil { - fmt.Println("[WARN] diff not found (diff output unavailable)") + fmt.Println(tag("WARN"), "diff not found (diff output unavailable)") } else { - fmt.Println("[OK] diff") + fmt.Println(tag("OK"), "diff") } fmt.Println() - fmt.Println("Package Managers:") + fmt.Println(bold("Package Managers:")) aurHelper := "" if cfg != nil { @@ -97,32 +98,44 @@ func runDoctor(cmd *cobra.Command, args []string) error { } managers := packages.GetAvailableManagersWithHelper(aurHelper) if len(managers) == 0 { - fmt.Println("[WARN] No package managers found") + fmt.Println(tag("WARN"), "No package managers found") } else { for _, m := range managers { - fmt.Printf("[OK] %s\n", m.Name()) + fmt.Printf("%s %s\n", tag("OK"), m.Name()) } } fmt.Println() if issues > 0 { - fmt.Printf("Found %d issue(s)\n", issues) + fmt.Println(color.RedString("Found %d issue(s)", issues)) os.Exit(1) } else { - fmt.Println("No issues found") + fmt.Println(color.GreenString("No issues found")) } return nil } +// tag colors a check's [OK], [WARN] or [ERROR] label. +func tag(level string) string { + switch level { + case "OK": + return color.GreenString("[OK]") + case "WARN": + return color.YellowString("[WARN]") + default: + return color.RedString("[ERROR]") + } +} + // checkHooks reports hooks that fail to load, and warns about active hooks // whose patterns match none of the managed files -- usually a typo. func checkHooks(cmd *cobra.Command) int { env, err := loadHookEnv(cmd) if err != nil { fmt.Println() - fmt.Println("Hooks:") - fmt.Printf("[ERROR] %v\n", err) + fmt.Println(bold("Hooks:")) + fmt.Printf("%s %v\n", tag("ERROR"), err) return 1 } if len(env.set) == 0 { @@ -130,7 +143,7 @@ func checkHooks(cmd *cobra.Command) int { } fmt.Println() - fmt.Println("Hooks:") + fmt.Println(bold("Hooks:")) matched := make(map[string]bool) for _, t := range env.set.Trigger(hookChanges(env.files, env.sourcePaths), env.profileChain) { @@ -141,9 +154,9 @@ func checkHooks(cmd *cobra.Command) int { case !h.IsEnabled(), !h.ActiveFor(env.profileChain): continue case matched[h.Name]: - fmt.Printf("[OK] %s\n", h.Name) + fmt.Printf("%s %s\n", tag("OK"), h.Name) default: - fmt.Printf("[WARN] %s matches no managed files\n", h.Name) + fmt.Printf("%s %s matches no managed files\n", tag("WARN"), h.Name) } } return 0 @@ -178,8 +191,8 @@ func checkEncrypted(cfg *config.Config) int { tree, err := scanner.Scan(cfg.ResolveSourcePaths(profile.AllSources(cfg))) if err != nil { fmt.Println() - fmt.Println("Encrypted files:") - fmt.Printf("[ERROR] scanning sources: %v\n", err) + fmt.Println(bold("Encrypted files:")) + fmt.Printf("%s scanning sources: %v\n", tag("ERROR"), err) return 1 } @@ -199,12 +212,12 @@ func checkEncrypted(cfg *config.Config) int { } fmt.Println() - fmt.Println("Encrypted files:") + fmt.Println(bold("Encrypted files:")) for _, p := range plain { - fmt.Printf("[ERROR] %s is marked #enc but is not encrypted\n", util.ShortenPath(p)) + fmt.Printf("%s %s is marked #enc but is not encrypted\n", tag("ERROR"), util.ShortenPath(p)) } if len(plain) == 0 { - fmt.Printf("[OK] %d #enc file(s) encrypted\n", checked) + fmt.Printf("%s %d #enc file(s) encrypted\n", tag("OK"), checked) } return len(plain) } diff --git a/internal/cli/hooks.go b/internal/cli/hooks.go index 64b0e78..95c7822 100644 --- a/internal/cli/hooks.go +++ b/internal/cli/hooks.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/fatih/color" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/tw" "github.com/spf13/cobra" @@ -286,7 +287,7 @@ func runTriggeredHooks(runner *hooks.Runner, triggered []*hooks.Triggered, db *s return nil, nil } if announce { - fmt.Println("Running hooks...") + fmt.Println(bold("Running hooks...")) } // Report what was skipped even when the user quit part-way. @@ -302,7 +303,7 @@ func runTriggeredHooks(runner *hooks.Runner, triggered []*hooks.Triggered, db *s fmt.Printf(" skipped hook %s; run it later with 'mate hooks run %s'\n", name, name) } if len(res.SkippedNoTTY) > 0 { - fmt.Fprintf(os.Stderr, "\nWarning: %d hook(s) skipped (no terminal to confirm on):\n", len(res.SkippedNoTTY)) + fmt.Fprintf(os.Stderr, "\n%s %d hook(s) skipped (no terminal to confirm on):\n", color.YellowString("Warning:"), len(res.SkippedNoTTY)) for _, s := range res.SkippedNoTTY { fmt.Fprintf(os.Stderr, " - %s\n", s) } diff --git a/internal/cli/init.go b/internal/cli/init.go index 09028f1..c82d519 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -214,7 +214,7 @@ func runInit(cmd *cobra.Command, args []string) error { return err } - fmt.Println("\nNext steps:") + fmt.Println("\n" + bold("Next steps:")) fmt.Printf(" 1. Create a source directory and list it in %s:\n", configPath) fmt.Println(" mkdir zsh and set sources: [zsh]") fmt.Println(" 2. Add files: mate add ~/.zshrc") diff --git a/internal/cli/packages.go b/internal/cli/packages.go index 621a4e9..7b58a2f 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -231,7 +231,7 @@ func runPackagesApply(cmd *cobra.Command, args []string) error { } if !anyChanges { - fmt.Println("All packages are in sync") + fmt.Println(color.GreenString("All packages are in sync")) return nil } @@ -250,7 +250,7 @@ func runPackagesApply(cmd *cobra.Command, args []string) error { return err } - fmt.Printf("\n=== %s ===\n", result.Manager) + fmt.Println("\n" + bold("=== "+result.Manager+" ===")) if hasMissing { fmt.Printf("Will install: %s\n", strings.Join(missing, ", ")) @@ -290,7 +290,7 @@ func runPackagesApply(cmd *cobra.Command, args []string) error { } } - fmt.Println("Done") + fmt.Println(color.GreenString("Done")) } return nil diff --git a/internal/cli/profile.go b/internal/cli/profile.go index db4ed37..07831b0 100644 --- a/internal/cli/profile.go +++ b/internal/cli/profile.go @@ -63,7 +63,7 @@ func runProfile(cmd *cobra.Command, args []string) error { fmt.Println("Source: no profile matched") if len(cfg.Profiles) > 0 { - fmt.Println("\nConfigured profiles:") + fmt.Println("\n" + bold("Configured profiles:")) for name := range cfg.Profiles { fmt.Printf(" - %s\n", name) } @@ -73,7 +73,7 @@ func runProfile(cmd *cobra.Command, args []string) error { sources := profile.ResolveSources(cfg, profileName) if len(sources) > 0 { sort.Strings(sources) - fmt.Println("\nSources:") + fmt.Println("\n" + bold("Sources:")) for _, s := range sources { fmt.Printf(" - %s\n", s) } @@ -88,7 +88,7 @@ func printDetectionReason(cfg *config.Config, profileName string) { return } - fmt.Println("\nMatched because:") + fmt.Println("\n" + bold("Matched because:")) d := p.Detection hostname, _ := os.Hostname() diff --git a/internal/cli/rename.go b/internal/cli/rename.go index c1fd48e..4174298 100644 --- a/internal/cli/rename.go +++ b/internal/cli/rename.go @@ -157,7 +157,7 @@ func runRename(cmd *cobra.Command, args []string) error { } } - fmt.Printf("Renamed:\n") + fmt.Println(bold("Renamed:")) fmt.Printf(" source: %s -> %s\n", util.ShortenPath(entry.SourcePath), util.ShortenPath(newSourcePath)) if targetRenamed { fmt.Printf(" target: %s -> %s\n", util.ShortenPath(entry.TargetPath), util.ShortenPath(newTargetPath)) diff --git a/internal/cli/root.go b/internal/cli/root.go index 3477abd..01cd562 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -4,12 +4,17 @@ import ( "errors" "fmt" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/secrets" ) var version = "dev" +// bold marks section headers. Like every color, it is dropped when stdout is +// not a terminal or NO_COLOR is set. +var bold = color.New(color.Bold).SprintFunc() + var rootCmd = &cobra.Command{ Use: "mate", Version: version, @@ -54,7 +59,7 @@ func SetVersion(v string) { func Execute() error { cmd, err := rootCmd.ExecuteC() if err != nil && cmd.SilenceErrors { - cmd.PrintErrln("Error:", errorMessage(err)) + cmd.PrintErrln(color.RedString("Error:"), errorMessage(err)) } return err } diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index f980cc5..b18a05e 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -109,7 +109,7 @@ func runSecretsFetch(cmd *cobra.Command, args []string) error { if errors.Is(err, secrets.ErrNoIdentity) { return err } - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "%s %v\n", color.RedString("Error:"), err) if ok, _ := util.Confirm("Continue with cached secrets? [y/n]: ", false); !ok { return err } @@ -199,11 +199,11 @@ func runSecretsStatus(cmd *cobra.Command, args []string) error { } if len(missing) == 0 { - fmt.Println("All secrets are cached") + fmt.Println(color.GreenString("All secrets are cached")) return nil } - fmt.Println("Secrets needing fetch:") + fmt.Println(bold("Secrets needing fetch:")) for _, item := range missing { fmt.Printf(" %s/%s/%s\n", item.Item, item.Type, item.Field) } diff --git a/internal/cli/status.go b/internal/cli/status.go index 59867fa..4849041 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/subbeh/statemate/internal/config" "github.com/subbeh/statemate/internal/encrypt" @@ -92,7 +93,7 @@ func runStatus(cmd *cobra.Command, args []string) error { } if tree.HasConflicts() { - fmt.Fprintln(os.Stderr, "Warning: conflicting targets detected") + fmt.Fprintf(os.Stderr, "%s conflicting targets detected\n", color.YellowString("Warning:")) for _, c := range tree.Conflicts { fmt.Fprintf(os.Stderr, " %s defined in:\n", util.ShortenPath(c.TargetPath)) for _, s := range c.Sources { @@ -186,7 +187,7 @@ func runStatus(cmd *cobra.Command, args []string) error { syncResults, err := packages.ComputeSync(cfg, profileName, sourcePaths, packages.WithDirConfigRenderer(dirConfigRenderer(tmplCtx))) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: not checking packages: %v\n", err) + fmt.Fprintf(os.Stderr, "%s not checking packages: %v\n", color.YellowString("Warning:"), err) } for _, r := range syncResults { if missing := r.Missing(); len(missing) > 0 { @@ -249,14 +250,14 @@ func runStatus(cmd *cobra.Command, args []string) error { if len(filteredChanges) == 0 && len(filteredOrphans) == 0 && len(pendingScripts) == 0 && len(pendingHooks) == 0 && pendingSecrets == 0 && len(pendingPackages) == 0 { - fmt.Println("Everything is up to date") + fmt.Println(color.GreenString("Everything is up to date")) return nil } printChanges(filteredChanges) if len(filteredOrphans) > 0 { - fmt.Fprintln(os.Stderr, "\nWarning: orphaned files (no longer in source):") + fmt.Fprintf(os.Stderr, "\n%s orphaned files (no longer in source):\n", color.YellowString("Warning:")) for _, o := range filteredOrphans { fmt.Fprintf(os.Stderr, " - %s\n", util.ShortenPath(o)) } @@ -264,7 +265,7 @@ func runStatus(cmd *cobra.Command, args []string) error { } if len(pendingScripts) > 0 { - fmt.Println("\nPending scripts:") + fmt.Println("\n" + bold("Pending scripts:")) for _, s := range pendingScripts { timing := "before" if s.Timing == scripts.TimingAfter { @@ -278,7 +279,7 @@ func runStatus(cmd *cobra.Command, args []string) error { } if len(pendingHooks) > 0 { - fmt.Println("\nPending hooks:") + fmt.Println("\n" + bold("Pending hooks:")) for _, h := range pendingHooks { fmt.Printf(" %s (%s)\n", h.Name, hooks.FileCount(len(h.Files))) if h.Description != "" { @@ -288,18 +289,18 @@ func runStatus(cmd *cobra.Command, args []string) error { } if len(pendingPackages) > 0 { - fmt.Println("\nMissing packages:") + fmt.Println("\n" + bold("Missing packages:")) for _, p := range pendingPackages { fmt.Printf(" %s: %s\n", p.manager, strings.Join(p.packages, ", ")) } } if pendingSecrets > 0 { - fmt.Printf("\nSecrets:\n %d secrets need refresh (run 'mate secrets fetch')\n", pendingSecrets) + fmt.Printf("\n%s\n %d secrets need refresh (run 'mate secrets fetch')\n", bold("Secrets:"), pendingSecrets) } if len(result.Skipped) > 0 { - fmt.Fprintf(os.Stderr, "\nWarning: %d file(s) skipped (permission denied, use --sudo to check)\n", len(result.Skipped)) + fmt.Fprintf(os.Stderr, "\n%s %d file(s) skipped (permission denied, use --sudo to check)\n", color.YellowString("Warning:"), len(result.Skipped)) } return nil @@ -320,21 +321,9 @@ func printChanges(changes []*target.Change) { fmt.Printf(" %-*s %s\n", maxTargetLen, "TARGET", "SOURCE") for _, c := range changes { - var prefix string - switch c.Status { - case target.StatusNew: - prefix = "+" - case target.StatusModified: - prefix = "~" - case target.StatusConflict: - prefix = "!" - case target.StatusImport: - // Points back at the source: this file moves the other way. - prefix = "<" - } targetDisplay := util.ShortenPath(c.Entry.TargetPath) sourceDir := extractSourceDir(c.Entry) - fmt.Printf("%s %-*s %s\n", prefix, maxTargetLen, targetDisplay, sourceDir) + fmt.Printf("%s %-*s %s\n", c.Status.Marker(), maxTargetLen, targetDisplay, sourceDir) } } diff --git a/internal/hooks/run.go b/internal/hooks/run.go index 7a3790b..f4d9419 100644 --- a/internal/hooks/run.go +++ b/internal/hooks/run.go @@ -8,6 +8,7 @@ import ( "golang.org/x/term" + "github.com/fatih/color" "github.com/subbeh/statemate/internal/scripts" "github.com/subbeh/statemate/internal/template" "github.com/subbeh/statemate/internal/util" @@ -77,7 +78,7 @@ func (r *Runner) Run(triggered []*Triggered) (*Result, error) { rendered, err := r.render(t) if err != nil { - fmt.Printf("<<< failed: hook %s (%v)\n", t.Name, err) + fmt.Printf("%s hook %s (%v)\n", color.RedString("<<< failed:"), t.Name, err) res.Failed = append(res.Failed, fmt.Errorf("hook %s: %w", t.Name, err)) continue } @@ -186,12 +187,12 @@ func (r *Runner) runSteps(t *Triggered, rendered []string) error { "STATEMATE_HOOK_FILES=" + strings.Join(t.Files, "\n"), } - fmt.Printf(">>> running: hook %s\n", t.Name) + fmt.Printf("%s hook %s\n", color.CyanString(">>> running:"), t.Name) for i, s := range t.steps { var err error if s.script != nil { if s.script.Profile != "" && !matchesChain(s.script.Profile, r.opts.ProfileChain) { - fmt.Fprintf(os.Stderr, "Warning: hook %s: skipping script %s (profile %s is not active)\n", + fmt.Fprintf(os.Stderr, "%s hook %s: skipping script %s (profile %s is not active)\n", color.YellowString("Warning:"), t.Name, s.script.Name, s.script.Profile) continue } @@ -204,11 +205,11 @@ func (r *Runner) runSteps(t *Triggered, rendered []string) error { err = r.runCommand(t, rendered[i], env) } if err != nil { - fmt.Printf("<<< failed: hook %s (step %d: %v)\n", t.Name, i+1, err) + fmt.Printf("%s hook %s (step %d: %v)\n", color.RedString("<<< failed:"), t.Name, i+1, err) return fmt.Errorf("step %d: %w", i+1, err) } } - fmt.Printf("<<< done: hook %s\n", t.Name) + fmt.Printf("%s hook %s\n", color.GreenString("<<< done:"), t.Name) return nil } diff --git a/internal/scripts/execute.go b/internal/scripts/execute.go index 5d35707..59ce126 100644 --- a/internal/scripts/execute.go +++ b/internal/scripts/execute.go @@ -13,6 +13,7 @@ import ( "golang.org/x/term" + "github.com/fatih/color" "github.com/subbeh/statemate/internal/state" "github.com/subbeh/statemate/internal/template" "github.com/subbeh/statemate/internal/util" @@ -502,12 +503,12 @@ func (e *Executor) run(script *Script, extraEnv ...string) (err error) { } cmd.Env = append(cmd.Env, extraEnv...) - fmt.Printf(">>> running: %s (%s/%s)\n", script.Name, script.Frequency, script.Timing) + fmt.Printf("%s %s (%s/%s)\n", color.CyanString(">>> running:"), script.Name, script.Frequency, script.Timing) err = cmd.Run() if err != nil { - fmt.Printf("<<< failed: %s (%v)\n", script.Name, err) + fmt.Printf("%s %s (%v)\n", color.RedString("<<< failed:"), script.Name, err) } else { - fmt.Printf("<<< done: %s\n", script.Name) + fmt.Printf("%s %s\n", color.GreenString("<<< done:"), script.Name) } return err } diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go index 0bb75b4..1f12a78 100644 --- a/internal/secrets/secrets.go +++ b/internal/secrets/secrets.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/fatih/color" "github.com/subbeh/statemate/internal/encrypt" ) @@ -110,7 +111,7 @@ func (m *Manager) Fetch(items []FetchItem) (*FetchResult, error) { if err := m.loadCache(); err != nil { if errors.Is(err, errUnencryptedCache) { - fmt.Fprintf(os.Stderr, "Warning: replacing unencrypted secrets cache %s with an encrypted one\n", m.cachePath) + fmt.Fprintf(os.Stderr, "%s replacing unencrypted secrets cache %s with an encrypted one\n", color.YellowString("Warning:"), m.cachePath) } m.cache = &Cache{Items: make(map[string]*CachedValue)} } diff --git a/internal/target/apply.go b/internal/target/apply.go index aa9bea1..37af1e8 100644 --- a/internal/target/apply.go +++ b/internal/target/apply.go @@ -11,6 +11,7 @@ import ( "strings" "syscall" + "github.com/fatih/color" "github.com/subbeh/statemate/internal/encrypt" "github.com/subbeh/statemate/internal/source" "github.com/subbeh/statemate/internal/state" @@ -73,6 +74,23 @@ func (s ChangeStatus) String() string { } } +// Marker is the colored one-character prefix that lists a change. +func (s ChangeStatus) Marker() string { + switch s { + case StatusNew: + return color.GreenString("+") + case StatusModified: + return color.YellowString("~") + case StatusConflict: + return color.RedString("!") + case StatusImport: + // Points back at the source: this file moves the other way. + return color.CyanString("<") + default: + return "" + } +} + func NewApplier(db *state.DB, tmplCtx *template.Context, enc *encrypt.AgeEncryptor, dryRun, force bool, verbose int) *Applier { return &Applier{ db: db, @@ -94,7 +112,7 @@ func (a *Applier) Apply(tree *source.Tree) (*ApplyResult, error) { // Only an empty directory is worth reporting: the rest come along with // the files inside them, which are listed separately. if _, err := os.Stat(dir.TargetPath); os.IsNotExist(err) && emptyDirs[dir.TargetPath] { - fmt.Printf("+ %s\n", dir.TargetPath) + fmt.Printf("%s %s\n", StatusNew.Marker(), dir.TargetPath) result.Applied++ } continue @@ -134,7 +152,7 @@ func (a *Applier) Apply(tree *source.Tree) (*ApplyResult, error) { } if created { if a.verbose > 0 { - fmt.Printf("+ %s\n", dir.TargetPath) + fmt.Printf("%s %s\n", StatusNew.Marker(), dir.TargetPath) } result.Applied++ } @@ -156,7 +174,7 @@ func (a *Applier) Apply(tree *source.Tree) (*ApplyResult, error) { } if created { if a.verbose > 0 { - fmt.Printf("+ %s\n", dir.TargetPath) + fmt.Printf("%s %s\n", StatusNew.Marker(), dir.TargetPath) } result.Applied++ } @@ -192,7 +210,7 @@ func (a *Applier) Apply(tree *source.Tree) (*ApplyResult, error) { if err := a.importFile(entry); err != nil { return nil, fmt.Errorf("importing %s: %w", entry.TargetPath, err) } - fmt.Printf("← %s (imported)\n", util.ShortenPath(entry.TargetPath)) + fmt.Printf("%s %s (imported)\n", color.CyanString("←"), util.ShortenPath(entry.TargetPath)) } result.Imported++ continue @@ -394,7 +412,7 @@ func dirAttrsMatch(info os.FileInfo, attrs source.Attrs) bool { } func (a *Applier) promptConflict(change *Change) (string, error) { - fmt.Printf("\nConflict: %s\n", change.Entry.TargetPath) + fmt.Printf("\n%s %s\n", color.RedString("Conflict:"), change.Entry.TargetPath) fmt.Printf(" Target has been modified since last apply\n") // Importing a #link would write the target's content through the source @@ -604,16 +622,5 @@ func (a *Applier) recordState(entry *source.Entry, sourceHash string) error { } func (a *Applier) printChange(change *Change) { - var prefix string - switch change.Status { - case StatusNew: - prefix = "+" - case StatusModified: - prefix = "~" - case StatusConflict: - prefix = "!" - case StatusImport: - prefix = "<" - } - fmt.Printf("%s %s\n", prefix, change.Entry.TargetPath) + fmt.Printf("%s %s\n", change.Status.Marker(), change.Entry.TargetPath) } diff --git a/internal/target/apply_test.go b/internal/target/apply_test.go index 7933e3e..e593a89 100644 --- a/internal/target/apply_test.go +++ b/internal/target/apply_test.go @@ -9,6 +9,7 @@ import ( "testing" "filippo.io/age" + "github.com/fatih/color" "github.com/subbeh/statemate/internal/encrypt" "github.com/subbeh/statemate/internal/source" @@ -789,3 +790,22 @@ func TestPromptConflict_NoTerminalExplainsItself(t *testing.T) { } } } + +func TestChangeStatusMarker(t *testing.T) { + saved := color.NoColor + t.Cleanup(func() { color.NoColor = saved }) + + color.NoColor = true + for status, want := range map[ChangeStatus]string{ + StatusNew: "+", StatusModified: "~", StatusConflict: "!", StatusImport: "<", StatusUnchanged: "", + } { + if got := status.Marker(); got != want { + t.Errorf("%s: Marker() = %q, want %q", status, got, want) + } + } + + color.NoColor = false + if got := StatusNew.Marker(); got != "\x1b[32m+\x1b[0m" { + t.Errorf("colored StatusNew.Marker() = %q, want green +", got) + } +} From ea3cffc3af385c66efe03b17083475812927c610 Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Sat, 17 Oct 2026 08:31:24 +1100 Subject: [PATCH 10/34] feat(cli): add --no-color flag A global --no-color turns colored output off, alongside the automatic switch-off when stdout is not a terminal or NO_COLOR is set. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- docs/commands/mate.md | 1 + docs/commands/mate_add.md | 1 + docs/commands/mate_apply.md | 1 + docs/commands/mate_cat.md | 1 + docs/commands/mate_check.md | 1 + docs/commands/mate_clean.md | 1 + docs/commands/mate_config.md | 1 + docs/commands/mate_config_source-dir.md | 1 + docs/commands/mate_decrypt.md | 1 + docs/commands/mate_delete.md | 1 + docs/commands/mate_diff.md | 1 + docs/commands/mate_doctor.md | 1 + docs/commands/mate_edit.md | 1 + docs/commands/mate_encrypt.md | 1 + docs/commands/mate_eval.md | 1 + docs/commands/mate_forget.md | 1 + docs/commands/mate_hooks.md | 1 + docs/commands/mate_hooks_list.md | 1 + docs/commands/mate_hooks_run.md | 1 + docs/commands/mate_init.md | 1 + docs/commands/mate_managed.md | 1 + docs/commands/mate_packages.md | 1 + docs/commands/mate_packages_apply.md | 1 + docs/commands/mate_packages_status.md | 1 + docs/commands/mate_profile.md | 1 + docs/commands/mate_rename.md | 1 + docs/commands/mate_scripts.md | 1 + docs/commands/mate_scripts_list.md | 1 + docs/commands/mate_scripts_run.md | 1 + docs/commands/mate_secrets.md | 1 + docs/commands/mate_secrets_fetch.md | 1 + docs/commands/mate_secrets_list.md | 1 + docs/commands/mate_secrets_status.md | 1 + docs/commands/mate_status.md | 1 + docs/commands/mate_unmanaged.md | 1 + docs/commands/mate_version.md | 1 + docs/configuration.md | 2 +- internal/cli/root.go | 6 +++++- 39 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d904b..2e5a6f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `mate add -s ` offers to create the source when its directory does not exist yet, then adds the file to it. The config is not changed: mate warns that the new source must still be listed under `sources:` before it is deployed - `mate unmanaged [dir]` lists the files under the current directory, or the given one, that no source deploys for any profile. Only files are listed, not directories, and paths are relative to the current directory so they can be passed to `mate add` -- Colored output: change markers (`+` `~` `!` `<`), `mate doctor`'s `[OK]`/`[WARN]`/`[ERROR]`, warnings, errors, script and hook progress, and section headers. Color is off when stdout is not a terminal or `NO_COLOR` is set +- Colored output: change markers (`+` `~` `!` `<`), `mate doctor`'s `[OK]`/`[WARN]`/`[ERROR]`, warnings, errors, script and hook progress, and section headers. Color is off when stdout is not a terminal, `NO_COLOR` is set, or `--no-color` is passed ### Changed - **Breaking:** `variable_commands` is removed. A variable's value may now be a template instead, so `variable_commands: {key: "gpg ..."}` becomes `variables: {key: '{{ cmd "gpg ..." }}'}`. Template values are rendered in `variables`, `include`, `var_files` and profile variables, including list items and nested keys, so a list such as `gpg_keys` can come from commands too. A leftover `variable_commands` key is ignored, leaving those variables undefined diff --git a/docs/commands/mate.md b/docs/commands/mate.md index 55dddf2..d87342a 100644 --- a/docs/commands/mate.md +++ b/docs/commands/mate.md @@ -22,6 +22,7 @@ Use "mate [command] --help" for more information about a command. ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) -h, --help help for mate + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_add.md b/docs/commands/mate_add.md index c1c3635..f978a13 100644 --- a/docs/commands/mate_add.md +++ b/docs/commands/mate_add.md @@ -56,6 +56,7 @@ mate add [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_apply.md b/docs/commands/mate_apply.md index 60859af..b75f80f 100644 --- a/docs/commands/mate_apply.md +++ b/docs/commands/mate_apply.md @@ -81,6 +81,7 @@ mate apply [path] [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_cat.md b/docs/commands/mate_cat.md index 76896fa..4344634 100644 --- a/docs/commands/mate_cat.md +++ b/docs/commands/mate_cat.md @@ -31,6 +31,7 @@ mate cat [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_check.md b/docs/commands/mate_check.md index 16f4ae8..400458c 100644 --- a/docs/commands/mate_check.md +++ b/docs/commands/mate_check.md @@ -33,6 +33,7 @@ mate check [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_clean.md b/docs/commands/mate_clean.md index 1295f39..b303a91 100644 --- a/docs/commands/mate_clean.md +++ b/docs/commands/mate_clean.md @@ -43,6 +43,7 @@ mate clean [path...] [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_config.md b/docs/commands/mate_config.md index 7f51feb..e35fe4b 100644 --- a/docs/commands/mate_config.md +++ b/docs/commands/mate_config.md @@ -16,6 +16,7 @@ Show resolved configuration values, for use in scripts and editor integrations ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_config_source-dir.md b/docs/commands/mate_config_source-dir.md index ed81015..70c3ca9 100644 --- a/docs/commands/mate_config_source-dir.md +++ b/docs/commands/mate_config_source-dir.md @@ -30,6 +30,7 @@ mate config source-dir [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_decrypt.md b/docs/commands/mate_decrypt.md index d0801f2..65dfc8e 100644 --- a/docs/commands/mate_decrypt.md +++ b/docs/commands/mate_decrypt.md @@ -43,6 +43,7 @@ mate decrypt [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_delete.md b/docs/commands/mate_delete.md index 16e9a55..7f4cb70 100644 --- a/docs/commands/mate_delete.md +++ b/docs/commands/mate_delete.md @@ -36,6 +36,7 @@ mate delete [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_diff.md b/docs/commands/mate_diff.md index 0f0c0fd..d4f130a 100644 --- a/docs/commands/mate_diff.md +++ b/docs/commands/mate_diff.md @@ -30,6 +30,7 @@ mate diff [path] [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_doctor.md b/docs/commands/mate_doctor.md index b14d002..6a95ef8 100644 --- a/docs/commands/mate_doctor.md +++ b/docs/commands/mate_doctor.md @@ -26,6 +26,7 @@ mate doctor [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_edit.md b/docs/commands/mate_edit.md index 3a9dea3..060b5fc 100644 --- a/docs/commands/mate_edit.md +++ b/docs/commands/mate_edit.md @@ -48,6 +48,7 @@ mate edit [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_encrypt.md b/docs/commands/mate_encrypt.md index 843e5e9..f302285 100644 --- a/docs/commands/mate_encrypt.md +++ b/docs/commands/mate_encrypt.md @@ -42,6 +42,7 @@ mate encrypt [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_eval.md b/docs/commands/mate_eval.md index a1175b5..523846e 100644 --- a/docs/commands/mate_eval.md +++ b/docs/commands/mate_eval.md @@ -34,6 +34,7 @@ mate eval [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_forget.md b/docs/commands/mate_forget.md index f5e8c2d..791ff6a 100644 --- a/docs/commands/mate_forget.md +++ b/docs/commands/mate_forget.md @@ -39,6 +39,7 @@ mate forget ... [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_hooks.md b/docs/commands/mate_hooks.md index b554686..c982875 100644 --- a/docs/commands/mate_hooks.md +++ b/docs/commands/mate_hooks.md @@ -35,6 +35,7 @@ as #tmpl and #perm:600 are ignored when included in a pattern. ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_hooks_list.md b/docs/commands/mate_hooks_list.md index 2e46911..00641d0 100644 --- a/docs/commands/mate_hooks_list.md +++ b/docs/commands/mate_hooks_list.md @@ -24,6 +24,7 @@ mate hooks list [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_hooks_run.md b/docs/commands/mate_hooks_run.md index 72eec23..8d955a9 100644 --- a/docs/commands/mate_hooks_run.md +++ b/docs/commands/mate_hooks_run.md @@ -26,6 +26,7 @@ mate hooks run [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_init.md b/docs/commands/mate_init.md index e6cac7e..6331af4 100644 --- a/docs/commands/mate_init.md +++ b/docs/commands/mate_init.md @@ -33,6 +33,7 @@ mate init [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_managed.md b/docs/commands/mate_managed.md index 0debafb..b99a48f 100644 --- a/docs/commands/mate_managed.md +++ b/docs/commands/mate_managed.md @@ -39,6 +39,7 @@ mate managed [path] [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_packages.md b/docs/commands/mate_packages.md index 7481640..4f8b930 100644 --- a/docs/commands/mate_packages.md +++ b/docs/commands/mate_packages.md @@ -16,6 +16,7 @@ Declarative package management across package managers ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_packages_apply.md b/docs/commands/mate_packages_apply.md index df24c55..03c08d0 100644 --- a/docs/commands/mate_packages_apply.md +++ b/docs/commands/mate_packages_apply.md @@ -29,6 +29,7 @@ mate packages apply [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_packages_status.md b/docs/commands/mate_packages_status.md index 63d8afe..ee25a49 100644 --- a/docs/commands/mate_packages_status.md +++ b/docs/commands/mate_packages_status.md @@ -42,6 +42,7 @@ mate packages status [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_profile.md b/docs/commands/mate_profile.md index c7787c9..68f39ea 100644 --- a/docs/commands/mate_profile.md +++ b/docs/commands/mate_profile.md @@ -23,6 +23,7 @@ mate profile [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_rename.md b/docs/commands/mate_rename.md index 762b671..9ae0a17 100644 --- a/docs/commands/mate_rename.md +++ b/docs/commands/mate_rename.md @@ -42,6 +42,7 @@ mate rename [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_scripts.md b/docs/commands/mate_scripts.md index 4943b18..b942402 100644 --- a/docs/commands/mate_scripts.md +++ b/docs/commands/mate_scripts.md @@ -32,6 +32,7 @@ triggers it. Editing the script itself does not trigger it; use ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_scripts_list.md b/docs/commands/mate_scripts_list.md index 7c86a49..0ae49e5 100644 --- a/docs/commands/mate_scripts_list.md +++ b/docs/commands/mate_scripts_list.md @@ -20,6 +20,7 @@ mate scripts list [flags] ``` -c, --config string config file (default: mate.yaml in $STATEMATE_DIR if set, else in the local config's source_dir, else in the current directory) + --no-color disable colored output (also off when stdout is not a terminal or NO_COLOR is set) -p, --profile string override auto-detected profile ``` diff --git a/docs/commands/mate_scripts_run.md b/docs/commands/mate_scripts_run.md index e3b2a03..c7d1d2f 100644 --- a/docs/commands/mate_scripts_run.md +++ b/docs/commands/mate_scripts_run.md @@ -26,6 +26,7 @@ mate scripts run