@udondan/avanti 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +467 -66
  2. package/dist/commands/diff.d.ts.map +1 -1
  3. package/dist/commands/diff.js +75 -12
  4. package/dist/commands/diff.js.map +1 -1
  5. package/dist/commands/lock.d.ts.map +1 -1
  6. package/dist/commands/lock.js +3 -2
  7. package/dist/commands/lock.js.map +1 -1
  8. package/dist/commands/log.d.ts.map +1 -1
  9. package/dist/commands/log.js +7 -5
  10. package/dist/commands/log.js.map +1 -1
  11. package/dist/commands/pull.d.ts.map +1 -1
  12. package/dist/commands/pull.js +675 -36
  13. package/dist/commands/pull.js.map +1 -1
  14. package/dist/commands/reset.d.ts.map +1 -1
  15. package/dist/commands/reset.js +43 -11
  16. package/dist/commands/reset.js.map +1 -1
  17. package/dist/commands/revert.d.ts.map +1 -1
  18. package/dist/commands/revert.js +68 -14
  19. package/dist/commands/revert.js.map +1 -1
  20. package/dist/condition.d.ts.map +1 -1
  21. package/dist/condition.js +9 -6
  22. package/dist/condition.js.map +1 -1
  23. package/dist/config-writeback.d.ts.map +1 -1
  24. package/dist/config-writeback.js +17 -0
  25. package/dist/config-writeback.js.map +1 -1
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +244 -3
  28. package/dist/config.js.map +1 -1
  29. package/dist/diff.d.ts +19 -0
  30. package/dist/diff.d.ts.map +1 -1
  31. package/dist/diff.js +317 -12
  32. package/dist/diff.js.map +1 -1
  33. package/dist/extract.d.ts +4 -0
  34. package/dist/extract.d.ts.map +1 -0
  35. package/dist/extract.js +142 -0
  36. package/dist/extract.js.map +1 -0
  37. package/dist/filter.d.ts +3 -0
  38. package/dist/filter.d.ts.map +1 -0
  39. package/dist/filter.js +126 -0
  40. package/dist/filter.js.map +1 -0
  41. package/dist/history.d.ts +7 -1
  42. package/dist/history.d.ts.map +1 -1
  43. package/dist/history.js +89 -9
  44. package/dist/history.js.map +1 -1
  45. package/dist/paths.d.ts +4 -0
  46. package/dist/paths.d.ts.map +1 -1
  47. package/dist/paths.js +97 -0
  48. package/dist/paths.js.map +1 -1
  49. package/dist/processors/ini.d.ts +33 -0
  50. package/dist/processors/ini.d.ts.map +1 -0
  51. package/dist/processors/ini.js +500 -0
  52. package/dist/processors/ini.js.map +1 -0
  53. package/dist/processors/insert.d.ts.map +1 -1
  54. package/dist/processors/insert.js +338 -15
  55. package/dist/processors/insert.js.map +1 -1
  56. package/dist/processors/on.d.ts +4 -0
  57. package/dist/processors/on.d.ts.map +1 -0
  58. package/dist/processors/on.js +54 -0
  59. package/dist/processors/on.js.map +1 -0
  60. package/dist/ref.d.ts +21 -0
  61. package/dist/ref.d.ts.map +1 -0
  62. package/dist/ref.js +65 -0
  63. package/dist/ref.js.map +1 -0
  64. package/dist/sources/bitbucket.d.ts.map +1 -1
  65. package/dist/sources/bitbucket.js +51 -12
  66. package/dist/sources/bitbucket.js.map +1 -1
  67. package/dist/sources/git.d.ts.map +1 -1
  68. package/dist/sources/git.js +54 -6
  69. package/dist/sources/git.js.map +1 -1
  70. package/dist/sources/github.d.ts.map +1 -1
  71. package/dist/sources/github.js +188 -51
  72. package/dist/sources/github.js.map +1 -1
  73. package/dist/sources/gitlab.d.ts.map +1 -1
  74. package/dist/sources/gitlab.js +242 -44
  75. package/dist/sources/gitlab.js.map +1 -1
  76. package/dist/sources/index.d.ts +3 -1
  77. package/dist/sources/index.d.ts.map +1 -1
  78. package/dist/sources/index.js +170 -34
  79. package/dist/sources/index.js.map +1 -1
  80. package/dist/sources/local.d.ts +3 -0
  81. package/dist/sources/local.d.ts.map +1 -1
  82. package/dist/sources/local.js +44 -0
  83. package/dist/sources/local.js.map +1 -1
  84. package/dist/types.d.ts +38 -2
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js.map +1 -1
  87. package/dist/variables-remote.d.ts.map +1 -1
  88. package/dist/variables-remote.js +2 -1
  89. package/dist/variables-remote.js.map +1 -1
  90. package/dist/variables.d.ts +1 -0
  91. package/dist/variables.d.ts.map +1 -1
  92. package/dist/variables.js +37 -2
  93. package/dist/variables.js.map +1 -1
  94. package/dist/writer.d.ts +17 -0
  95. package/dist/writer.d.ts.map +1 -1
  96. package/dist/writer.js +848 -20
  97. package/dist/writer.js.map +1 -1
  98. package/package.json +11 -7
package/README.md CHANGED
@@ -16,28 +16,45 @@ atomic rollbacks, and diff-before-apply safety.
16
16
  - [`avanti diff`](#avanti-diff)
17
17
  - [`avanti pull`](#avanti-pull)
18
18
  - [`avanti lock`](#avanti-lock)
19
+ - [`--verbose` / `-v`](#--verbose---v)
19
20
  - [History](#history)
20
21
  - [`avanti log`](#avanti-log)
21
22
  - [`avanti diff <pullId>`](#avanti-diff-pullid)
22
23
  - [`avanti revert [pullId]`](#avanti-revert-pullid)
23
24
  - [`avanti reset`](#avanti-reset)
24
- - [`--verbose` / `-v`](#--verbose---v)
25
25
  - [Working Directory](#working-directory)
26
26
  - [Path Constraints](#path-constraints)
27
27
  - [Configuration](#configuration)
28
28
  - [File Entry Fields](#file-entry-fields)
29
29
  - [Source Types](#source-types)
30
+ - [SHA pinning](#sha-pinning)
31
+ - [Filter](#filter)
32
+ - [Extract](#extract)
30
33
  - [Directory Sources](#directory-sources)
31
34
  - [JSON Merging](#json-merging)
32
35
  - [YAML Merging](#yaml-merging)
33
36
  - [TOML Merging](#toml-merging)
37
+ - [INI Merging](#ini-merging)
34
38
  - [Template Rendering](#template-rendering)
39
+ - [Event Hooks](#event-hooks)
35
40
  - [Insert Mode](#insert-mode)
36
41
  - [Conditions](#conditions)
42
+ - [Condition fields](#condition-fields)
43
+ - [Examples](#examples)
37
44
  - [Scaffold Pattern](#scaffold-pattern)
38
45
  - [Backup](#backup)
46
+ - [Path variables](#path-variables)
47
+ - [Counter pattern](#counter-pattern)
48
+ - [Security: backup_roots](#security-backup_roots)
49
+ - [Backup examples](#backup-examples)
39
50
  - [Write in Place](#write-in-place)
51
+ - [Follow Symlink](#follow-symlink)
52
+ - [Sudo](#sudo)
40
53
  - [Variables](#variables)
54
+ - [List and object variables](#list-and-object-variables)
55
+ - [Accessing nested values with \${expr}](#accessing-nested-values-with-expr)
56
+ - [Source-based variables](#source-based-variables)
57
+ - [System-injected variables](#system-injected-variables)
41
58
  - [$self — Self-managing Config](#self--self-managing-config)
42
59
  - [Authentication](#authentication)
43
60
  - [Private Instances](#private-instances)
@@ -105,9 +122,10 @@ avanti revert # roll back instantly if something breaks
105
122
  - **JSON merging** — deep-merge multiple JSON/JSONC sources with configurable conflict, array, and object strategies; format output with configurable indentation, trailing commas, key sorting, minification, and comment stripping
106
123
  - **YAML merging** — deep-merge multiple YAML/YML sources with the same strategies, with full comment preservation
107
124
  - **TOML merging** — deep-merge multiple TOML sources with configurable conflict, array, and table strategies
125
+ - **INI merging** — deep-merge multiple INI/CFG sources with the same strategies, with full comment and key-order preservation
108
126
  - **Variables** — define reusable values in a `variables:` block and reference them anywhere with `$name`; variables can be plain strings, `$env:NAME` environment variable references, or fetched from any remote/local source (the same source types as `files:`)
109
127
  - **Post-processing** — apply text replacements (string or regex) and/or pipe content through a shell script
110
- - **Release artifacts** — download release assets attached to a GitHub or GitLab release by tag, or use `$latest` for the newest release (GitLab prefers `package`-type links; falls back to all links)
128
+ - **Release artifacts** — download release assets attached to a GitHub or GitLab release by tag, `$latest` (newest stable semver tag), `$recent` (most recently created/published tag), or `/pattern/[flags]` (GitLab prefers `package`-type links; falls back to all links)
111
129
  - **Directory sync** — recursively sync directories from GitLab/GitHub/Bitbucket/git/S3/local sources
112
130
  - **SHA pinning** — pin any remote source to a content fingerprint with `sha:`; use `avanti lock` to compute and write SHAs automatically; `avanti pull --accept-changes` reviews a mismatch and updates the pin
113
131
  - **`$self`** — avanti can manage its own config file; declare `$self` in `files:` and the fetched content becomes the active config for the rest of the run, including YAML/JSON merge from multiple sources
@@ -188,10 +206,30 @@ Run `avanti pull --accept-changes` to review the diff and update SHA values.
188
206
 
189
207
  `avanti diff` shows a `⚠ SHA mismatch` warning inline for any source that no longer matches its pinned SHA.
190
208
 
191
- SHA is computed over the raw fetched content of each source, before any `replace` or `post` processing. Each file's path and content are fed into the hash in sorted order, separated by null bytes — so renames and additions affect the fingerprint even for single-file sources. Pull history records the observed SHA for every source, so `avanti log` shows a full audit trail of what changed and when.
209
+ SHA is computed over the raw fetched content of each source, before any `replace` or `on.write` processing. Each file's path and content are fed into the hash in sorted order, separated by null bytes — so renames and additions affect the fingerprint even for single-file sources. Pull history records the observed SHA for every source, so `avanti log` shows a full audit trail of what changed and when.
192
210
 
193
211
  Excluded from SHA pinning: local paths and `raw:` sources (their content is either authored locally or inline in the config, so changes are always visible).
194
212
 
213
+ ### `--verbose` / `-v`
214
+
215
+ Pass `--verbose` (or `-v`) to any command to print internal debug details to stderr. Verbose output does not appear on stdout, so piping diff output is unaffected.
216
+
217
+ ```sh
218
+ avanti diff --verbose
219
+ avanti pull -v
220
+ ```
221
+
222
+ Each line is prefixed with `[verbose]` and includes:
223
+
224
+ - The source being fetched (e.g. `github:org/repo:file@main`)
225
+ - Every HTTP request URL and response status code
226
+ - Retry delays and reasons
227
+ - CLI tool invocations (`gh`, `glab`, `vault`, `git`)
228
+ - AWS SDK API calls (`s3 GetObject`, `ssm GetParameter`, `secrets-manager GetSecretValue`)
229
+ - Cache hits
230
+
231
+ **Credential safety:** tokens are read from environment variables and sent as HTTP headers, which are never logged. Git URLs with embedded credentials are redacted. `exec:` source commands are logged verbatim — if your config embeds secrets in an exec command (e.g. `exec: curl -H "Token: $env:MY_SECRET"`), those secrets will appear in verbose output after variable substitution.
232
+
195
233
  ## History
196
234
 
197
235
  Every successful `avanti pull` that writes at least one file is recorded in a local history store. This lets you inspect what changed, preview past states, revert the whole project, or fully undo all avanti changes.
@@ -287,31 +325,19 @@ Apply? [y/N]
287
325
 
288
326
  Use `--yes` to skip the prompt. The history log is preserved — you can still run `avanti log` after a reset.
289
327
 
290
- ### `--verbose` / `-v`
291
-
292
- Pass `--verbose` (or `-v`) to any command to print internal debug details to stderr. Verbose output does not appear on stdout, so piping diff output is unaffected.
293
-
294
- ```sh
295
- avanti diff --verbose
296
- avanti pull -v
297
- ```
298
-
299
- Each line is prefixed with `[verbose]` and includes:
300
-
301
- - The source being fetched (e.g. `github:org/repo:file@main`)
302
- - Every HTTP request URL and response status code
303
- - Retry delays and reasons
304
- - CLI tool invocations (`gh`, `glab`, `aws`, `vault`, `git`)
305
- - Cache hits
306
-
307
- **Credential safety:** tokens are read from environment variables and sent as HTTP headers, which are never logged. Git URLs with embedded credentials are redacted. `exec:` source commands are logged verbatim — if your config embeds secrets in an exec command (e.g. `exec: curl -H "Token: $env:MY_SECRET"`), those secrets will appear in verbose output after variable substitution.
308
-
309
328
  ## Working Directory
310
329
 
311
330
  All relative `src` and `target` paths are resolved relative to the **working directory** — the directory where you invoke `avanti`, or the path given with `-w`.
312
331
 
313
332
  This is independent of where the config file lives. A config loaded from another directory with `-c /shared/avanti.yml` still resolves all paths from your working directory (or the one you specify with `-w`).
314
333
 
334
+ The path given to `-w` supports tilde expansion: `~` resolves to the home directory and `~/some/path` resolves to a subdirectory of it:
335
+
336
+ ```sh
337
+ avanti -w ~ pull # home directory as working dir
338
+ avanti -w ~/projects/foo pull # subdirectory of home
339
+ ```
340
+
315
341
  Use `-w` to deploy the same config to multiple locations without `cd`-ing there first:
316
342
 
317
343
  ```sh
@@ -360,7 +386,8 @@ files:
360
386
  some-file.yml:
361
387
  src:
362
388
  exec: glab api "projects/group%2Fproject/repository/files/some-file.yaml/raw?ref=main"
363
- post: sed -e 's/v3/v4/g'
389
+ on:
390
+ write: sed -e 's/v3/v4/g'
364
391
 
365
392
  renovate.json:
366
393
  src:
@@ -407,19 +434,26 @@ A brace group is only expanded when it contains **at least one comma** (e.g. `{f
407
434
 
408
435
  > **YAML quoting:** YAML treats `{` at the start of a plain key as a flow mapping. If the brace group is the first character of a key, quote it: `'{dev,prod}.yml':` or `"{dev,prod}.yml":`. Keys where the brace group appears after a path prefix (e.g. `config/{dev,prod}.yml`) do not need quoting.
409
436
 
410
- | Field | Required | Description |
411
- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
412
- | `src` | Yes | Source (see below). May be a single source or a **list** of sources to concatenate. |
413
- | `if` | No | Condition object (or list of objects). All must pass for the entry to be processed. See [Conditions](#conditions). |
414
- | `ifAny` | No | List of condition objects. At least one must pass. See [Conditions](#conditions). |
415
- | `mode` | No | File permission mode. Use a quoted octal string (`"0755"`) or a YAML octal literal (`0o755`). Mode-only changes (content unchanged) are detected by `diff` and applied by `pull`. **POSIX only** — ignored on Windows. |
416
- | `replace` | No | List of `{from, to}` replacement rules. `from` may be a plain string or `/pattern/flags` regex. |
417
- | `post` | No | Shell script. Content is piped via stdin; stdout is used as the result. Runs after `replace`. |
418
- | `template` | No | Treat the fetched content as a template and render it with avanti config variables as context. See [Template Rendering](#template-rendering). |
419
- | `json` | No | JSON merge/format options (see below). When omitted, merging is auto-enabled if all sources have a `.json` or `.jsonc` extension. Use `true`/`false` to force on or off regardless of extension. |
420
- | `yaml` | No | YAML merge/format options (see below). When omitted, merging is auto-enabled if all sources have a `.yaml` or `.yml` extension. Use `true`/`false` to force on or off regardless of extension. Comments are preserved in merged output. |
421
- | `strategy` | No | Write strategy: `replace` _(default)_ overwrite the target file entirely; `insert` merge content into the existing file without clobbering unrelated content. See [Insert Mode](#insert-mode). |
422
- | `writeInPlace` | No | If `true`, replaces file content in-place instead of using an atomic rename. Preserves the existing inode. **Not atomic** use only when inode stability is required. See [Write in Place](#write-in-place). |
437
+ | Field | Required | Description |
438
+ | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
439
+ | `src` | Yes | Source (see below). May be a single source or a **list** of sources to concatenate. |
440
+ | `if` | No | Condition object (or list of objects). All must pass for the entry to be processed. See [Conditions](#conditions). |
441
+ | `ifAny` | No | List of condition objects. At least one must pass. See [Conditions](#conditions). |
442
+ | `mode` | No | File permission mode. Use a quoted octal string (`"0755"`) or a YAML octal literal (`0o755`). Mode-only changes (content unchanged) are detected by `diff` and applied by `pull`. **POSIX only** — ignored on Windows. |
443
+ | `backup` | No | Path to copy the current file to before overwriting it. Supports path variables (`$dirname`, `$filename`, `$datetime`) and the `%d+` counter token for auto-incrementing slots. See [Backup](#backup). |
444
+ | `replace` | No | List of `{from, to}` replacement rules. `from` may be a plain string or `/pattern/flags` regex. |
445
+ | `on` | No | Lifecycle event hooks. See [Event Hooks](#event-hooks). |
446
+ | `template` | No | Treat the fetched content as a template and render it with avanti config variables as context. See [Template Rendering](#template-rendering). |
447
+ | `json` | No | JSON merge/format options (see below). When omitted, merging is auto-enabled if all sources have a `.json` or `.jsonc` extension. Use `true`/`false` to force on or off regardless of extension. |
448
+ | `yaml` | No | YAML merge/format options (see below). When omitted, merging is auto-enabled if all sources have a `.yaml` or `.yml` extension. Use `true`/`false` to force on or off regardless of extension. Comments are preserved in merged output. |
449
+ | `toml` | No | TOML merge/format options (see below). When omitted, merging is auto-enabled if all sources have a `.toml` extension. Use `true`/`false` to force on or off regardless of extension. See [TOML Merging](#toml-merging). |
450
+ | `ini` | No | INI merge/format options (see below). When omitted, merging is auto-enabled if all sources have a `.ini` or `.cfg` extension. Use `true`/`false` to force on or off regardless of extension. Comments and key order are preserved. See [INI Merging](#ini-merging). |
451
+ | `strategy` | No | Write strategy: `replace` _(default)_ — overwrite the target file entirely; `insert` — merge content into the existing file without clobbering unrelated content. See [Insert Mode](#insert-mode). |
452
+ | `writeInPlace` | No | If `true`, replaces file content in-place instead of using an atomic rename. Preserves the existing inode. **Not atomic** — use only when inode stability is required. Errors if the target is a symlink. See [Write in Place](#write-in-place). |
453
+ | `followSymlink` | No | If `true` and the target path is a symlink, writes the fetched content to the **symlink's target** instead of replacing the symlink itself. The resolved target must not be a directory and must stay inside the working directory. See [Follow Symlink](#follow-symlink). |
454
+ | `symlink` | No | Create a symlink at the target path instead of writing file content. `src` must be a single local path. Use `true` or `"absolute"` to create an absolute symlink; use `"relative"` to express the symlink target as a path relative to the symlink's parent directory. Cannot be combined with `replace`, `template`, `json`, `yaml`, `toml`, `ini`, `on.write`, `extract`, `writeInPlace`, `strategy`, `followSymlink`, `mode`, or a list `src`. See [Symlink](#symlink). |
455
+ | `extract` | No | Unpack an archive (`.zip`, `.tar`, `.tar.gz`, `.tgz`) downloaded from a single-file source before writing. Target must end with `"/"`. Use `true` to extract all files, or a list of patterns to extract only matching entries. Cannot be combined with a list `src`. See [Extract](#extract). |
456
+ | `sudo` | No | Write the file using elevated privileges. Use `true` to write as root, or a username string (e.g. `"www-data"`) to write as a specific user via `sudo -u`. avanti authenticates once per distinct identity before any writes — the OS sudo credential cache is reused for all subsequent operations within the same pull session. **POSIX only** — `pull` errors on Windows when any file has `sudo` set. **Note:** `sudo` is honored by `pull` only (including stale-file cleanup). The `revert` and `reset` commands restore files using normal (non-elevated) file operations and will fail on root-owned paths. |
423
457
 
424
458
  ### Source Types
425
459
 
@@ -461,7 +495,7 @@ src:
461
495
  gitlab:
462
496
  project: group/repo # GitLab project path
463
497
  file: path/to/file.txt # file or directory in repo (mutually exclusive with release)
464
- ref: main # branch, tag, or $latest (optional)
498
+ ref: main # branch, tag, $latest, $recent, or /pattern/ (optional)
465
499
  sha: abc123... # optional SHA-256 fingerprint
466
500
  host: gitlab.mycompany.com # override default gitlab.com (optional)
467
501
  via: cli # api, cli, or list (default: [api, cli])
@@ -470,16 +504,19 @@ src:
470
504
  src:
471
505
  gitlab:
472
506
  project: group/repo # GitLab project path
473
- release: v1.2.3 # release tag, or $latest (mutually exclusive with file)
507
+ release: v1.2.3 # release tag, $latest, $recent, or /pattern/ (mutually exclusive with file)
474
508
  sha: abc123... # optional SHA-256 fingerprint
475
509
  host: gitlab.mycompany.com # override default gitlab.com (optional)
476
510
  via: cli # api, cli, or list (default: [api, cli])
511
+ filter: # optional: keep only matching assets (see below)
512
+ - installer.deb
513
+ - checksums-{amd64,arm64}.txt
477
514
 
478
515
  src:
479
516
  github:
480
517
  repo: owner/repo # GitHub owner/repo
481
518
  file: path/to/file.txt # file or directory in repo (mutually exclusive with release)
482
- ref: main # branch, tag, or $latest (optional)
519
+ ref: main # branch, tag, $latest, $recent, or /pattern/ (optional)
483
520
  sha: abc123... # optional SHA-256 fingerprint
484
521
  host: github.mycompany.com # GitHub Enterprise Server hostname (optional)
485
522
  via: cli # api, cli, or list (default: [api, cli])
@@ -488,10 +525,14 @@ src:
488
525
  src:
489
526
  github:
490
527
  repo: owner/repo # GitHub owner/repo
491
- release: v1.2.3 # release tag, or $latest (mutually exclusive with file)
528
+ release: v1.2.3 # release tag, $latest, $recent, or /pattern/ (mutually exclusive with file)
492
529
  sha: abc123... # optional SHA-256 fingerprint
493
530
  host: github.mycompany.com # GitHub Enterprise Server hostname (optional)
494
531
  via: cli # api, cli, or list (default: [api, cli])
532
+ filter: # optional: keep only matching assets (see below)
533
+ - exact-match.png
534
+ - file-{a,b,c}.yml
535
+ - /^some.*\.jpg/
495
536
 
496
537
  src:
497
538
  bitbucket:
@@ -547,6 +588,79 @@ The optional `sha` field pins a source to a specific content fingerprint. When p
547
588
 
548
589
  Use `avanti lock` to compute and write SHA values automatically. Use `avanti pull --accept-changes` to review a mismatch and update the pinned SHA. Plain string sources (a bare local path or URL string) and `raw:` sources do not support `sha`. Use the explicit `path:` or `url:` map form to pin a local file or HTTP URL.
549
590
 
591
+ #### Filter
592
+
593
+ The optional `filter` field narrows which files are kept when a source returns multiple files (directory sources, release artifacts, S3 prefixes). It is supported on `path:`, `github:`, `gitlab:`, `bitbucket:`, `git:`, and `aws_s3:` sources.
594
+
595
+ `filter` is a list of one or more patterns. A file is kept if **any** pattern matches its path relative to the source root (the filename for flat sources like release assets, or the relative path for directory sources). Paths are always matched using forward slashes (`/`) regardless of the platform — on Windows, write `subdir/file.yml`, not `subdir\file.yml`.
596
+
597
+ | Pattern | Matches |
598
+ | ------------------ | -------------------------------------------------------------------------------------- |
599
+ | `exact.png` | Exact string equality |
600
+ | `subdir/` | Directory prefix — all entries whose path starts with `subdir/` |
601
+ | `file-{a,b,c}.yml` | Brace-expanded alternatives — `file-a.yml`, `file-b.yml`, `file-c.yml` |
602
+ | `/^some.*\.jpg/` | JavaScript regular expression (delimited by `/`) tested against the full relative path |
603
+
604
+ ```yaml
605
+ files:
606
+ assets/:
607
+ src:
608
+ github:
609
+ repo: owner/repo
610
+ release: $latest
611
+ filter:
612
+ - exact-match.png
613
+ - dist/ # all files under dist/
614
+ - checksums-{amd64,arm64}.txt
615
+ - /^some.*\.jpg/
616
+ ```
617
+
618
+ Variables are resolved in filter patterns before matching, so patterns like `$env:ARCH.tar.gz` or `$platform-release.zip` work as expected. An error is raised if the filter matches zero files, preventing silent misconfiguration. The `sha` fingerprint (if present) is computed over the filtered set.
619
+
620
+ > **Note:** brace expansion is not supported in directory-prefix patterns (patterns ending with `/`). Use separate patterns instead — e.g. `"core/"` and `"utils/"` rather than `"{core,utils}/"`.
621
+
622
+ #### Extract
623
+
624
+ The optional `extract` field unpacks a downloaded archive before writing files. It applies to any single-file source (HTTP URL, local path, etc.) that returns an archive. Set `extract: true` to extract all entries, or provide a list of patterns to keep only matching entries.
625
+
626
+ **The target must be a directory** (end with `/`). Archive extraction writes multiple files; a non-directory target is rejected at parse time.
627
+
628
+ | Format | Extensions |
629
+ | ---------- | ----------------- |
630
+ | ZIP | `.zip` |
631
+ | tar | `.tar` |
632
+ | tar + gzip | `.tar.gz`, `.tgz` |
633
+
634
+ Patterns use the same syntax as [`filter`](#filter):
635
+
636
+ | Pattern | Matches |
637
+ | ------------- | -------------------------------------------------------------------------------------- |
638
+ | `exact.png` | Exact string equality |
639
+ | `subdir/` | Directory prefix — all entries whose path starts with `subdir/` |
640
+ | `{a,b,c}.yml` | Brace-expanded alternatives |
641
+ | `/^.*\.jpg/` | JavaScript regular expression (delimited by `/`) tested against the full relative path |
642
+
643
+ > **Note:** brace expansion is not supported in directory-prefix patterns (patterns ending with `/`). Use separate patterns instead — e.g. `"core/"` and `"utils/"` rather than `"{core,utils}/"`.
644
+
645
+ ```yaml
646
+ files:
647
+ # Extract all files from a release archive into a local directory
648
+ tools/:
649
+ src: https://example.com/release.tar.gz
650
+ extract: true
651
+
652
+ # Extract only matching entries
653
+ assets/:
654
+ src: https://example.com/bundle.zip
655
+ extract:
656
+ - readme.md # exact match
657
+ - images/ # all entries under images/
658
+ - libs/{core,utils}.js # brace expansion (not with trailing /)
659
+ - /^assets\/.*\.png$/ # regex
660
+ ```
661
+
662
+ Variables are resolved in patterns before matching. An error is raised if the pattern list matches zero entries. `extract` cannot be combined with a list `src`. Entry paths are validated — archives containing path-traversal sequences (`../`) or absolute paths are rejected for security. The `sha` fingerprint (if present) is computed over the archive before extraction.
663
+
550
664
  ### Directory Sources
551
665
 
552
666
  Any source type that references a path (local, GitLab, GitHub, Bitbucket, git, S3) can point to a directory instead of a single file. End the path with `/` to declare it a directory explicitly; without a trailing slash the tool probes the remote to decide.
@@ -637,7 +751,7 @@ files:
637
751
  ref: main
638
752
  ```
639
753
 
640
- Sources are fetched in order and joined with a newline. Post-processing (`replace`, `post`) is applied to the combined result. If any source fails, the entire entry is aborted.
754
+ Sources are fetched in order and joined with a newline. Post-processing (`replace`, `on.write`) is applied to the combined result. If any source fails, the entire entry is aborted.
641
755
 
642
756
  ### JSON Merging
643
757
 
@@ -844,11 +958,111 @@ files:
844
958
  src: ./config.toml
845
959
  ```
846
960
 
961
+ ### INI Merging
962
+
963
+ When all sources in a list have a `.ini` or `.cfg` extension, INI merging is enabled
964
+ automatically — no extra config needed:
965
+
966
+ ```yaml
967
+ files:
968
+ merged.ini:
969
+ src:
970
+ - ./defaults.ini
971
+ - ./overrides.ini
972
+ ```
973
+
974
+ To merge sources that don't have an INI extension (e.g. `exec:`, `raw:`, or a URL without `.ini`), set `ini: true`:
975
+
976
+ ```yaml
977
+ files:
978
+ merged.ini:
979
+ src:
980
+ - exec: cat defaults.ini
981
+ - ./overrides.ini
982
+ ini: true
983
+ ```
984
+
985
+ To opt out of auto-detection and force plain concatenation, set `ini: false`.
986
+
987
+ **Fine-grained options** — pass an object to control merge behavior:
988
+
989
+ ```yaml
990
+ files:
991
+ merged.ini:
992
+ src:
993
+ - ./defaults.ini
994
+ - github:
995
+ repo: org/configs
996
+ file: overrides.ini
997
+ ini:
998
+ conflicts: last_wins # abort | first_wins | last_wins (default)
999
+ arrays: replace # replace (default) | concat | dedupe
1000
+ objects: merge # merge (default) | replace
1001
+ ```
1002
+
1003
+ The options behave identically to JSON, YAML, and TOML merging:
1004
+
1005
+ - `conflicts` — what to do when the same key holds a scalar (or an array/object when their strategy is `replace`):
1006
+ - `last_wins` _(default)_ — the last source's value wins
1007
+ - `first_wins` — the first source's value is kept
1008
+ - `abort` — throw an error (identical values are not considered a conflict)
1009
+ - `arrays` — how to combine arrays at the same key (written as `key[] = val` in INI):
1010
+ - `replace` _(default)_ — the later source's array replaces the earlier one
1011
+ - `concat` — arrays are concatenated (no deduplication)
1012
+ - `dedupe` — items from the later source are appended only if not already present
1013
+ - `objects` — how to combine sections at the same name:
1014
+ - `merge` _(default)_ — deep merge, applying the same rules recursively to section keys
1015
+ - `replace` — the later source's section replaces the earlier one entirely
1016
+
1017
+ **Comments and key order are preserved in the base (first) source.** The INI processor uses
1018
+ a line-level AST, so comment lines, inline comments, and blank lines from the first source
1019
+ are preserved through the merge. Minor whitespace normalization may occur (e.g. a single
1020
+ space is inserted before inline comments, and spacing around `=` follows the base key's
1021
+ original separator). When a key's value is updated by a later source, the base key's inline
1022
+ comment is kept and the key stays at its original position — it is not shuffled to the end.
1023
+ **Comment behavior under `objects: merge` (default):** Comment lines (`; ...` / `# ...`),
1024
+ blank lines, and section header comments from overlay sources are not transferred when merging
1025
+ individual keys. For keys that already exist in the base, the base key's inline comment is
1026
+ kept. For new keys introduced only by the overlay, their inline comments are preserved (there
1027
+ is no base inline comment to fall back on). When a new section is introduced by the overlay
1028
+ (one that does not exist in the base), it is inserted without its section header comment or
1029
+ any internal comment/blank nodes — only its key-value pairs are carried over.
1030
+
1031
+ **Comment behavior under `objects: replace`:** When the overlay section's key-value content
1032
+ differs from the base, the entire overlay section is used as-is — including its section
1033
+ header comment, internal comment lines, blank lines, and inline comments. If the two sections
1034
+ have identical key-value content (even when they differ only in comments or whitespace), no
1035
+ replacement occurs — the base section is kept unchanged.
1036
+
1037
+ **Inline comment limitation for arrays:** When the same key appears as multiple `key[] = val`
1038
+ entries, all values are coalesced into a single array node. Only the inline comment from the
1039
+ _first_ occurrence (if any) is preserved; inline comments on subsequent `key[] = val` lines are
1040
+ discarded.
1041
+
1042
+ **Supported INI features:** sections (`[section]`), subsections (`[section "name"]`),
1043
+ `key = value` pairs, bare keys, quoted values (`"..."` / `'...'`), comment lines (`;` and `#`),
1044
+ inline comments, blank lines, backslash line continuation, and arrays via `key[] = val`. All
1045
+ `key[] = val` entries for the same key are collected into one array regardless of position in
1046
+ the file; non-contiguous entries are normalized to appear at the first occurrence of that key.
1047
+
1048
+ **Value coercion:** Unquoted values that match `true` or `false` (case-insensitive) are parsed
1049
+ as booleans, and values that parse as a valid number are parsed as numbers. This means
1050
+ `enabled = true` is stored as a boolean and `port = 8080` as a number; on format or merge
1051
+ these are re-serialised as `true` / `8080` respectively. Strings like `001` are normalised to
1052
+ `1`. To preserve the exact string form, quote the value: `port = "8080"`.
1053
+
1054
+ **Inline comment delimiter:** `;` and `#` are treated as comment delimiters when they appear
1055
+ outside of quoted strings. If a value contains a literal `;` or `#` character, quote the value
1056
+ (e.g. `url = "https://example.com#anchor"`) to prevent it from being interpreted as a comment.
1057
+
1058
+ **Pretty-printing a single file** — `ini` works on single-source entries too. Auto-detection
1059
+ applies here as well, so a single `.ini` or `.cfg` source is normalized automatically.
1060
+
847
1061
  ### Template Rendering
848
1062
 
849
1063
  Set `template` to treat the fetched content as a template. avanti renders it at deploy time using all avanti config variables as the template context, then writes the rendered output to the target file.
850
1064
 
851
- > **Security note** — EJS and Eta templates execute arbitrary JavaScript at render time. Handlebars, Nunjucks, Liquid, and Mustache are logic-limited and do not execute raw JS. For any engine, template sources must be trusted: either authored locally, fetched from a controlled internal source, or SHA-pinned (see [`sha:`](#sha-pinning)). Treat a compromised remote template as equivalent to a compromised `post:` script or `exec:` source.
1065
+ > **Security note** — EJS and Eta templates execute arbitrary JavaScript at render time. Handlebars, Nunjucks, Liquid, and Mustache are logic-limited and do not execute raw JS. For any engine, template sources must be trusted: either authored locally, fetched from a controlled internal source, or SHA-pinned (see [`sha:`](#sha-pinning)). Treat a compromised remote template as equivalent to a compromised `on.write` script or `exec:` source.
852
1066
 
853
1067
  ```yaml
854
1068
  variables:
@@ -889,7 +1103,42 @@ For multi-source arrays (`src: [a, b, c]`) and directory-to-single-file merges,
889
1103
 
890
1104
  **`jinja2` alias** — `template: jinja2` is equivalent to `template: nunjucks`. Nunjucks is a JavaScript implementation heavily inspired by Jinja2; most Jinja2 templates work without changes.
891
1105
 
892
- **Pipeline order** — template rendering runs first, before `replace` and `post`. Subsequent processors receive the already-rendered content.
1106
+ **Pipeline order** — template rendering runs first, before `replace` and `on.write`. Subsequent processors receive the already-rendered content.
1107
+
1108
+ ### Event Hooks
1109
+
1110
+ The `on:` field on a file entry lets you run shell commands at specific points in the file lifecycle. `on.write` supports avanti variable substitution (same rules as `exec:` and `replace:`). Side-effect hooks (`before*`, `create`, `update`) are passed to the shell verbatim — use `$AVANTI_TARGET` and `$AVANTI_IS_NEW` as environment variables.
1111
+
1112
+ | Hook | When | Content transform? |
1113
+ | ----------------- | ------------------------------------------------------------------------------- | ------------------ |
1114
+ | `on.write` | During processing, after `replace`; stdin → stdout replaces content | Yes |
1115
+ | `on.beforeWrite` | After user confirms, before writing — fires for every changed file | No |
1116
+ | `on.beforeCreate` | Same timing, but only when the file is being **created** for the first time | No |
1117
+ | `on.beforeUpdate` | Same timing, but only when the file already **exists** and has changed | No |
1118
+ | `on.create` | After the file has been successfully written — new files only | No |
1119
+ | `on.update` | After the file has been successfully written — existing files with changes only | No |
1120
+
1121
+ Side-effect hooks (`before*`, `create`, `update`) receive two environment variables:
1122
+
1123
+ | Variable | Value |
1124
+ | --------------- | ----------------------------------------------------------- |
1125
+ | `AVANTI_TARGET` | Absolute path of the target file |
1126
+ | `AVANTI_IS_NEW` | `"true"` if the file is being created; `"false"` if updated |
1127
+
1128
+ On Unix, access them as `$AVANTI_TARGET` / `$AVANTI_IS_NEW`. On Windows (PowerShell), avanti automatically injects a prelude that maps these to local variables, so `$AVANTI_TARGET` works identically — you do not need to write `$env:AVANTI_TARGET`.
1129
+
1130
+ Only hooks for files with **actual changes** are fired (`create`/`update`/`before*` are silent no-ops when content and mode are unchanged).
1131
+
1132
+ ```yaml
1133
+ files:
1134
+ config/app.yml:
1135
+ src: https://config.example.com/app.yml
1136
+ on:
1137
+ write: sed -e 's/v3/v4/g' # transform content
1138
+ beforeCreate: mkdir -p "$(dirname "$AVANTI_TARGET")"
1139
+ create: echo "Created $AVANTI_TARGET"
1140
+ update: git add "$AVANTI_TARGET"
1141
+ ```
893
1142
 
894
1143
  ### Insert Mode
895
1144
 
@@ -906,7 +1155,7 @@ files:
906
1155
  **How it works:**
907
1156
 
908
1157
  - **First run** — the fetched content is merged into the existing file (or written as-is if the file does not exist yet).
909
- - **Subsequent runs (no-op)** — avanti detects that the raw source and the post-processed output (`replace`/`post`) are both unchanged and skips the file entirely.
1158
+ - **Subsequent runs (no-op)** — avanti detects that the raw source and the post-processed output (`replace`/`on.write`) are both unchanged and skips the file entirely.
910
1159
  - **Subsequent runs (source changed)** — avanti removes the keys/text it previously contributed, then merges the updated content in.
911
1160
  - **User edits are preserved** — keys or text the user added or modified are left untouched. If a user overrides an avanti-managed key, avanti will not remove it even if the source no longer includes it.
912
1161
 
@@ -944,13 +1193,13 @@ When both are present, both must pass. Each condition object may also include `n
944
1193
 
945
1194
  #### Condition fields
946
1195
 
947
- | Field | Type | Description |
948
- | --------------- | -------------- | ---------------------------------------------------------------------------------- |
949
- | `os` | string or list | Platform must match. Values: `linux`, `mac`, `windows`. List = any matches. |
950
- | `exists` | string | Path (file or directory) must exist. Variables are resolved. |
951
- | `exec` | string | Shell command must exit with code `0`. |
952
- | `target_exists` | boolean | `true` — pass only if target exists. `false` — pass only if target does not exist. |
953
- | `not` | boolean | `true` — invert the result of all checks in this condition object. |
1196
+ | Field | Type | Description |
1197
+ | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
1198
+ | `os` | string or list | Platform must match. Values: `linux`, `mac`, `windows`. Aliases: `darwin` (= `mac`), `win32` (= `windows`). List = any matches. |
1199
+ | `exists` | string | Path (file or directory) must exist. Variables are resolved. |
1200
+ | `exec` | string | Shell command must exit with code `0`. |
1201
+ | `target_exists` | boolean | `true` — pass only if target exists. `false` — pass only if target does not exist. |
1202
+ | `not` | boolean | `true` — invert the result of all checks in this condition object. |
954
1203
 
955
1204
  #### Examples
956
1205
 
@@ -1063,11 +1312,11 @@ files:
1063
1312
  backup: $dirname/$filename.bkp
1064
1313
  ```
1065
1314
 
1066
- Backup only happens when the target is a regular file (not a symlink or directory). If the backup path already exists it is overwritten — use the [counter pattern](#counter-pattern) or `$datetime` when you want to keep every backup.
1315
+ Backup happens when the target path currently holds a regular file or a symlink — regardless of whether the entry being written is a regular file or a symlink entry. On POSIX, if the existing target is a symlink, the symlink itself (not the file it points to) is preserved in the backup; if the symlink had a relative target, avanti rewrites it to an absolute path so the backup symlink resolves correctly from the backup directory. On Windows, symlink backups are skipped (a warning is printed) because creating a symlink backup requires elevated privileges and dereferencing the link could read files outside the working directory. **Exception:** for regular-file entries with `sudo: true`, only existing regular files are backed up — if the current target is a symlink it is not backed up. For `symlink:` entries with `sudo: true`, the existing symlink (or regular file) at the target path is backed up before being replaced. Directory targets are never backed up. If the backup path already exists it is overwritten — use the [counter pattern](#counter-pattern) or `$datetime` when you want to keep every backup.
1067
1316
 
1068
1317
  #### Path variables
1069
1318
 
1070
- All [system-injected variables](#system-injected-variables) — per-file path variables (`$path`, `$filename`, `$basename`, `$ext`, `$dirname`, `$basedir`) and pull-time variables (`$date`, `$datetime`) — are available in `backup:` patterns.
1319
+ All [system-injected variables](#system-injected-variables) — per-file path variables (`$path`, `$filename`, `$basename`, `$ext`, `$dirname`, `$basedir`), pull-time variables (`$date`, `$datetime`), and system variables (`$os`, `$arch`, `$arch_go`) — are available in `backup:` patterns.
1071
1320
 
1072
1321
  #### Counter pattern
1073
1322
 
@@ -1149,6 +1398,111 @@ files:
1149
1398
 
1150
1399
  > **Warning:** `writeInPlace: true` is **not atomic**. Between the truncate and the completed write, a concurrent reader will see empty or partial content. Use the default atomic rename when correctness under concurrent reads matters more than inode stability.
1151
1400
 
1401
+ ### Follow Symlink
1402
+
1403
+ When the target path is a symlink, avanti's default atomic-rename strategy replaces the symlink itself — the symlink is overwritten with the fetched file content. Set `followSymlink: true` to write through the symlink instead: avanti resolves the symlink chain to its real path and writes the content there, leaving the symlink pointer intact.
1404
+
1405
+ > **Interaction with `writeInPlace`:** `writeInPlace: true` errors when its target path is a symlink. When combined with `followSymlink: true`, avanti resolves the symlink first and passes the real path to the writer, so `writeInPlace` never sees a symlink. The result: content is written in-place to the real file (inode preserved) while the symlink itself remains intact.
1406
+
1407
+ ```yaml
1408
+ files:
1409
+ config/settings.json:
1410
+ src:
1411
+ github:
1412
+ repo: org/shared-configs
1413
+ file: settings.json
1414
+ followSymlink: true
1415
+ ```
1416
+
1417
+ This is useful when a symlink is managed by another tool (e.g. a dotfile manager) and must not be replaced. avanti updates the real file's content while the symlink pointer remains unchanged.
1418
+
1419
+ **Safety constraints** — avanti validates the resolved target before writing:
1420
+
1421
+ - The resolved path must **not be a directory** — avanti refuses to write file content over a directory target.
1422
+ - The resolved path must remain **inside the working directory** — symlinks that escape (directly or through intermediate symlinked directories) are rejected.
1423
+ - **Circular symlinks** are detected and rejected.
1424
+
1425
+ Dangling symlinks (a symlink chain whose endpoint does not yet exist) are supported: avanti follows the chain to the non-existent endpoint and creates the file there, subject to the same directory and working-directory constraints above.
1426
+
1427
+ When the target path does not exist yet, or does not point to a symlink, `followSymlink` has no effect and the file is created or written normally.
1428
+
1429
+ ### Symlink
1430
+
1431
+ Set `symlink: true` (or `symlink: "absolute"`) to create a filesystem symlink at the target path pointing to the `src` path, instead of copying the file's content. The `src` must be a single local path — remote sources (HTTP, GitHub, GitLab, exec, etc.) are not supported.
1432
+
1433
+ ```yaml
1434
+ files:
1435
+ ~/.config/app/config.yml:
1436
+ src: /opt/app/defaults/config.yml
1437
+ symlink: true
1438
+ ```
1439
+
1440
+ Use `symlink: "relative"` to store the symlink target as a path relative to the symlink's parent directory. This is useful when you want symlinks that remain valid after the directory tree is moved or mounted at a different location:
1441
+
1442
+ ```yaml
1443
+ files:
1444
+ configs/active:
1445
+ src: configs/production.yml
1446
+ symlink: relative # symlink points to production.yml rather than an absolute path
1447
+ ```
1448
+
1449
+ **How `diff` and `pull` handle symlinks:**
1450
+
1451
+ - No symlink at the target path → create it (shown as a new entry in the diff)
1452
+ - Symlink already points to the correct target → no-op (no diff output, exit 0)
1453
+ - Symlink points to a different target → update it (diff shows old → new target)
1454
+ - A regular file exists at the target path → replace it with a symlink (shown in diff)
1455
+
1456
+ The symlink itself is tracked in avanti's history, so `revert` and `reset` restore the symlink (including its target path) to the state recorded at the referenced pull.
1457
+
1458
+ **Constraints** — `symlink` cannot be combined with:
1459
+
1460
+ - `replace`, `template`, `json`, `yaml`, `toml`, `ini`, `on.write`, `extract` — content processors are meaningless for a symlink
1461
+ - `writeInPlace`, `strategy`, `followSymlink` — incompatible write strategies
1462
+ - `mode` — symlinks do not have independent permission bits on POSIX
1463
+ - A list `src` — a symlink has exactly one target
1464
+ - `src.sha`, `src.filter`, `src.if`, `src.ifAny` — object-form src options that only apply to fetched content
1465
+ - The `$self` key — the config file itself cannot be a symlink entry
1466
+
1467
+ **POSIX only** — symlink entries are not supported on Windows. `pull` will error if a symlink entry is reached on win32. Use an `if: { os: [linux, mac] }` condition to gate symlink entries in cross-platform configs.
1468
+
1469
+ ### Sudo
1470
+
1471
+ Set `sudo: true` to write a file using elevated privileges (as root). Set `sudo: "username"` to write as a specific user via `sudo -u`:
1472
+
1473
+ ```yaml
1474
+ files:
1475
+ /etc/ssh/sshd_config:
1476
+ src:
1477
+ github:
1478
+ repo: org/system-configs
1479
+ file: sshd_config
1480
+ mode: '0600'
1481
+ sudo: true
1482
+
1483
+ /var/www/html/index.html:
1484
+ src: https://example.com/index.html
1485
+ sudo: 'www-data'
1486
+ ```
1487
+
1488
+ Absolute target paths (e.g. `/etc/ssh/sshd_config`) require `--working-dir /`. The map key is the target path:
1489
+
1490
+ ```sh
1491
+ avanti pull --working-dir /
1492
+ ```
1493
+
1494
+ avanti calls `sudo -v` once per distinct identity to prime the OS credential cache, so the user is prompted for their password at most once per distinct sudo identity per pull session. Authentication happens as early as needed: if any sudo target is unreadable by the current user (e.g. a root-owned file), avanti authenticates before reading that file to compare it for the diff — so a password prompt may appear before the diff is displayed or the user is asked to confirm. For files that are already readable, authentication is deferred until just before the write phase.
1495
+
1496
+ **Stale-file cleanup** — when a `sudo` entry is removed from the config, avanti uses the stored sudo identity to restore or delete the file during the next pull.
1497
+
1498
+ **POSIX only** — `pull` errors on Windows when any file has `sudo` set. Use an `if: { os: [linux, mac] }` condition to gate `sudo` entries in cross-platform configs.
1499
+
1500
+ **Limitations:**
1501
+
1502
+ - `sudo` is honored by `pull` only. The `revert` and `reset` commands use normal file operations and will fail on root-owned paths.
1503
+ - `strategy: insert` cannot be combined with `sudo` — insert mode reads the existing file without privilege escalation, which silently treats an unreadable privileged file as absent. avanti rejects this combination at config parse time.
1504
+ - `backup` paths for `sudo` entries are resolved before privilege escalation. The backup path's parent directories must be stat-able by the current user (all ancestor directories need the execute/search bit set). A 0755 root-owned backup directory works fine; a 0700 root-owned directory does not, because `lstat` cannot traverse it to resolve `%d` counters or verify the path.
1505
+
1152
1506
  ### Variables
1153
1507
 
1154
1508
  Define reusable values at the top level under `variables:`:
@@ -1174,11 +1528,11 @@ files:
1174
1528
  to: $email # resolved to "you@example.com"
1175
1529
  ```
1176
1530
 
1177
- Variables are resolved in every string field: target keys, `ref`, `exec` commands, HTTP URLs, local paths, `raw` content, `replace` rules (`from` and `to`), and `post` scripts.
1531
+ Variables are resolved in every string field: target keys, `ref`, `exec` commands, HTTP URLs, local paths, `raw` content, `filter` patterns, `replace` rules (`from` and `to`), and `on.write` scripts. Side-effect hooks (`on.beforeWrite`, `on.beforeCreate`, `on.beforeUpdate`, `on.create`, `on.update`) are passed to the shell verbatim — use `$AVANTI_TARGET` / `$AVANTI_IS_NEW` env vars instead of `$varname` substitutions.
1178
1532
 
1179
1533
  For `raw:` sources, variables are resolved in the content itself. For all other source types (`http`, `local`, `github`, `gitlab`, `exec`), variables are only resolved in the fields that locate the source (URL, path, command) — not in the fetched content. Use a `replace:` rule if you need to substitute values in fetched content.
1180
1534
 
1181
- **Shell safety in `exec:` and `post:`** — when a variable is substituted into an `exec:` command or a `post:` script, its value is automatically single-quoted. This means shell metacharacters (`;`, `&&`, `$(...)`, etc.) in the value are treated as literal data and are never executed. The surrounding command template itself is not quoted, so the static shell syntax you write is executed as usual. On Unix the script runs via `sh -c`; on Windows it runs via PowerShell (`-EncodedCommand`).
1535
+ **Shell safety in `exec:` and `on.write`** — when a variable is substituted into an `exec:` command or an `on.write` hook script, its value is automatically single-quoted. This means shell metacharacters (`;`, `&&`, `$(...)`, etc.) in the value are treated as literal data and are never executed. The surrounding command template itself is not quoted, so the static shell syntax you write is executed as usual. On Unix the script runs via `sh -c`; on Windows it runs via PowerShell (`-EncodedCommand`).
1182
1536
 
1183
1537
  ```yaml
1184
1538
  variables:
@@ -1188,10 +1542,11 @@ files:
1188
1542
  data.json:
1189
1543
  src:
1190
1544
  exec: curl https://example.com/api/$version/data # expands to: curl …/'1.0'/data
1191
- post: sed 's/$version/replaced/g' # expands to: sed 's/'\''1.0'\''/replaced/g'
1545
+ on:
1546
+ write: sed 's/$version/replaced/g' # expands to: sed 's/'\''1.0'\''/replaced/g'
1192
1547
  ```
1193
1548
 
1194
- **Escaping a literal `$`** — use `$$` to emit a literal `$` that is not treated as a variable reference. This is useful in `exec:` and `post:` scripts that contain shell or PowerShell syntax with `$`-prefixed identifiers (e.g. PowerShell built-ins like `$$true` or `$$null`):
1549
+ **Escaping a literal `$`** — use `$$` to emit a literal `$` that is not treated as a variable reference. This is useful in `exec:` commands and `on.write` hook scripts that contain shell or PowerShell syntax with `$`-prefixed identifiers (e.g. PowerShell built-ins like `$$true` or `$$null`):
1195
1550
 
1196
1551
  ```yaml
1197
1552
  files:
@@ -1199,7 +1554,8 @@ files:
1199
1554
  src:
1200
1555
  # On Windows exec: runs in PowerShell — $true is a PS built-in, needs $$
1201
1556
  exec: "if ($$true) { Write-Output 'yes' }"
1202
- post: sed 's/$$HOME/redacted/g' # $HOME would be treated as an avanti variable
1557
+ on:
1558
+ write: sed 's/$$HOME/redacted/g' # $HOME would be treated as an avanti variable
1203
1559
  ```
1204
1560
 
1205
1561
  `$$` produces a single `$` after substitution. `$$$name` produces `$` followed by the value of `name`. `$${expr}` produces a literal `${expr}` — use this to include shell-style `${VAR}` or template placeholders verbatim in a string without avanti interpreting them.
@@ -1326,11 +1682,25 @@ variables:
1326
1682
  registry_line: //$host/:_authToken=$token # both $host and $token are available
1327
1683
  ```
1328
1684
 
1329
- `$latest` is a reserved keyword that resolves to the latest published version and cannot be used as a variable name. For GitLab it resolves to the latest tag sorted by semantic version (for `file:` sources) or the latest release (for `release:` sources, falling back to tags on older instances). For GitHub it resolves to the tag of the latest release (for both `file:` and `release:` sources); if the repository has no releases, it falls back to the most recently created tag. For Bitbucket it resolves to the latest tag sorted by name; if no tags exist, it falls back to the repository's default branch.
1685
+ The `ref` (and `release`) field accepts four forms:
1686
+
1687
+ - **Literal** — a branch name, tag, or commit hash passed directly to the VCS (e.g. `main`, `v1.2.3`, `abc123`).
1688
+ - **`$latest`** — resolves to the newest **stable semver tag** (`vX.Y.Z` or `X.Y.Z`, no pre-release suffix), consistently across all providers. GitHub first checks the published "latest release" and accepts it when it is semver; all providers scan tags filtered by the semver pattern.
1689
+ - **`$recent`** — resolves to the most **recently created or published tag**, regardless of its name format. Use this when you want whatever was tagged last, even if it is a nightly or pre-release build. For `git:` remotes the ordering is determined by `git ls-remote` output rather than creation date (date-based ordering requires fetching tag objects and is not supported).
1690
+ - **`/pattern/[flags]`** — a regex pattern of the form `/body/` or `/body/flags` (e.g. `ref: /^v1\.\d+\.\d+$/`). Resolves to the first tag whose name matches the pattern, ordered newest-first on GitHub, GitLab, and Bitbucket. For `git:` remotes the match order follows `git ls-remote` output. Flags such as `i` are supported. Note: the pattern body must be non-empty — `//` is treated as a literal ref, not a match-all regex. The stateful flags `g` and `y` are silently stripped; any other unrecognised flag produces an error.
1691
+
1692
+ | Form | Meaning |
1693
+ | -------------- | ------------------------------------------------ |
1694
+ | `ref: $latest` | Newest `vX.Y.Z` / `X.Y.Z` stable tag |
1695
+ | `ref: $recent` | Most recently created/published tag (any format) |
1696
+ | `ref: /^v1\./` | Latest tag matching the regex |
1697
+ | `ref: main` | Literal branch / tag / commit |
1698
+
1699
+ `$latest`, `$recent`, and `$self` are reserved and cannot be used as variable names.
1330
1700
 
1331
1701
  When `ref` is omitted, all source types (GitHub, GitLab, Bitbucket, git) resolve to the repository's default branch.
1332
1702
 
1333
- `$self` is a reserved keyword that expands to the absolute path of the active config file. It is injected automatically and cannot be used as a variable name. Use it anywhere a variable is valid — `exec:` commands, `replace:` rules, `exists:` conditions, `post:` scripts, or any source field:
1703
+ `$self` is a reserved keyword that expands to the absolute path of the active config file. It is injected automatically and cannot be used as a variable name. Use it anywhere a variable is valid — `exec:` commands, `replace:` rules, `exists:` conditions, `on.write` scripts, or any source field:
1334
1704
 
1335
1705
  ```yaml
1336
1706
  files:
@@ -1358,7 +1728,9 @@ When the config is specified as a remote spec (e.g. `--config github:org/repo:.a
1358
1728
 
1359
1729
  In addition to `$self` and `$latest`, avanti injects several variables automatically at the start of every run. These names are reserved and cannot be used in `variables:`.
1360
1730
 
1361
- **Per-file path variables** avanti derives the following variables from each file entry's resolved target path. They can be used in source URLs, `ref:`, conditions, `replace:`, `post:`, template rendering, and `backup:` patterns.
1731
+ **Per-file path variables**, **pull-time variables**, and **system variables** are all available everywhere variables are resolved: source URLs, `ref:`, conditions, `replace:`, `on.write` scripts, template rendering, and `backup:` patterns. Side-effect hooks (`on.beforeWrite`, `on.beforeCreate`, `on.beforeUpdate`, `on.create`, `on.update`) do not resolve avanti variables — use `$AVANTI_TARGET` and `$AVANTI_IS_NEW` env vars instead.
1732
+
1733
+ **Per-file path variables** — avanti derives the following variables from each file entry's resolved target path.
1362
1734
 
1363
1735
  Example with working directory `/home/user/project` and map key `configs/app.yaml`:
1364
1736
 
@@ -1371,7 +1743,7 @@ Example with working directory `/home/user/project` and map key `configs/app.yam
1371
1743
  | `$dirname` | `/home/user/project/configs` |
1372
1744
  | `$basedir` | `configs` |
1373
1745
 
1374
- > **Availability in source URLs and conditions:** per-file path variables are only resolved before the fetch when the map key is a fixed (non-directory) path. They are always available in processors (`replace:`, `post:`, template rendering) and `backup:`.
1746
+ > **Availability in source URLs and conditions:** per-file path variables are only resolved before the fetch when the map key is a fixed (non-directory) path. They are always available in processors (`replace:`, `on.write` scripts, template rendering) and `backup:`. Side-effect hooks (`on.create`, `on.update`, etc.) do not resolve avanti variables.
1375
1747
 
1376
1748
  ```yaml
1377
1749
  variables:
@@ -1393,13 +1765,14 @@ files:
1393
1765
  if:
1394
1766
  exists: $path
1395
1767
 
1396
- # $dirname in a processor log which directory was updated
1768
+ # $dirname in on.writetransform content using the file's directory name
1397
1769
  app/config.yaml:
1398
1770
  src: github:org/repo/app/config.yaml
1399
- post: echo updated $dirname >> pull.log
1771
+ on:
1772
+ write: sed "s|__DIR__|$dirname|g"
1400
1773
  ```
1401
1774
 
1402
- **Pull-time variables** — injected once at the start of every run and available everywhere (source URLs, conditions, `replace:`, `post:`, template rendering, `backup:`):
1775
+ **Pull-time variables** — injected once at the start of every run and available everywhere (source URLs, conditions, `replace:`, `on.write` scripts, template rendering, `backup:`):
1403
1776
 
1404
1777
  | Variable | Value | Example |
1405
1778
  | ----------- | --------------------------------------- | --------------------- |
@@ -1420,6 +1793,34 @@ files:
1420
1793
  to: $datetime
1421
1794
  ```
1422
1795
 
1796
+ **System variables** — injected once per run and reflect the machine running avanti. Useful for downloading the correct release artifact for the current OS and CPU architecture:
1797
+
1798
+ | Variable | `linux` | `darwin` | `win32` |
1799
+ | -------- | ------- | -------- | --------- |
1800
+ | `$os` | `linux` | `darwin` | `windows` |
1801
+
1802
+ | Variable | `x64` | `arm64` | `ia32` | `arm` |
1803
+ | ---------- | -------- | ------- | ------ | ----- |
1804
+ | `$arch` | `x86_64` | `arm64` | `i686` | `arm` |
1805
+ | `$arch_go` | `amd64` | `arm64` | `386` | `arm` |
1806
+
1807
+ `$arch` uses GNU-triple / Rust naming (`x86_64`). `$arch_go` uses Go / Docker / Kubernetes naming (`amd64`). The `arm64` value is identical in both. Unknown `process.platform` or `process.arch` values are passed through unchanged.
1808
+
1809
+ ```yaml
1810
+ variables:
1811
+ rg_version: '14.1.1'
1812
+ kubectl_version: '1.32.0'
1813
+
1814
+ files:
1815
+ # Download the ripgrep tarball for the current system (Rust/GNU naming)
1816
+ releases/ripgrep.tar.gz:
1817
+ src: https://github.com/BurntSushi/ripgrep/releases/download/$rg_version/ripgrep-$rg_version-$arch-unknown-$os.tar.gz
1818
+
1819
+ # Download kubectl for the current system (Go naming)
1820
+ bin/kubectl:
1821
+ src: https://dl.k8s.io/release/v$kubectl_version/bin/$os/$arch_go/kubectl
1822
+ ```
1823
+
1423
1824
  ### $self — Self-managing Config
1424
1825
 
1425
1826
  The special `$self` key in the `files:` map tells avanti to manage its own config file. When `$self` is present, avanti fetches the listed sources and uses the result as the active config for the rest of the run — all in a single invocation.
@@ -1462,7 +1863,7 @@ files:
1462
1863
  arrays: concat
1463
1864
  ```
1464
1865
 
1465
- `$self` supports all the same source types, `replace`, `post`, and YAML/JSON merge options as any other file entry. See [Self-managing Config](#self-managing-config) in the Use Cases section for a full worked example.
1866
+ `$self` supports all the same source types, `replace`, `on.write`, and YAML/JSON merge options as any other file entry. Lifecycle hooks (`on.beforeWrite`, `on.beforeCreate`, `on.beforeUpdate`, `on.create`, `on.update`) are not supported for `$self` — they require a confirmed write context that the config re-evaluation pass does not have. See [Self-managing Config](#self-managing-config) in the Use Cases section for a full worked example.
1466
1867
 
1467
1868
  ### Authentication
1468
1869