@sebastienrousseau/dotfiles 0.2.501 → 0.2.502

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 (65) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +38 -8
  3. package/docs/COPYRIGHT +1 -1
  4. package/docs/index.md +2 -2
  5. package/docs/manual/00-introduction.md +1 -1
  6. package/docs/manual/03-reference/01-dot-cli.md +63 -45
  7. package/docs/manual/03-reference/02-config-files.md +2 -2
  8. package/docs/manual/03-reference/05-feature-flags.md +62 -3
  9. package/docs/manual/_toc.yml +1 -1
  10. package/docs/manual/command-index.md +12 -8
  11. package/docs/manual/index.md +66 -0
  12. package/docs/operations/COVERAGE.md +36 -2
  13. package/docs/operations/HARD_AUDIT_2026.md +631 -0
  14. package/docs/operations/REGISTRY.md +89 -0
  15. package/docs/operations/ROADMAP_2026.md +665 -0
  16. package/docs/operations/TRACEABILITY.md +3 -0
  17. package/docs/operations/VERSION_SYNC.md +4 -4
  18. package/docs/reference/POWERSHELL_PARITY.md +80 -0
  19. package/docs/registry.json +6 -0
  20. package/docs/security/CI_PINNING.md +113 -0
  21. package/docs/security/COMMIT_SIGNING.md +138 -0
  22. package/docs/security/DISCLOSURE.md +130 -0
  23. package/docs/security/KEY_ROTATION.md +81 -1
  24. package/docs/security/SCORECARD.md +74 -14
  25. package/docs/security/security-pubkey.asc +15 -0
  26. package/dot_config/fish/conf.d/direnv.fish +4 -0
  27. package/dot_config/fish/conf.d/mise-activate.fish +5 -0
  28. package/dot_config/git/hooks/executable_commit-msg +1 -1
  29. package/dot_config/shell/00-core-paths.sh.tmpl +8 -1
  30. package/dot_config/shell/README.md +1 -1
  31. package/dot_config/zsh/dot_zshrc.tmpl +57 -4
  32. package/dot_config/zsh/rc.d/30-options.zsh.tmpl +1 -1
  33. package/dot_local/bin/executable_dot +49 -9
  34. package/dot_local/bin/executable_dot-bootstrap +0 -1
  35. package/dot_local/bin/executable_dot-theme-sync +8 -8
  36. package/dot_local/bin/executable_tour +2 -2
  37. package/dot_local/share/man/man1/dot.1 +1 -1
  38. package/dot_local/share/zsh/completions/_dot +4 -0
  39. package/install.sh +44 -37
  40. package/package.json +1 -1
  41. package/scripts/ci/dot-cli-startup-bench.sh +126 -0
  42. package/scripts/ci/install-chezmoi-verified.sh +4 -1
  43. package/scripts/ci/lint-reusable-pins.sh +78 -0
  44. package/scripts/ci/run-coverage.sh +89 -0
  45. package/scripts/ci/windows-smoke-test.ps1 +136 -0
  46. package/scripts/diagnostics/doctor.sh +39 -9
  47. package/scripts/dot/commands/agent.sh +19 -22
  48. package/scripts/dot/commands/agents.sh +325 -0
  49. package/scripts/dot/commands/aliases.sh +10 -8
  50. package/scripts/dot/commands/core.sh +10 -4
  51. package/scripts/dot/commands/fleet.sh +278 -3
  52. package/scripts/dot/commands/init.sh +184 -0
  53. package/scripts/dot/commands/meta.sh +7 -4
  54. package/scripts/dot/commands/registry.sh +263 -0
  55. package/scripts/dot/commands/tools.sh +49 -1
  56. package/scripts/dot/lib/bento.sh +1 -1
  57. package/scripts/dot/lib/platform.sh +21 -8
  58. package/scripts/dot/lib/ui.sh +134 -2
  59. package/scripts/dot/lib/utils.sh +1 -1
  60. package/scripts/git-hooks/pre-commit-audit.sh +1 -1
  61. package/scripts/lib/secrets_provider.sh +32 -6
  62. package/scripts/ops/rollback.sh +14 -0
  63. package/scripts/security/check-disclosure-key-expiry.sh +110 -0
  64. package/scripts/security/lock-configs.sh +11 -2
  65. package/scripts/version-sync.sh +3 -0
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env bash
2
+ # Copyright (c) 2015-2026 Dotfiles. All rights reserved.
3
+ # shellcheck shell=bash
4
+ #
5
+ # scripts/dot/commands/agents.sh
6
+ #
7
+ # `dot agents` — multi-harness AI agent configuration manager.
8
+ #
9
+ # Closes the #1 competitive gap identified in HARD_AUDIT_2026:
10
+ # every other dotfiles framework either targets one AI tool or
11
+ # requires hand-maintenance of N parallel config files. This command
12
+ # keeps CLAUDE.md (canonical) and AGENTS.md (cross-harness standard)
13
+ # in sync, plus stubs the Cursor/Codex tool-specific formats.
14
+ #
15
+ # Subcommands:
16
+ # render Regenerate AGENTS.md and tool-specific stubs from CLAUDE.md.
17
+ # check Verify AGENTS.md tracks CLAUDE.md (exit 0 in sync, 1 drifted).
18
+ # list Show which harnesses are recognised + their target paths.
19
+ #
20
+ # Reads: CLAUDE.md (repo root) — the canonical agent context.
21
+ # Writes: AGENTS.md, .cursor/rules/dotfiles.mdc, .codex/config.toml
22
+ # (only when invoked with `render`).
23
+
24
+ set -euo pipefail
25
+
26
+ # shellcheck disable=SC1091
27
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ source "$SCRIPT_DIR/../lib/ui.sh"
29
+ source "$SCRIPT_DIR/../lib/utils.sh"
30
+
31
+ _agents_repo_root() {
32
+ # Resolve the chezmoi source dir (where CLAUDE.md/AGENTS.md live).
33
+ # We REQUIRE the resolved dir to contain `.chezmoidata.toml` so a
34
+ # user running `dot agents render` from inside some other git
35
+ # checkout doesn't accidentally write CLAUDE.md/AGENTS.md/.cursor/
36
+ # /.codex/ into that repo. Round-2 audit C-finding.
37
+ #
38
+ # On a CI runner `chezmoi source-path` may return a default path
39
+ # like `$HOME/.local/share/chezmoi` that doesn't exist on disk —
40
+ # we treat that as "not a match" and fall through to git, rather
41
+ # than accepting the wrong candidate.
42
+ local candidate=""
43
+ if command -v chezmoi >/dev/null 2>&1; then
44
+ candidate="$(chezmoi source-path 2>/dev/null || true)"
45
+ if [[ -n "$candidate" && ! -f "$candidate/.chezmoidata.toml" ]]; then
46
+ candidate=""
47
+ fi
48
+ fi
49
+ if [[ -z "$candidate" ]]; then
50
+ candidate="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || true)"
51
+ fi
52
+ if [[ -z "$candidate" ]] || [[ ! -f "$candidate/.chezmoidata.toml" ]]; then
53
+ return 1
54
+ fi
55
+ printf '%s\n' "$candidate"
56
+ }
57
+
58
+ _agents_canonical() {
59
+ printf "%s/CLAUDE.md" "$(_agents_repo_root)"
60
+ }
61
+
62
+ _agents_targets() {
63
+ # printed one-per-line: <harness>\t<path-relative-to-repo-root>
64
+ # Closes the round-2 audit gap #3 — harness parity with
65
+ # TonyCasey/ai-dotfiles-manager (who covers 9 surfaces; we now
66
+ # cover 12 to maintain a margin).
67
+ local root
68
+ root="$(_agents_repo_root)"
69
+ cat <<EOF
70
+ agents-md $root/AGENTS.md
71
+ cursor $root/.cursor/rules/dotfiles.mdc
72
+ codex $root/.codex/config.toml
73
+ windsurf $root/.windsurf/rules.md
74
+ zed $root/.zed/agent-config.toml
75
+ roo $root/.roo/rules.md
76
+ cline $root/.clinerules
77
+ aider $root/.aider.conf.yml
78
+ continue $root/.continuerc.json
79
+ jules $root/.jules/system.md
80
+ gemini $root/.gemini/GEMINI.md
81
+ EOF
82
+ }
83
+
84
+ # Strip the leading HTML comment header (everything up to the first blank
85
+ # line after the `-->`), the H1 title line (which differs by design
86
+ # between CLAUDE.md / AGENTS.md), and the trailing cross-reference
87
+ # block. Leaves the comparable content body.
88
+ _agents_body() {
89
+ local file="$1"
90
+ awk '
91
+ BEGIN { in_header = 0; saw_close = 0; emit = 0 }
92
+ /^<!--/ && NR <= 5 { in_header = 1; next }
93
+ in_header && /-->/ { in_header = 0; saw_close = 1; next }
94
+ in_header { next }
95
+ saw_close && /^$/ && !emit { emit = 1; next }
96
+ saw_close && emit { print }
97
+ !saw_close { emit = 1; print }
98
+ ' "$file" |
99
+ # Drop the H1 (`# CLAUDE.md ...` or `# AGENTS.md ...`) and the
100
+ # trailing "Need richer context?" footer if present.
101
+ awk '
102
+ /^# (CLAUDE|AGENTS)\.md/ { next }
103
+ /^---$/ { trailer = 1; next }
104
+ trailer && /^\*\*Need richer context\?\*\*/ { next }
105
+ trailer && NF == 0 { next }
106
+ { trailer = 0; print }
107
+ '
108
+ }
109
+
110
+ cmd_agents() {
111
+ local subcommand="${1:-list}"
112
+ shift || true
113
+
114
+ case "$subcommand" in
115
+ list)
116
+ ui_header "Agent harness targets"
117
+ echo ""
118
+ ui_table_begin "Harness" "Target Path" "Status"
119
+ while IFS=$'\t' read -r harness path; do
120
+ if [[ -f "$path" ]]; then
121
+ ui_table_add "$harness" "$path" "rendered"
122
+ else
123
+ ui_table_add "$harness" "$path" "not yet rendered"
124
+ fi
125
+ done < <(_agents_targets)
126
+ ui_table_end
127
+ ;;
128
+ check)
129
+ local claude_md agents_md
130
+ claude_md="$(_agents_canonical)"
131
+ agents_md="$(_agents_repo_root)/AGENTS.md"
132
+ if [[ ! -f "$claude_md" ]]; then
133
+ ui_err "CLAUDE.md" "not found at $claude_md"
134
+ return 2
135
+ fi
136
+ if [[ ! -f "$agents_md" ]]; then
137
+ ui_warn "AGENTS.md" "missing — run 'dot agents render'"
138
+ return 1
139
+ fi
140
+ # Diff the bodies (header comments differ by design).
141
+ # `--ignore-blank-lines` so the trailing newline from the footer
142
+ # block doesn't false-positive as drift.
143
+ if diff -q --ignore-blank-lines <(_agents_body "$claude_md") <(_agents_body "$agents_md") >/dev/null 2>&1; then
144
+ ui_ok "AGENTS.md" "in sync with CLAUDE.md"
145
+ return 0
146
+ fi
147
+ ui_warn "AGENTS.md" "drifted from CLAUDE.md — run 'dot agents render'"
148
+ return 1
149
+ ;;
150
+ render)
151
+ local claude_md root
152
+ claude_md="$(_agents_canonical)"
153
+ root="$(_agents_repo_root)"
154
+ [[ -f "$claude_md" ]] || {
155
+ ui_err "CLAUDE.md" "not found at $claude_md"
156
+ return 2
157
+ }
158
+
159
+ # 1. AGENTS.md — body of CLAUDE.md + AGENTS.md cross-reference header.
160
+ local agents_md="$root/AGENTS.md"
161
+ {
162
+ cat <<'HEADER'
163
+ <!--
164
+ AGENTS.md — Cross-harness AI agent guidelines for this repository.
165
+
166
+ This file follows the AGENTS.md standard (originated by OpenAI in
167
+ August 2025, stewarded since December 2025 by the Linux Foundation
168
+ Agentic AI Foundation). Native readers: Codex CLI, GitHub Copilot,
169
+ Cursor, Windsurf, Amp, Devin, and a growing list of other agents.
170
+
171
+ Canonical source: CLAUDE.md (Claude Code uses CLAUDE.md natively).
172
+ This file is kept in sync via `dot agents render`. Edit CLAUDE.md
173
+ first; do not hand-edit AGENTS.md.
174
+ -->
175
+
176
+ HEADER
177
+ # Replace the title line so the rendered file declares its
178
+ # purpose, but pass everything else through unchanged.
179
+ _agents_body "$claude_md" | sed '1s/^# CLAUDE\.md.*/# AGENTS.md — AI Assistant Guidelines/'
180
+ cat <<'FOOTER'
181
+
182
+ ---
183
+
184
+ **Need richer context?** This file is the cross-harness summary. Claude Code reads the full canonical version from [`CLAUDE.md`](./CLAUDE.md). Both files are kept in sync via `dot agents render`.
185
+ FOOTER
186
+ } >"$agents_md"
187
+ chmod 0644 "$agents_md" 2>/dev/null || true
188
+ ui_ok "AGENTS.md" "rendered → $agents_md"
189
+
190
+ # 2. Cursor rules — MDC format, points at CLAUDE.md/AGENTS.md.
191
+ local cursor_dir="$root/.cursor/rules"
192
+ mkdir -p "$cursor_dir"
193
+ cat >"$cursor_dir/dotfiles.mdc" <<'MDC'
194
+ ---
195
+ description: Repo conventions for the dotfiles project (sourced from CLAUDE.md/AGENTS.md)
196
+ globs:
197
+ - "**/*"
198
+ alwaysApply: true
199
+ ---
200
+
201
+ See `AGENTS.md` and `CLAUDE.md` in the repository root for the full
202
+ project conventions, repository layout, testing, and CI policy. This
203
+ Cursor rule file exists so Cursor's rule engine picks up the same
204
+ context; do not duplicate content here — keep CLAUDE.md canonical.
205
+ MDC
206
+ chmod 0644 "$cursor_dir/dotfiles.mdc" 2>/dev/null || true
207
+ ui_ok "Cursor" "rendered → $cursor_dir/dotfiles.mdc"
208
+
209
+ # 3. Codex CLI config stub — declares the AGENTS.md path.
210
+ local codex_dir="$root/.codex"
211
+ mkdir -p "$codex_dir"
212
+ cat >"$codex_dir/config.toml" <<'TOML'
213
+ # Codex CLI config — points at the AGENTS.md cross-harness context.
214
+ # Codex reads AGENTS.md natively; this file is a project-scoped pin so
215
+ # the CLI prefers the in-repo guidance over any global default.
216
+ project_context = "AGENTS.md"
217
+ TOML
218
+ chmod 0644 "$codex_dir/config.toml" 2>/dev/null || true
219
+ ui_ok "Codex" "rendered → $codex_dir/config.toml"
220
+
221
+ # 4-11. Per-harness rules files. All eight emitters reuse the
222
+ # same body extracted from CLAUDE.md so a single edit propagates
223
+ # consistently. The header differs per harness so each tool sees
224
+ # syntax it expects (front-matter style, comment block, etc.).
225
+ _agents_render_markdown_with_header() {
226
+ local _path="$1" _harness="$2" _header="$3"
227
+ mkdir -p "$(dirname "$_path")"
228
+ {
229
+ printf '%s\n\n' "$_header"
230
+ _agents_body "$claude_md"
231
+ # Heredoc instead of printf — Codacy/shellcheck flags
232
+ # printf-with-backticks-in-single-quotes (SC2016) as
233
+ # ambiguous even when the backticks are literal Markdown.
234
+ cat <<'FOOTER'
235
+
236
+ ---
237
+
238
+ **Canonical source:** [`CLAUDE.md`](./CLAUDE.md) — keep in sync via `dot agents render`.
239
+ FOOTER
240
+ } >"$_path"
241
+ chmod 0644 "$_path" 2>/dev/null || true
242
+ ui_ok "$_harness" "rendered → $_path"
243
+ }
244
+
245
+ # 4. Windsurf — looks for `.windsurf/rules.md`.
246
+ _agents_render_markdown_with_header "$root/.windsurf/rules.md" \
247
+ "Windsurf" "# Windsurf project rules
248
+
249
+ These rules govern Cascade and the Windsurf agent inside this repository."
250
+
251
+ # 5. Roo — looks for `.roo/rules.md`.
252
+ _agents_render_markdown_with_header "$root/.roo/rules.md" \
253
+ "Roo" "# Roo project rules"
254
+
255
+ # 6. Cline — looks for `.clinerules` (no extension).
256
+ _agents_render_markdown_with_header "$root/.clinerules" \
257
+ "Cline" "# Cline workspace rules"
258
+
259
+ # 7. Jules — looks for `.jules/system.md`.
260
+ _agents_render_markdown_with_header "$root/.jules/system.md" \
261
+ "Jules" "# Jules system prompt"
262
+
263
+ # 8. Gemini — looks for `.gemini/GEMINI.md`.
264
+ _agents_render_markdown_with_header "$root/.gemini/GEMINI.md" \
265
+ "Gemini" "# Gemini agent rules"
266
+
267
+ # 9. Zed — config pointer (TOML).
268
+ local zed_dir="$root/.zed"
269
+ mkdir -p "$zed_dir"
270
+ cat >"$zed_dir/agent-config.toml" <<'TOML'
271
+ # Zed agent config — points at AGENTS.md for the cross-harness body.
272
+ # Zed reads this file when its agent mode is enabled; the actual rule
273
+ # body lives in AGENTS.md (which `dot agents render` keeps current).
274
+ agent_context = "AGENTS.md"
275
+ TOML
276
+ chmod 0644 "$zed_dir/agent-config.toml" 2>/dev/null || true
277
+ ui_ok "Zed" "rendered → $zed_dir/agent-config.toml"
278
+
279
+ # 10. Aider — YAML pointer to AGENTS.md / CLAUDE.md.
280
+ cat >"$root/.aider.conf.yml" <<'YML'
281
+ # Aider config — surfaces the cross-harness context bundle.
282
+ # Aider's `--read` flag pulls in AGENTS.md / CLAUDE.md per session.
283
+ read:
284
+ - AGENTS.md
285
+ - CLAUDE.md
286
+ YML
287
+ chmod 0644 "$root/.aider.conf.yml" 2>/dev/null || true
288
+ ui_ok "Aider" "rendered → $root/.aider.conf.yml"
289
+
290
+ # 11. Continue — JSON pointer used by VS Code / JetBrains plugin.
291
+ cat >"$root/.continuerc.json" <<'JSON'
292
+ {
293
+ "_comment": "Continue config pointer — see AGENTS.md for the full body.",
294
+ "systemMessage": "Apply the rules in AGENTS.md (canonical source: CLAUDE.md). Run `dot agents render` if the two drift."
295
+ }
296
+ JSON
297
+ chmod 0644 "$root/.continuerc.json" 2>/dev/null || true
298
+ ui_ok "Continue" "rendered → $root/.continuerc.json"
299
+
300
+ ui_info "Hint" "commit AGENTS.md alongside CLAUDE.md changes (or add to your pre-commit hook)"
301
+ ;;
302
+ --help | -h | help)
303
+ cat <<EOF
304
+ Usage: dot agents <subcommand>
305
+
306
+ Subcommands:
307
+ list Show which agent harnesses are recognised and their target paths
308
+ check Verify AGENTS.md tracks CLAUDE.md (exit 0 in sync, 1 drifted)
309
+ render Regenerate AGENTS.md + 10 harness-specific files from CLAUDE.md
310
+
311
+ CLAUDE.md is the canonical source. AGENTS.md follows the cross-harness
312
+ standard read by Codex, Copilot, Cursor, Windsurf, Amp, and Devin.
313
+
314
+ Harnesses covered by 'render':
315
+ agents-md, cursor, codex, windsurf, zed, roo, cline,
316
+ aider, continue, jules, gemini.
317
+ EOF
318
+ ;;
319
+ *)
320
+ ui_err "Unknown subcommand" "$subcommand"
321
+ echo "Run 'dot agents --help' for usage." >&2
322
+ return 1
323
+ ;;
324
+ esac
325
+ }
@@ -40,11 +40,11 @@ cmd_aliases() {
40
40
  list)
41
41
  ui_header "Aliases"
42
42
  echo ""
43
- printf " %-18s %-42s %s\n" "Name" "Value" "Source"
44
- echo " $(printf '%.18s' '------------------') $(printf '%.42s' '------------------------------------------') -------------------------"
45
- emit_alias_manifest | sort -t $'\t' -k1,1 | while IFS=$'\t' read -r name value file line; do
46
- printf " %-18s %-42s %s:%s\n" "$name" "${value:0:42}" "${file##*/}" "$line"
47
- done
43
+ ui_table_begin "Name" "Value" "Source"
44
+ while IFS=$'\t' read -r name value file line; do
45
+ ui_table_add "$name" "${value:0:60}" "${file##*/}:$line"
46
+ done < <(emit_alias_manifest | sort -t $'\t' -k1,1)
47
+ ui_table_end
48
48
  ;;
49
49
  search)
50
50
  local query="${1:-}"
@@ -60,9 +60,11 @@ cmd_aliases() {
60
60
  ui_warn "No matches" "$query"
61
61
  return 1
62
62
  fi
63
- printf "%s\n" "$results" | while IFS=$'\t' read -r name value file line; do
64
- printf " %-18s %-42s %s:%s\n" "$name" "${value:0:42}" "$file" "$line"
65
- done
63
+ ui_table_begin "Name" "Value" "Source"
64
+ while IFS=$'\t' read -r name value file line; do
65
+ ui_table_add "$name" "${value:0:60}" "$file:$line"
66
+ done <<<"$results"
67
+ ui_table_end
66
68
  ;;
67
69
  why)
68
70
  local alias_name="${1:-}"
@@ -77,13 +77,19 @@ cmd_diff() {
77
77
  }
78
78
 
79
79
  cmd_status() {
80
- local out
81
- out="$(chezmoi status "$@" || true)"
80
+ local out rc=0
81
+ # Capture chezmoi's stderr too so an actual tool failure surfaces
82
+ # to the user instead of being indistinguishable from a clean tree.
83
+ out="$(chezmoi status "$@" 2>&1)" || rc=$?
84
+ ui_header "Dotfiles Status"
85
+ if [[ "$rc" -ne 0 ]]; then
86
+ ui_err "chezmoi" "exited $rc"
87
+ [[ -n "$out" ]] && printf "%s\n" "$out"
88
+ return "$rc"
89
+ fi
82
90
  if [[ -z "$out" ]]; then
83
- ui_header "Dotfiles Status"
84
91
  ui_ok "Clean" "no local drift detected"
85
92
  else
86
- ui_header "Dotfiles Status"
87
93
  printf "%s\n" "$out"
88
94
  fi
89
95
  }
@@ -291,10 +291,21 @@ cmd_fleet_namespace() {
291
291
  local data_file
292
292
  data_file="$(resolve_source_dir)/.chezmoidata.toml"
293
293
  if grep -q "^namespace = " "$data_file" 2>/dev/null; then
294
- if sed --version >/dev/null 2>&1; then
295
- sed -i "s/^namespace = \".*\"/namespace = \"$new_ns\"/" "$data_file"
294
+ # Atomic write: render into a tempfile + mv so concurrent
295
+ # `dot fleet namespace set` callers can't corrupt the TOML.
296
+ # Avoids `sed -i` portability dance (GNU `-i` vs BSD `-i ''`).
297
+ local _tmp
298
+ _tmp="$(mktemp "${data_file}.XXXXXX")" || die "Cannot create tempfile"
299
+ # Explicit if/else instead of A && B || C — the latter (SC2015)
300
+ # silently runs C when B itself fails, masking real mv errors.
301
+ if sed "s/^namespace = \".*\"/namespace = \"$new_ns\"/" "$data_file" >"$_tmp"; then
302
+ if ! mv "$_tmp" "$data_file"; then
303
+ rm -f "$_tmp"
304
+ die "Failed to commit namespace update"
305
+ fi
296
306
  else
297
- sed -i '' "s/^namespace = \".*\"/namespace = \"$new_ns\"/" "$data_file"
307
+ rm -f "$_tmp"
308
+ die "Failed to render namespace update"
298
309
  fi
299
310
  fi
300
311
  ui_ok "Namespace" "Set to '$new_ns'. Run 'dot sync' to apply."
@@ -349,6 +360,266 @@ cmd_fleet_enforce() {
349
360
  esac
350
361
  }
351
362
 
363
+ _fleet_hosts_file() {
364
+ printf '%s\n' "${DOTFILES_FLEET_HOSTS:-$HOME/.config/dotfiles/fleet.toml}"
365
+ }
366
+
367
+ # Parse the hosts file. Format:
368
+ # [hosts.laptop]
369
+ # ssh = "user@laptop.local"
370
+ # profile = "workstation"
371
+ #
372
+ # Echoes one record per line: "<name>\t<ssh-target>\t<profile>".
373
+ _fleet_hosts_iter() {
374
+ local f
375
+ f="$(_fleet_hosts_file)"
376
+ [[ -f "$f" ]] || return 0
377
+ awk '
378
+ BEGIN { name = ""; ssh = ""; profile = "" }
379
+ /^\[hosts\./ {
380
+ if (name != "") { printf "%s\t%s\t%s\n", name, ssh, profile }
381
+ gsub(/[\[\]]/, "", $0); sub(/^hosts\./, "", $0); name = $0
382
+ ssh = ""; profile = ""
383
+ next
384
+ }
385
+ /^ssh[[:space:]]*=/ { sub(/^ssh[[:space:]]*=[[:space:]]*/, ""); gsub(/"/, ""); ssh = $0; next }
386
+ /^profile[[:space:]]*=/ { sub(/^profile[[:space:]]*=[[:space:]]*/, ""); gsub(/"/, ""); profile = $0; next }
387
+ END {
388
+ if (name != "") { printf "%s\t%s\t%s\n", name, ssh, profile }
389
+ }
390
+ ' "$f"
391
+ }
392
+
393
+ # SSH-based "dot fleet apply" — push the local dotfiles state out to
394
+ # each registered host. The §3 hero-feature: nobody else owns the
395
+ # "Ansible for personal devices" niche.
396
+ cmd_fleet_apply() {
397
+ local dry_run=0 only_host="" cmd="" jobs=4 verify_hosts=0
398
+ while [[ $# -gt 0 ]]; do
399
+ case "$1" in
400
+ --dry-run | -n)
401
+ dry_run=1
402
+ shift
403
+ ;;
404
+ --verify-hosts)
405
+ # Pre-flight check that every host already has a known_hosts
406
+ # entry, closing the TOFU window before any SSH connection.
407
+ # R3 audit N4. Without this, accept-new is the default and a
408
+ # first-connection MITM can seed an attacker key.
409
+ verify_hosts=1
410
+ shift
411
+ ;;
412
+ --host)
413
+ only_host="$2"
414
+ shift 2
415
+ ;;
416
+ --cmd)
417
+ cmd="$2"
418
+ shift 2
419
+ ;;
420
+ --jobs | -j)
421
+ jobs="$2"
422
+ shift 2
423
+ ;;
424
+ --help | -h)
425
+ cat <<EOF
426
+ Usage: dot fleet apply [--host <name>] [--cmd <shell>] [--dry-run] [--jobs <n>]
427
+
428
+ Push dotfiles state to every host registered in:
429
+ ${DOTFILES_FLEET_HOSTS:-\$HOME/.config/dotfiles/fleet.toml}
430
+
431
+ Format of fleet.toml:
432
+ [hosts.laptop]
433
+ ssh = "user@laptop.local"
434
+ profile = "workstation"
435
+
436
+ Hostnames are validated against [A-Za-z0-9._@:+/-]+ before any SSH
437
+ fan-out; invalid entries abort the apply.
438
+
439
+ First-time SSH connections use StrictHostKeyChecking=accept-new (TOFU).
440
+ If your threat model requires no TOFU window, pre-populate
441
+ ~/.ssh/known_hosts before running this command.
442
+
443
+ Behavior:
444
+ By default each host runs: dot sync && dot doctor --quiet
445
+ Override with --cmd "<shell>" to run an arbitrary command on every
446
+ host (e.g. --cmd "uptime").
447
+
448
+ WARNING: --cmd is the trust boundary. Whatever string you pass
449
+ executes on every remote host with the credentials your SSH key
450
+ carries. Verify the command before running.
451
+
452
+ Flags:
453
+ --host <name> Apply to a single host only.
454
+ --cmd <shell> Run a custom command instead of 'dot sync'.
455
+ --dry-run, -n Print resolved hosts + planned command; don't SSH.
456
+ --jobs <n> Parallelism (default 4).
457
+ --verify-hosts Refuse to open any SSH connection unless every
458
+ target host already has a key in ~/.ssh/known_hosts.
459
+ Use when your threat model excludes the TOFU window.
460
+ EOF
461
+ return 0
462
+ ;;
463
+ *)
464
+ ui_err "Unknown arg" "$1"
465
+ return 1
466
+ ;;
467
+ esac
468
+ done
469
+
470
+ local hosts_file
471
+ hosts_file="$(_fleet_hosts_file)"
472
+ if [[ ! -f "$hosts_file" ]]; then
473
+ ui_err "Fleet" "no hosts file at $hosts_file"
474
+ ui_info "Hint" "create it with stanzas like '[hosts.laptop]\\nssh = \"user@laptop.local\"'"
475
+ return 1
476
+ fi
477
+
478
+ local entries
479
+ entries="$(_fleet_hosts_iter)"
480
+ if [[ -z "$entries" ]]; then
481
+ ui_err "Fleet" "hosts file is empty: $hosts_file"
482
+ return 1
483
+ fi
484
+ if [[ -n "$only_host" ]]; then
485
+ entries="$(printf '%s\n' "$entries" | awk -F'\t' -v h="$only_host" '$1 == h')"
486
+ [[ -n "$entries" ]] || {
487
+ ui_err "Fleet" "host not found: $only_host"
488
+ return 1
489
+ }
490
+ fi
491
+
492
+ local default_cmd='dot sync && dot doctor --quiet'
493
+ local effective_cmd="${cmd:-$default_cmd}"
494
+
495
+ ui_header "Fleet apply"
496
+ ui_info "Hosts file" "$hosts_file"
497
+ ui_info "Command" "$effective_cmd"
498
+ ui_info "Parallel" "$jobs"
499
+
500
+ if [[ "$dry_run" -eq 1 ]]; then
501
+ printf '%s\n' "$entries" | while IFS=$'\t' read -r name ssh profile; do
502
+ ui_info "$name" "$ssh profile=$profile cmd=$effective_cmd"
503
+ done
504
+ ui_ok "Dry-run" "no SSH connections opened"
505
+ return 0
506
+ fi
507
+
508
+ if ! command -v ssh >/dev/null 2>&1; then
509
+ ui_err "ssh" "not installed"
510
+ return 127
511
+ fi
512
+
513
+ local total=0 ok=0 fail=0
514
+ local tmpdir
515
+ # `-t` template includes PID + random, so two concurrent `dot fleet
516
+ # apply` invocations from the same user can't collide on $tmpdir.
517
+ tmpdir="$(mktemp -d -t dotfiles-fleet.XXXXXX)"
518
+ # Capture tmpdir's value at trap-definition time (via the eval-on-
519
+ # define `printf -v`), NOT at trap-fire time. A naive
520
+ # `trap 'rm -rf "$tmpdir"' RETURN` is unsafe under set -u because
521
+ # `local tmpdir` is destroyed before the RETURN trap evaluates.
522
+ # The SC2064 warning ("Use single quotes, otherwise this expands now
523
+ # rather than when signalled") is exactly the behaviour we want here —
524
+ # we explicitly want eager expansion. Suppress per-line.
525
+ local _cleanup
526
+ printf -v _cleanup 'rm -rf %q' "$tmpdir"
527
+ # shellcheck disable=SC2064
528
+ trap "$_cleanup" RETURN
529
+
530
+ # Validate every hostname against a conservative regex BEFORE fan-out.
531
+ # `user@host:port` characters only — refuses single quotes, backticks,
532
+ # `$()`, semicolons, spaces, any shell metacharacter. Closes the
533
+ # round-2 audit's hostname-injection finding.
534
+ while IFS=$'\t' read -r name ssh profile; do
535
+ [[ -n "$name" ]] || continue
536
+ if [[ ! "$ssh" =~ ^[a-zA-Z0-9._@:+/-]+$ ]]; then
537
+ ui_err "$name" "invalid ssh target ($ssh) — only [a-zA-Z0-9._@:+/-] allowed"
538
+ return 1
539
+ fi
540
+ done <<<"$entries"
541
+
542
+ # --verify-hosts: refuse the apply when any target host is missing
543
+ # from ~/.ssh/known_hosts. Closes the R3 audit N4 TOFU-window gap.
544
+ if ((verify_hosts == 1)); then
545
+ local known_hosts="${HOME}/.ssh/known_hosts"
546
+ if [[ ! -f "$known_hosts" ]]; then
547
+ ui_err "verify-hosts" "no $known_hosts — populate before --verify-hosts"
548
+ return 1
549
+ fi
550
+ local unknown_count=0
551
+ while IFS=$'\t' read -r name ssh profile; do
552
+ [[ -n "$name" && -n "$ssh" ]] || continue
553
+ # Strip `user@` prefix and `:port` suffix for the lookup.
554
+ local hostpart="${ssh#*@}"
555
+ hostpart="${hostpart%%:*}"
556
+ if ! ssh-keygen -F "$hostpart" -f "$known_hosts" >/dev/null 2>&1; then
557
+ ui_err "$name" "no known_hosts entry for $hostpart — would TOFU on first connect"
558
+ unknown_count=$((unknown_count + 1))
559
+ fi
560
+ done <<<"$entries"
561
+ if ((unknown_count > 0)); then
562
+ ui_err "verify-hosts" "$unknown_count host(s) missing from known_hosts — aborting"
563
+ return 1
564
+ fi
565
+ ui_ok "verify-hosts" "all hosts found in known_hosts"
566
+ fi
567
+
568
+ # Run one SSH per host, parallelised via background jobs with a
569
+ # semaphore. We DO NOT use `xargs -d` because that flag is GNU-only
570
+ # and the §3 hero feature must work on macOS BSD xargs too. Also
571
+ # avoids embedding `{}` substitution into a `bash -c` (the previous
572
+ # implementation had a quoting hazard around TOML hostnames).
573
+ _fleet_apply_one() {
574
+ local _name="$1" _ssh="$2" _cmd="$3" _tmp="$4"
575
+ if ssh -o BatchMode=yes -o ConnectTimeout=10 \
576
+ -o StrictHostKeyChecking=accept-new \
577
+ "$_ssh" "$_cmd" </dev/null \
578
+ >"$_tmp/$_name.out" 2>"$_tmp/$_name.err"; then
579
+ printf 'ok\n' >"$_tmp/$_name.status"
580
+ else
581
+ printf 'fail %d\n' "$?" >"$_tmp/$_name.status"
582
+ fi
583
+ }
584
+
585
+ local running=0
586
+ while IFS=$'\t' read -r name ssh profile; do
587
+ [[ -n "$name" && -n "$ssh" ]] || continue
588
+ total=$((total + 1))
589
+ while ((running >= jobs)); do
590
+ wait -n 2>/dev/null || break
591
+ running=$((running - 1))
592
+ done
593
+ _fleet_apply_one "$name" "$ssh" "$effective_cmd" "$tmpdir" &
594
+ running=$((running + 1))
595
+ done <<<"$entries"
596
+ wait
597
+
598
+ # `while < <(printf ...)` instead of `printf ... | while` — the
599
+ # pipe form runs the loop body in a subshell, so the ok/fail
600
+ # counters never propagate back to the parent. Caught by
601
+ # tests/unit/fleet/test_fleet_apply_mocked_ssh.sh which exercised
602
+ # the full apply path (the dry-run test missed this).
603
+ while IFS=$'\t' read -r name ssh profile; do
604
+ [[ -n "$name" ]] || continue
605
+ if [[ -s "$tmpdir/$name.status" ]] && head -1 "$tmpdir/$name.status" | grep -q '^ok'; then
606
+ ui_ok "$name" "$ssh"
607
+ ok=$((ok + 1))
608
+ else
609
+ local err_summary=""
610
+ [[ -s "$tmpdir/$name.err" ]] && err_summary=" — $(head -1 "$tmpdir/$name.err")"
611
+ ui_err "$name" "$ssh${err_summary}"
612
+ fail=$((fail + 1))
613
+ fi
614
+ local _evt_status="unknown"
615
+ [[ -s "$tmpdir/$name.status" ]] && _evt_status="$(head -1 "$tmpdir/$name.status")"
616
+ _fleet_emit_event "apply" "$_evt_status" "host=$name" "cmd=$effective_cmd"
617
+ done < <(printf '%s\n' "$entries")
618
+
619
+ ui_info "Summary" "$ok ok / $fail failed / $total total"
620
+ [[ "$fail" -eq 0 ]]
621
+ }
622
+
352
623
  cmd_fleet() {
353
624
  local subcommand="${1:-status}"
354
625
  if [[ "${1:-}" == --* ]] || [[ -z "${1:-}" ]]; then
@@ -373,6 +644,9 @@ cmd_fleet() {
373
644
  enforce)
374
645
  cmd_fleet_enforce "$@"
375
646
  ;;
647
+ apply | push)
648
+ cmd_fleet_apply "$@"
649
+ ;;
376
650
  *)
377
651
  ui_header "Fleet Commands"
378
652
  echo ""
@@ -383,6 +657,7 @@ cmd_fleet() {
383
657
  ui_ok "events" "Show recent fleet events"
384
658
  ui_ok "namespace" "Show or set the active namespace"
385
659
  ui_ok "enforce" "Show or set RBAC enforcement mode (advisory|strict)"
660
+ ui_ok "apply" "SSH out to every host in fleet.toml and run 'dot sync'"
386
661
  ;;
387
662
  esac
388
663
  }