@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.
- package/CHANGELOG.md +77 -0
- package/README.md +38 -8
- package/docs/COPYRIGHT +1 -1
- package/docs/index.md +2 -2
- package/docs/manual/00-introduction.md +1 -1
- package/docs/manual/03-reference/01-dot-cli.md +63 -45
- package/docs/manual/03-reference/02-config-files.md +2 -2
- package/docs/manual/03-reference/05-feature-flags.md +62 -3
- package/docs/manual/_toc.yml +1 -1
- package/docs/manual/command-index.md +12 -8
- package/docs/manual/index.md +66 -0
- package/docs/operations/COVERAGE.md +36 -2
- package/docs/operations/HARD_AUDIT_2026.md +631 -0
- package/docs/operations/REGISTRY.md +89 -0
- package/docs/operations/ROADMAP_2026.md +665 -0
- package/docs/operations/TRACEABILITY.md +3 -0
- package/docs/operations/VERSION_SYNC.md +4 -4
- package/docs/reference/POWERSHELL_PARITY.md +80 -0
- package/docs/registry.json +6 -0
- package/docs/security/CI_PINNING.md +113 -0
- package/docs/security/COMMIT_SIGNING.md +138 -0
- package/docs/security/DISCLOSURE.md +130 -0
- package/docs/security/KEY_ROTATION.md +81 -1
- package/docs/security/SCORECARD.md +74 -14
- package/docs/security/security-pubkey.asc +15 -0
- package/dot_config/fish/conf.d/direnv.fish +4 -0
- package/dot_config/fish/conf.d/mise-activate.fish +5 -0
- package/dot_config/git/hooks/executable_commit-msg +1 -1
- package/dot_config/shell/00-core-paths.sh.tmpl +8 -1
- package/dot_config/shell/README.md +1 -1
- package/dot_config/zsh/dot_zshrc.tmpl +57 -4
- package/dot_config/zsh/rc.d/30-options.zsh.tmpl +1 -1
- package/dot_local/bin/executable_dot +49 -9
- package/dot_local/bin/executable_dot-bootstrap +0 -1
- package/dot_local/bin/executable_dot-theme-sync +8 -8
- package/dot_local/bin/executable_tour +2 -2
- package/dot_local/share/man/man1/dot.1 +1 -1
- package/dot_local/share/zsh/completions/_dot +4 -0
- package/install.sh +44 -37
- package/package.json +1 -1
- package/scripts/ci/dot-cli-startup-bench.sh +126 -0
- package/scripts/ci/install-chezmoi-verified.sh +4 -1
- package/scripts/ci/lint-reusable-pins.sh +78 -0
- package/scripts/ci/run-coverage.sh +89 -0
- package/scripts/ci/windows-smoke-test.ps1 +136 -0
- package/scripts/diagnostics/doctor.sh +39 -9
- package/scripts/dot/commands/agent.sh +19 -22
- package/scripts/dot/commands/agents.sh +325 -0
- package/scripts/dot/commands/aliases.sh +10 -8
- package/scripts/dot/commands/core.sh +10 -4
- package/scripts/dot/commands/fleet.sh +278 -3
- package/scripts/dot/commands/init.sh +184 -0
- package/scripts/dot/commands/meta.sh +7 -4
- package/scripts/dot/commands/registry.sh +263 -0
- package/scripts/dot/commands/tools.sh +49 -1
- package/scripts/dot/lib/bento.sh +1 -1
- package/scripts/dot/lib/platform.sh +21 -8
- package/scripts/dot/lib/ui.sh +134 -2
- package/scripts/dot/lib/utils.sh +1 -1
- package/scripts/git-hooks/pre-commit-audit.sh +1 -1
- package/scripts/lib/secrets_provider.sh +32 -6
- package/scripts/ops/rollback.sh +14 -0
- package/scripts/security/check-disclosure-key-expiry.sh +110 -0
- package/scripts/security/lock-configs.sh +11 -2
- 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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
}
|