-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdotfiles_init.py
More file actions
executable file
·1597 lines (1409 loc) · 74.7 KB
/
Copy pathdotfiles_init.py
File metadata and controls
executable file
·1597 lines (1409 loc) · 74.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "questionary>=2.0",
# "rich>=13.9",
# "tyro>=0.9",
# ]
# ///
"""
dotfiles_init.py — Interactive wrapper around `chezmoi init` inspired by
Vercel Labs' `skills` CLI (https://github.com/vercel-labs/skills).
The existing `.chezmoi.toml.tmpl` defines the prompt set (profile, email,
name, feature flags, and preferences). `chezmoi init` asks them sequentially, which works but
hides the feature flags from each other and makes "set up a new machine
the way I always do" a manual checklist. This wrapper:
1. Pre-flights chezmoi / git / SSH key and offers to fix gaps.
2. Lets you pick a pre-configured BUNDLE (personal-mac, work-mac,
server-linux, minimal) that ticks typical feature flags for you.
3. Presents feature flags grouped, as a questionary multi-select you can
fly through with Space + Enter.
4. Shells out to `chezmoi init <repo> --apply [--ssh] --promptString ... `
so chezmoi remains the source of truth — we just pre-answer its prompts.
5. Streams chezmoi's output live and prints a recap at the end.
PROMPTS (below) is the SINGLE SOURCE OF TRUTH. The `gen` subcommand renders
the prompt block in `.chezmoi.toml.tmpl` and the ARG + flag blocks in
`Dockerfile` from it (between marker comments). `gen --check` (wired into
pre-commit) fails loudly if the on-disk files drift, so adding a prompt is
"edit PROMPTS, run `just gen-prompts`" rather than a manual three-file edit.
Invocation:
# Fresh machine (via bootstrap.sh which installs uv + chezmoi first):
curl -fsSL https://raw.githubusercontent.com/daviddwlee84/dotfiles/main/bootstrap.sh | bash
# Existing chezmoi source dir — re-apply with new answers:
uv run --script ~/.local/share/chezmoi/scripts/init/dotfiles_init.py
# Regenerate template + Dockerfile from PROMPTS (or --check for drift):
uv run --script ~/.local/share/chezmoi/scripts/init/dotfiles_init.py gen
uv run --script ~/.local/share/chezmoi/scripts/init/dotfiles_init.py gen --check
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Literal
import questionary
import tyro
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
DEFAULT_REPO = "daviddwlee84" # chezmoi expands to github.com/daviddwlee84/dotfiles
CHEZMOI_SOURCE_DIR = Path.home() / ".local" / "share" / "chezmoi"
CHEZMOI_CONFIG = Path.home() / ".config" / "chezmoi" / "chezmoi.toml"
SSH_DIR = Path.home() / ".ssh"
SSH_CONFIG = SSH_DIR / "config"
# Canonical names OpenSSH tries by default if no IdentityFile is set.
SSH_DEFAULT_KEYS = [
SSH_DIR / "id_ed25519",
SSH_DIR / "id_rsa",
SSH_DIR / "id_ecdsa",
]
console = Console()
# ---------------------------------------------------------------------------
# Prompt schema — THE single source of truth.
#
# `.chezmoi.toml.tmpl` (prompt block) and `Dockerfile` (ARG + flag block) are
# GENERATED from PROMPTS by the `gen` subcommand. To add / change a prompt:
# 1. Edit PROMPTS below (key, kind, prompt_text, default, condition, comment).
# 2. Run `just gen-prompts` (or `dotfiles_init.py gen`) to regenerate the
# template + Dockerfile between their marker regions.
# `gen --check` (wired into pre-commit) fails if the on-disk files drift.
# ---------------------------------------------------------------------------
PromptType = Literal["string", "bool", "choice"]
@dataclass(frozen=True)
class When:
"""Declarative gate for a prompt — the decision-tree primitive.
Each dimension is a set of allowed values; an empty set means
"unconstrained". Values WITHIN a dimension are OR'd, dimensions are
AND'd together. The dimensions map onto chezmoi template facts:
- ``os`` -> ``.chezmoi.os`` ("darwin" / "linux")
- ``arch`` -> ``.chezmoi.arch`` ("amd64" / "arm64")
- ``profile`` -> the init-time ``$profile`` local (NOT ``.profile``,
which doesn't exist yet while .chezmoi.toml.tmpl renders)
A single ``When`` drives all three consumers (template conditional, TUI
gating, Dockerfile is unconditional) so they can never disagree.
"""
os: frozenset[str] = frozenset()
profile: frozenset[str] = frozenset()
arch: frozenset[str] = frozenset()
def matches(self, os_name: str, profile: object, arch: str) -> bool:
if self.os and os_name not in self.os:
return False
if self.profile and profile not in self.profile:
return False
if self.arch and arch not in self.arch:
return False
return True
def to_go(self) -> str:
"""Render the Go-template boolean expression WITHOUT the surrounding
``{{ if }}`` — e.g. ``eq .chezmoi.os "darwin"`` or
``or (eq $profile "macos") (eq $profile "ubuntu_desktop")``."""
dim_exprs: list[str] = []
for var, values in (
(".chezmoi.os", self.os),
(".chezmoi.arch", self.arch),
("$profile", self.profile),
):
if not values:
continue
eqs = ['eq %s "%s"' % (var, v) for v in sorted(values)]
if len(eqs) == 1:
dim_exprs.append(eqs[0])
else:
dim_exprs.append("or " + " ".join("(%s)" % e for e in eqs))
if not dim_exprs:
return ""
if len(dim_exprs) == 1:
return dim_exprs[0]
return "and " + " ".join("(%s)" % e for e in dim_exprs)
@dataclass(frozen=True)
class Prompt:
key: str # chezmoi prompt name (2nd arg to promptXOnce)
kind: PromptType
group: str # UI grouping
label: str # short, shown in checkbox
desc: str # longer tooltip-style hint (shown under label)
default: object # python bool / str
# The exact PROMPT TEXT from .chezmoi.toml.tmpl (3rd arg to promptXOnce).
# This is what `chezmoi init --promptBool/--promptString/--promptChoice`
# matches against when populating `promptXOnce` values — NOT the key name.
# Verified empirically against chezmoi 2.68.0 / 2.69.4 / 2.70.2 on linux
# and darwin: only the prompt text form `--promptBool "<text>=true"` works;
# the key-name form silently fails over to interactive prompt.
#
# Since `gen` now RENDERS the template + Dockerfile from this field, the
# prompt text is authored here exactly once.
prompt_text: str = ""
choices: tuple[str, ...] = () # for kind=choice
# Conditionality: when `condition` is set, the prompt is only asked on
# hosts matching it; on non-matching hosts the template bakes `else_value`
# directly (no prompt) and the TUI hides the option.
condition: When | None = None
else_value: object = None
# The bilingual doc block emitted as `#` comments above the prompt in the
# generated .chezmoi.toml.tmpl. Lines are stored WITHOUT the leading "# ".
comment: str = ""
hidden: bool = False # basics (name/email/profile) handled specially
# Order matters — reflects both the UI order AND the order prompts are
# rendered into the generated .chezmoi.toml.tmpl block.
# prompt_text is the 3rd argument to promptXOnce; chezmoi init's
# `--promptBool "<text>=..."` matches on it.
#
# Profile -> (os, form-factor):
# macos=darwin/GUI · ubuntu_desktop=linux/GUI · ubuntu_server,centos_server=linux/headless
_DESKTOP_PROFILES = frozenset({"macos", "ubuntu_desktop"})
PROMPTS: tuple[Prompt, ...] = (
# --- Basics (special-cased in ask_basics) ----------------------------
Prompt("profile", "choice", "Basics", "Profile",
"Which profile this machine should use.",
default="", prompt_text="Which profile",
choices=("macos", "ubuntu_desktop", "ubuntu_server", "centos_server"),
hidden=True),
Prompt("email", "string", "Basics", "Git email",
"Your email address, used by Git and chezmoi templates.",
default="daviddwlee84@gmail.com",
prompt_text="What is your email address",
hidden=True),
Prompt("name", "string", "Basics", "Git name",
"Your full name, used by Git and chezmoi templates.",
default="Da-Wei Lee",
prompt_text="What is your full name",
hidden=True),
# --- Coding agents & AI ----------------------------------------------
Prompt("installCodingAgents", "bool", "Coding agents & AI",
"Coding agents",
"Claude Code, OpenCode, Cursor, Copilot, Gemini, etc.",
default=True,
prompt_text="Install coding agents (Claude Code, OpenCode, Cursor, Copilot, Gemini, etc.)",
comment="是否安裝 coding agents (Claude Code, OpenCode, Cursor, Copilot, Gemini, etc.)"),
Prompt("installLlmTools", "bool", "Coding agents & AI",
"Local LLM tools",
"Ollama, LiteLLM, llmfit and matching local models.",
default=False,
prompt_text="Install local LLM tools (Ollama, LiteLLM, llmfit, models)",
comment="是否安裝本地 LLM tools (Ollama, LiteLLM, llmfit, models)"),
Prompt("installAiDesktopApps", "bool", "Coding agents & AI",
"AI desktop apps (macOS)",
"Claude, ChatGPT, OpenCode, Antigravity, Codex, Ollama app via Brewfile.",
default=False,
condition=When(os=frozenset({"darwin"})), else_value=False,
prompt_text="Install AI desktop apps via macOS Homebrew Brewfile (Claude, ChatGPT, OpenCode, Antigravity, Codex, Ollama app)",
comment="是否安裝 macOS AI desktop apps via Homebrew Brewfile (Claude, ChatGPT, OpenCode, Antigravity, Codex, Ollama app)"),
# --- Dev tooling -----------------------------------------------------
Prompt("installPythonUvTools", "bool", "Dev tooling",
"Python CLI tools (via uv)",
"mlflow, sqlit-tui, tmuxp and other Python CLIs.",
default=True,
prompt_text="Install Python CLI tools via uv (mlflow, sqlit-tui, tmuxp, etc.)",
comment="是否安裝 Python CLI tools via uv (mlflow, sqlit-tui, tmuxp, etc.)"),
Prompt("installJsCliTools", "bool", "Dev tooling",
"JS / npm CLI tools",
"Standalone node CLIs like readability-cli.",
default=True,
prompt_text="Install standalone JS/npm CLI utilities (readability-cli for terminal web reader, etc.)",
comment="是否安裝 standalone JS/npm CLI utilities (readability-cli for `readnode`, etc.)"),
Prompt("installDotnetTools", "bool", "Dev tooling",
".NET SDK + dotnet tools",
".NET SDK via mise plus global tools (azure-cost-cli, etc.).",
default=False,
prompt_text="Install .NET SDK via mise and dotnet global tools (azure-cost-cli, etc.)",
comment="是否安裝 .NET SDK (via mise) 與 .NET global tools (azure-cost-cli, 等)"),
Prompt("installExtraRuntimes", "bool", "Dev tooling",
"Extra mise runtimes (rust, bun, ruby)",
"Rust toolchain + cargo tools, bun, ruby + gem tools via mise (~1.8GB). Node is always installed regardless (nvim LSP / npm agents need it).",
default=True,
prompt_text="Install extra mise runtimes (rust, bun, ruby) and their cargo/gem tools",
comment=("是否安裝額外的 mise runtimes (rust, bun, ruby) 與對應的 cargo/gem 工具(~1.8GB)。\n"
"Node 永遠會裝(nvim LSP / npm-based coding agents 硬依賴),不受此 flag 影響。\n"
"false 時 mise [tools] 只留 node,且 ansible TAGS 會剔除 rust_cargo_tools /\n"
"ruby_gem_tools(沒有 toolchain 跑了也只會失敗)。lean cloud VM / CI 建議 false。")),
Prompt("installAuditd", "bool", "System & apps",
"Linux audit framework (auditd)",
"Installs auditd + a baseline rule set (identity / sudoers / sshd_config / privileged-exec watches). Linux only — no-op on macOS. See docs/sysadmin/auditd.md.",
default=False,
condition=When(os=frozenset({"linux"})), else_value=False,
prompt_text="Install Linux audit framework (auditd) + baseline rules (identity / sudoers / sshd_config / privileged-exec watches)",
comment=("是否安裝 Linux Audit framework (auditd) + 基準規則集(identity / sudoers /\n"
"sshd_config / privileged-exec watch)。Linux only — macOS 自動跳過(macOS 用\n"
"OpenBSM,不是 auditd),所以在 darwin 上不問也直接 false。\n"
"詳見 docs/sysadmin/auditd.md 與 docs/playbooks/auditd.md。")),
Prompt("installHomelabTools", "bool", "System & apps",
"Homelab hardware-monitoring tools",
"Installs physical-server hardware CLIs (lm-sensors, smartmontools, ipmitool, nvme-cli, storcli), each gated on the matching hardware being present. Linux only; no-op on macOS and inside VMs. See docs/sysadmin/hardware.md.",
default=False,
condition=When(os=frozenset({"linux"})), else_value=False,
prompt_text="Install homelab hardware-monitoring tools (lm-sensors / smartmontools / ipmitool / nvme-cli / storcli), gated on detected hardware",
comment=("是否安裝 homelab 硬體監控 CLI(lm-sensors / smartmontools / ipmitool /\n"
"nvme-cli / storcli)。每個工具還會依實際偵測到的硬體決定要不要裝(沒有\n"
"MegaRAID 控制器就不裝 storcli、沒有 NVMe 就不裝 nvme-cli…),避免裝一堆\n"
"用不到的東西。Linux only — macOS 與 VM 內自動跳過。\n"
"詳見 docs/sysadmin/hardware.md。")),
Prompt("installResilioSync", "bool", "System & apps",
"Resilio Sync (P2P file sync)",
"Installs Resilio Sync for AirDrop-style cross-system transfer and phone→NAS photo backup. macOS: GUI app via Homebrew cask. Linux (desktop + server): headless daemon (no GUI) run as a per-user systemd service, configured via WebUI at 127.0.0.1:8888. See docs/tools/resilio-sync.md.",
default=False,
prompt_text="Install Resilio Sync (P2P file sync — GUI cask on macOS, headless WebUI daemon on Linux/servers)",
comment=("是否安裝 Resilio Sync(P2P 檔案同步,類似 AirDrop 的跨機傳輸、手機照片備份到\n"
"NAS)。macOS 走 Homebrew cask 的 GUI app;Linux(桌機與 server 皆同)沒有 GUI,\n"
"以 per-user systemd service 跑成背景 daemon,透過 127.0.0.1:8888 的 WebUI 設定\n"
"(headless server 用 SSH tunnel)。詳見 docs/tools/resilio-sync.md。")),
Prompt("installWakeOnLan", "bool", "System & apps",
"Wake-on-LAN (power this box on remotely)",
"Arms this machine's wired NIC(s) to wake from a magic packet and persists it across reboots via a wol@<iface> systemd unit (installs ethtool). Linux only; no-op on macOS and inside VMs. Wake-from-full-shutdown (S5) also needs BIOS 'Power On By PCI-E' on + 'ErP Ready' off. The sender side is the `wake` CLI / wakeonlan. See docs/sysadmin/wake-on-lan.md.",
default=False,
condition=When(os=frozenset({"linux"})), else_value=False,
prompt_text="Arm Wake-on-LAN on wired NIC(s) so this box can be powered on remotely by a magic packet (persisted via a systemd unit)",
comment=("是否啟用 Wake-on-LAN(被動端):把有線網卡 arm 成可被 magic packet 喚醒,\n"
"並用 wol@<iface> 的 systemd unit 讓設定在重開機後自動套用(會裝 ethtool)。\n"
"Linux only — macOS 與 VM 內自動跳過。從「完全關機 (S5)」喚醒還需要 BIOS\n"
"開 'Power On By PCI-E'、關 'ErP Ready'(韌體設定 Ansible 無法代勞)。\n"
"主動端(發封包)用 `wake` CLI 或 wakeonlan。詳見 docs/sysadmin/wake-on-lan.md。")),
Prompt("installIacTools", "bool", "Dev tooling",
"Infrastructure-as-Code tools",
"Azure CLI, Terraform, OpenTofu.",
default=False,
prompt_text="Install Infrastructure-as-Code tools (Azure CLI, Terraform, OpenTofu)",
comment="是否安裝 Infrastructure-as-Code 工具 (Azure CLI, Terraform, OpenTofu)"),
Prompt("installMediaTools", "bool", "Dev tooling",
"Media / AV CLI tools",
"ffmpeg, ImageMagick, exiftool, libvips. ffmpeg is also vhs's runtime dep.",
default=False,
prompt_text="Install media/AV CLI tools (ffmpeg, ImageMagick, exiftool, libvips)",
comment=("是否安裝影音/媒體 CLI 工具 (ffmpeg, ImageMagick, exiftool, libvips)\n"
"ffmpeg 也是 vhs 的 runtime 依賴;裝了影音包之後 vhs 才能真正錄製。詳見 docs/tools/ffmpeg.md")),
# --- System & apps ---------------------------------------------------
Prompt("installBitwarden", "bool", "System & apps",
"Bitwarden CLI + Desktop",
"@bitwarden/cli with SSH Agent integration and Zsh completion. On ubuntu_desktop / macOS profiles, also installs Bitwarden Desktop (snap or .deb fallback on Linux, Homebrew Cask on macOS).",
default=False,
prompt_text="Install Bitwarden CLI (and Desktop on ubuntu_desktop/macOS — snap or .deb on Linux, Cask on macOS) with SSH Agent integration",
comment=("是否安裝 Bitwarden CLI(+ SSH Agent 自動偵測 + Zsh completion);在\n"
"ubuntu_desktop / macOS profile 上同時也會裝 Bitwarden Desktop(snap 優先、\n"
".deb fallback / Homebrew Cask)。詳細邏輯見 docs/playbooks/linux-gui-apps.md\n"
"和 dot_ansible/roles/bitwarden/。")),
Prompt("installMediaControl", "bool", "System & apps",
"System media-control CLIs",
"nowplaying-cli + switchaudio-osx (macOS), playerctl (Linux). Unlocks full sysplay/sysnow + output-device switching; built-in sysvol/sysmute work without it.",
default=False,
prompt_text="Install system media-control CLIs (nowplaying-cli/switchaudio-osx on macOS, playerctl on Linux) for the sys* shell helpers",
comment=("是否安裝系統媒體控制 CLI(macOS: nowplaying-cli, switchaudio-osx;Linux: playerctl)。\n"
"搭配 sys* shell helpers(sysvol/sysmute/sysplay/sysnow)。不裝也能用 built-in\n"
"的 sysvol/sysmute(音量/靜音);裝了才有完整 sysplay/sysnow(播放控制/查正在播放)。\n"
"詳見 docs/tools/media-control.md。")),
Prompt("installBrewApps", "bool", "System & apps",
"Homebrew GUI apps",
"Terminals, browsers, utilities via Brewfile (excl. AI desktop). Desktop-class profiles only (macos / ubuntu_desktop).",
default=False,
condition=When(profile=_DESKTOP_PROFILES), else_value=False,
prompt_text="Install general GUI apps via Homebrew Brewfile (terminals, browsers, utilities, etc.; excludes AI desktop apps)",
comment=("是否安裝一般 GUI apps via Homebrew Brewfile (terminals, browsers, utilities, etc.; excludes AI desktop apps)\n"
"Desktop-class only(macos / ubuntu_desktop);headless server 沒有 GUI,直接 false。")),
Prompt("installGamingApps", "bool", "System & apps",
"Gaming apps",
"Steam for desktop profiles: Homebrew cask on macOS, Valve apt repo on Ubuntu Desktop.",
default=False,
condition=When(profile=_DESKTOP_PROFILES), else_value=False,
prompt_text="Install gaming apps (Steam)",
comment=("是否安裝遊戲相關桌面應用(目前先裝 Steam)。Desktop-class only(macos /\n"
"ubuntu_desktop);macOS 走 Homebrew Cask,Ubuntu Desktop 走 Valve 官方 apt repo /\n"
"steam-launcher。headless server 直接 false。")),
Prompt("installInputMethod", "bool", "System & apps",
"Traditional Chinese IME",
"McBopomofo + RIME for zh-TW input. Desktop-class profiles only (macos / ubuntu_desktop).",
default=False,
condition=When(profile=_DESKTOP_PROFILES), else_value=False,
prompt_text="Install Traditional Chinese input methods (McBopomofo, RIME)",
comment=("是否安裝繁體中文輸入法 (McBopomofo + RIME)\n"
"Desktop-class only(macos / ubuntu_desktop);headless server 沒有 GUI,直接 false。")),
Prompt("discordChannel", "choice", "System & apps",
"Discord install channel",
"ubuntu_desktop only (macOS → Brewfile cask, ubuntu_server → skipped). flatpak (recommended): Flathub auto-updates via `flatpak update`. deb: official .deb, manual re-deploy each release. none: skip.",
default="flatpak",
condition=When(profile=frozenset({"ubuntu_desktop"})), else_value="none",
prompt_text="Discord install channel (flatpak|deb|none)",
choices=("flatpak", "deb", "none"),
comment=("Discord channel: 哪一條安裝路徑(ubuntu_desktop only — macOS 走 Brewfile cask,\n"
"ubuntu_server 沒有 GUI 不需要)\n"
" - flatpak: 推薦,Flathub 走 com.discordapp.Discord,自動更新\n"
" - deb: 官方 .deb,每次版本更新需手動跑 `chezmoi apply` 重抓\n"
" - none: 完全不裝,留給使用者自行處理\n"
"詳細決策 + sandbox 副作用見 docs/playbooks/linux-gui-apps.md\n"
"注意: 這裡引用前面定義的 $profile local variable(不是 .profile,因為\n"
".chezmoi.toml.tmpl 正在產生 .profile,init 階段 .profile 還不存在)")),
Prompt("installNiri", "bool", "System & apps",
"niri Wayland compositor",
"Scrollable-tiling Wayland compositor, built from source (cargo). ubuntu_desktop only — not in Ubuntu 24.04 apt. Installs binary + GDM session/systemd files; NVIDIA gets a VRAM application-profile automatically. See docs/playbooks/niri.md.",
default=False,
condition=When(profile=frozenset({"ubuntu_desktop"})), else_value=False,
prompt_text="Install niri (scrollable-tiling Wayland compositor, built from source)",
comment=("是否安裝 niri (scrollable-tiling Wayland 合成器),從源碼 cargo build。ubuntu_desktop\n"
"only — macOS 無 Wayland、ubuntu_server 無 GUI,皆直接 false。NVIDIA 在現代驅動上開箱\n"
"即用,niri role 會自動寫入 VRAM application-profile(GLVidHeapReuseRatio=0)。\n"
"詳見 dot_ansible/roles/niri/ 與 docs/playbooks/niri.md")),
Prompt("installNetworkingTools", "bool", "System & apps",
"Networking CLI tools",
"nmap, mtr, httpie, gping, trippy.",
default=False,
prompt_text="Install networking CLI tools (nmap, mtr, httpie, gping, trippy, etc.)",
comment="是否安裝網路診斷工具 (nmap, mtr, httpie, gping, trippy, etc.)"),
Prompt("installTunnelTools", "bool", "System & apps",
"Tunnel tools (ngrok, cloudflared)",
"Expose localhost / SSH reverse tunnels via ngrok and cloudflared.",
default=False,
prompt_text="Install tunnel tools (ngrok, cloudflared — expose localhost, SSH tunnels)",
comment="是否安裝 tunnel 工具 (ngrok, cloudflared) — expose localhost / SSH reverse tunnel"),
# --- Preferences -----------------------------------------------------
Prompt("useChineseMirror", "bool", "Preferences",
"Use China (GFW) mirrors",
"Switch Homebrew / pip / npm / etc. to China-hosted mirrors.",
default=False,
prompt_text="Are you in China (behind GFW) and need to use mirrors",
comment="是否在中國大陸 GFW 內,需要使用鏡像源"),
Prompt("gitleaksAllRepos", "bool", "Preferences",
"Gitleaks on all repos",
"Scan secrets even for repos without .pre-commit-config.yaml.",
default=False,
prompt_text="Enable gitleaks for ALL git repos (not just those with .pre-commit-config.yaml)",
comment="是否對所有 repo 啟用 gitleaks 掃描(包含沒有 .pre-commit-config.yaml 的 repo)"),
Prompt("backupMode", "choice", "Preferences",
"Backup mode for existing dotfiles",
"smart = only files chezmoi will overwrite (uses `chezmoi status`); full = hardcoded allowlist (onboarding mode); off = skip.",
default="smart",
prompt_text="Backup mode for existing dotfiles (smart|full|off)",
choices=("smart", "full", "off"),
comment=("在 chezmoi apply 之前備份現有的 dotfiles。\n"
"smart = 只備份 chezmoi 將要覆蓋/刪除的檔案(用 `chezmoi status` 偵測)\n"
"full = 備份固定 allowlist(onboard 第一次最保險)\n"
"off = 完全跳過(CI / Docker / 一次性環境用)")),
Prompt("allowPartialFailure", "bool", "Preferences",
"Allow partial Ansible failures",
"Continue other roles if one role fails.",
default=False,
prompt_text="Allow partial Ansible failures (continue installing other tools if one role fails)",
comment="允許 Ansible 部分失敗(逐 tag 執行,單一 role 失敗不影響其餘)"),
Prompt("noRoot", "bool", "Preferences",
"No sudo / root access",
"Skip all system package installations (user-level tools only). Linux only — macOS users are always admins.",
default=False,
condition=When(os=frozenset({"linux"})), else_value=False,
prompt_text="No sudo/root access - skip all system package installations",
comment=("沒有 sudo/root 權限,只安裝 user-level 工具 (mise, cargo, gem, uv, npm)\n"
"Linux only — macOS 上一律有 admin 權限,darwin 直接 false 不問。")),
Prompt("motdStyle", "choice", "Preferences",
"SSH login banner style",
"figlet (~6 lines, ~5ms) | fastfetch-slim (figlet + fastfetch slim, ~10 lines, ~80ms) | fastfetch-full (full distro logo + everything, ~22 lines, ~150ms). Runtime override: MOTD_STYLE in ~/.zshrc.adhoc.",
default="figlet",
prompt_text="SSH login banner style (figlet|fastfetch-slim|fastfetch-full)",
choices=("figlet", "fastfetch-slim", "fastfetch-full"),
comment=("SSH login banner (~/.zlogin) 樣式 — 詳見 docs/zsh/motd.md\n"
" - figlet: 單行 figlet hostname + metadata(~6 行,~5ms,預設)\n"
" - fastfetch-slim: figlet hostname + fastfetch 精簡版(~10 行,~80ms)\n"
" - fastfetch-full: fastfetch 完整輸出含 distro logo(~22 行,~150ms)\n"
"Runtime override: 在 ~/.zshrc.adhoc 設 MOTD_STYLE=... 可即時切換無需 chezmoi init --force")),
Prompt("primaryShell", "choice", "Preferences",
"Primary interactive shell",
"Decides which shell `chsh` switches to. Both ~/.zshrc and ~/.bashrc are deployed regardless (so the other shell still works ad-hoc). Bash side ships with oh-my-bash + ble.sh for UX close to zsh; on macOS the bash role brew-installs bash 5.x when bash is selected. See docs/shells/bash.md.",
default="zsh",
prompt_text="Primary interactive shell (zsh|bash)",
choices=("zsh", "bash"),
comment=("Primary interactive shell — decides which shell `chsh` switches to as the\n"
"login shell. Both ~/.zshrc and ~/.bashrc are deployed regardless (so the\n"
"other shell still works ad-hoc); only the login-shell switch is gated.\n"
"Bash side ships with oh-my-bash + ble.sh for a UX close to zsh; on macOS\n"
"the bash role also brew-installs bash 5.x (system bash 3.2 is too old for\n"
"OMB plugins) when bash is selected. See docs/shells/bash.md.")),
Prompt("enableVimMode", "bool", "Preferences",
"Vim mode in shells & tmux",
"zsh-vi-mode plugin, bash `set -o vi`, ble.sh vi_imap/vi_nmap, tmux mode-keys vi, vim-tmux-navigator C-h/j/k/l. Does NOT touch Neovim or editor configs (VSCode/Cursor/Codex/OpenCode). Full catalog: docs/this_repo/vim-mode.md.",
default=True,
prompt_text="Enable vim-style modal editing in shells (zsh-vi-mode, set -o vi, ble.sh vi-mode) and tmux vim navigation (vim-tmux-navigator C-h/j/k/l, mode-keys vi); does NOT affect Neovim",
comment=("Enable vim-style modal editing in shells (zsh-vi-mode plugin, bash `set -o vi`,\n"
"ble.sh vi_imap/vi_nmap keymap) and tmux vim navigation (mode-keys vi,\n"
"copy-mode-vi table, vim-tmux-navigator C-h/j/k/l). Default true (this repo's\n"
"historical behavior). Set false for non-vim users / CI / Docker.\n"
"Does NOT touch Neovim (~/.config/nvim/) — Neovim is always vim-flavored.\n"
"Editor configs (VSCode/Cursor/Antigravity/Codex/OpenCode) are also not gated.\n"
"Full catalog of what changes: docs/this_repo/vim-mode.md")),
)
# ---------------------------------------------------------------------------
# Bundles — named override dicts. Anything not listed keeps the prompt's
# own default. A bundle CAN override basics (name/email/profile) but usually
# leaves those as defaults so the per-machine input still works.
# ---------------------------------------------------------------------------
BUNDLES: dict[str, dict[str, object]] = {
"personal-mac": {
"installCodingAgents": True,
"installLlmTools": True,
"installAiDesktopApps": True,
"installPythonUvTools": True,
"installJsCliTools": True,
"installExtraRuntimes": True,
"installBitwarden": True,
"installBrewApps": True,
"installNetworkingTools": True,
"installMediaTools": True,
"backupMode": "smart",
},
"work-mac": {
"installCodingAgents": True,
"installPythonUvTools": True,
"installJsCliTools": True,
"installExtraRuntimes": True,
"installBrewApps": True,
"installGamingApps": False,
"backupMode": "smart",
# deliberately off: installLlmTools, installAiDesktopApps, installBitwarden, installGamingApps
},
"server-linux": {
"installCodingAgents": True,
"installPythonUvTools": True,
"installJsCliTools": True,
"installExtraRuntimes": True,
"installNetworkingTools": True,
"installAuditd": True,
"installHomelabTools": True,
"installWakeOnLan": True,
"installGamingApps": False,
"backupMode": "smart",
# GUI / desktop flags stay off; noRoot stays false (needs sudo to apt-get).
},
"cloud-vm": {
# Lean throwaway / cloud dev VM: ergonomic shell + tmux + nvim +
# coding agents, nothing heavier. Pairs with `just az-dev-vm`
# (scripts/azure/dev_vm.py) which passes --bundle cloud-vm during the
# remote non-interactive bootstrap. Compared to server-linux this
# drops uv/js/networking/auditd extras AND the ~1.8GB of extra mise
# runtimes (rust/bun/ruby + dotnet) — node stays (nvim/agents).
"installCodingAgents": True,
"installLlmTools": False,
"installPythonUvTools": False,
"installJsCliTools": False,
"installExtraRuntimes": False,
"installDotnetTools": False,
"installIacTools": False,
"installBitwarden": False,
"installNetworkingTools": False,
"installMediaTools": False,
"installGamingApps": False,
"installAuditd": False,
"installHomelabTools": False,
"installWakeOnLan": False,
"backupMode": "off", # fresh VM — nothing worth backing up
},
"minimal": {
# Dotfiles only — every installX forced off so `chezmoi apply` in CI /
# Docker does the minimum work possible. Note this overrides the
# prompt-level defaults (which have coding-agents / python-uv /
# js-cli turned ON for fresh personal machines, and backupMode=smart).
"installCodingAgents": False,
"installLlmTools": False,
"installAiDesktopApps": False,
"installPythonUvTools": False,
"installJsCliTools": False,
"installExtraRuntimes": False,
"installDotnetTools": False,
"installIacTools": False,
"installBitwarden": False,
"installBrewApps": False,
"installGamingApps": False,
"installInputMethod": False,
"installNiri": False,
"installNetworkingTools": False,
"installMediaTools": False,
"installAuditd": False,
"installHomelabTools": False,
"installWakeOnLan": False,
"useChineseMirror": False,
"gitleaksAllRepos": False,
"backupMode": "off",
"allowPartialFailure": False,
"noRoot": False,
# Vim mode off: CI / Docker shells don't benefit from modal editing,
# and emacs keymap matches what most non-interactive automation
# expects (Ctrl+L = clear, Ctrl+H = backspace, no mode flicker).
"enableVimMode": False,
},
"custom": {
# Alias for "no overrides applied" — user ticks everything themselves.
},
}
# ---------------------------------------------------------------------------
# Pre-flight
# ---------------------------------------------------------------------------
@dataclass
class Preflight:
chezmoi: str | None
git: str | None
ssh_hint: str | None # how we detected SSH is ready (or None)
source_exists: bool
os_name: str
arch: str # normalized to chezmoi's .chezmoi.arch (amd64/arm64)
is_tty: bool
chezmoi_is_snap: bool = False # resolved chezmoi lives under /snap (see _resolve_chezmoi)
def _normalize_arch(machine: str) -> str:
"""Map platform.machine() onto chezmoi's GOARCH-style .chezmoi.arch."""
return {
"x86_64": "amd64", "amd64": "amd64",
"aarch64": "arm64", "arm64": "arm64",
}.get(machine.lower(), machine.lower())
def _prompt_applies(p: Prompt, os_name: str, profile: object, arch: str) -> bool:
"""True if `p` should be asked on this host (no condition = always)."""
return p.condition is None or p.condition.matches(os_name, profile, arch)
def read_current_config() -> dict[str, object]:
"""Return the `[data]` table of ~/.config/chezmoi/chezmoi.toml as a dict
of {prompt_key: current_value}, or {} if the config doesn't exist / can't
be parsed. This is what `reconfigure` seeds the TUI with so you toggle
deltas from the live state instead of from prompt defaults."""
if not CHEZMOI_CONFIG.exists():
return {}
try:
return tomllib.loads(CHEZMOI_CONFIG.read_text()).get("data", {}) or {}
except (OSError, tomllib.TOMLDecodeError):
return {}
def _detect_ssh() -> str | None:
"""Return a short hint describing why we think SSH-to-github is configured,
or None if we can't tell. We accept several signals because the user may
keep keys under non-canonical names and route them via ~/.ssh/config or
its Include'd fragments."""
# (1) Canonical default keys.
for k in SSH_DEFAULT_KEYS:
if k.exists():
return f"default key at {k}"
# (2) ~/.ssh/config (or an Include'd file under ~/.ssh/config.d/) with a
# `Host github…` stanza → user is routing a custom key.
config_texts: list[str] = []
for root in (SSH_CONFIG, *((SSH_DIR / "config.d").glob("*") if (SSH_DIR / "config.d").is_dir() else ())):
if root.is_file():
try:
config_texts.append(root.read_text(errors="replace"))
except OSError:
pass
joined = "\n".join(config_texts)
if re.search(r"(?mi)^\s*Host\s+[\w.\-\* ]*github", joined):
return "~/.ssh/config routes github.com"
# (3) Any loaded key in the agent (default or via IdentityAgent socket).
try:
r = subprocess.run(["ssh-add", "-l"], capture_output=True, text=True, timeout=3)
if r.returncode == 0 and r.stdout.strip():
return "ssh-agent has keys loaded"
except (OSError, subprocess.SubprocessError):
pass
return None
def detect() -> Preflight:
import platform
chezmoi_bin, is_snap = _resolve_chezmoi()
return Preflight(
chezmoi=chezmoi_bin,
git=shutil.which("git"),
ssh_hint=_detect_ssh(),
source_exists=(CHEZMOI_SOURCE_DIR / ".git").is_dir(),
os_name={"Darwin": "darwin", "Linux": "linux"}.get(platform.system(), platform.system().lower()),
arch=_normalize_arch(platform.machine()),
is_tty=sys.stdin.isatty() and sys.stdout.isatty(),
chezmoi_is_snap=is_snap,
)
def print_preflight(pf: Preflight) -> None:
t = Table(title="Preflight", show_header=False, box=None, padding=(0, 2))
t.add_column(); t.add_column()
if pf.chezmoi and pf.chezmoi_is_snap:
chezmoi_cell = f"⚠ [yellow]{pf.chezmoi} (snap — stdin/stdout bug; will install ~/.local/bin/chezmoi)[/yellow]"
elif pf.chezmoi:
chezmoi_cell = "✓ " + pf.chezmoi
else:
chezmoi_cell = "✗ [red]not found[/red]"
t.add_row("chezmoi", chezmoi_cell)
t.add_row("git", "✓ " + pf.git if pf.git else "✗ [red]not found[/red]")
t.add_row("SSH", f"✓ {pf.ssh_hint}" if pf.ssh_hint else "✗ [yellow]no signal found[/yellow]")
t.add_row("chezmoi source", "✓ present (re-init mode)" if pf.source_exists else "– fresh init")
console.print(t)
console.print()
def _resolve_chezmoi() -> tuple[str | None, bool]:
"""Resolve the chezmoi binary, preferring ~/.local/bin over a snap install.
The snap build of chezmoi has a long-standing stdin/stdout
`permission denied` bug (chezmoi.io troubleshooting FAQ) that breaks this
repo's modify_ / run_ scripts, and chezmoi upstream recommends installing
via get.chezmoi.io instead. So we prefer ~/.local/bin/chezmoi, and when the
only binary on PATH lives under /snap we flag it for replacement.
Returns (path or None, is_snap)."""
local = Path.home() / ".local" / "bin" / "chezmoi"
if local.exists():
return str(local), False
found = shutil.which("chezmoi")
if not found:
return None, False
return found, found.startswith("/snap/") or "/snap/" in found
def install_chezmoi_interactive(os_name: str) -> str:
"""Offer to install chezmoi via the canonical one-liner; return new path."""
if not questionary.confirm("chezmoi not found. Install via get.chezmoi.io/lb?", default=True).ask():
console.print("[red]chezmoi is required; aborting.[/red]")
sys.exit(1)
target = Path.home() / ".local" / "bin"
target.mkdir(parents=True, exist_ok=True)
subprocess.check_call(
"curl -fsLS --retry 3 --retry-delay 5 get.chezmoi.io/lb | sh",
shell=True,
env={**os.environ, "BINDIR": str(target)},
)
# Prefer the freshly-installed ~/.local/bin binary over any snap/brew one
# that PATH might resolve first.
local = target / "chezmoi"
path = str(local) if local.exists() else (shutil.which("chezmoi") or str(local))
console.print(f"[green]chezmoi installed at {path}[/green]\n")
return path
def ensure_ssh_key_interactive() -> Path | None:
"""Offer to generate an ed25519 key; return path or None if user declined."""
if not questionary.confirm(
"No SSH key found. Generate ~/.ssh/id_ed25519 for GitHub (SSH clone)?",
default=True,
).ask():
return None
key_path = Path.home() / ".ssh" / "id_ed25519"
key_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
email = questionary.text(
"Email to tag the SSH key with:",
default="daviddwlee84@gmail.com",
).ask() or "daviddwlee84@gmail.com"
subprocess.check_call(["ssh-keygen", "-t", "ed25519", "-C", email, "-f", str(key_path), "-N", ""])
pub = key_path.with_suffix(".pub").read_text().strip()
console.print(Panel(
f"[bold]Add this public key to GitHub → Settings → SSH keys:[/bold]\n\n{pub}\n\n"
f"Open: https://github.com/settings/ssh/new",
title="SSH key generated",
border_style="green",
))
questionary.press_any_key_to_continue("Press any key once you've added the key to GitHub...").ask()
return key_path
# ---------------------------------------------------------------------------
# Prompting flow
# ---------------------------------------------------------------------------
def ask_bundle() -> str:
choices = [
questionary.Choice(
title=f"{name:<14} — {summary}",
value=name,
)
for name, summary in [
("personal-mac", "full personal setup (AI desktop apps, LLM tools, Brewfile)"),
("work-mac", "safer: coding agents + dev tooling, no personal AI apps"),
("server-linux", "headless linux — coding agents, networking, dev tooling"),
("cloud-vm", "lean throwaway VM — shell + tmux + nvim + coding agents only"),
("minimal", "dotfiles only, no installX flags"),
("custom", "no overrides — tick every feature yourself"),
]
]
return questionary.select(
"Pick a bundle:",
choices=choices,
default=choices[0],
).ask() or "custom"
def ask_basics(pf: Preflight, overrides: dict[str, object]) -> dict[str, object]:
# profile: auto-detect on Darwin; ask ubuntu_desktop vs ubuntu_server on Linux
default_profile = "macos" if pf.os_name == "darwin" else "ubuntu_server"
if pf.os_name == "darwin":
profile = "macos"
console.print(f"[dim]profile → {profile} (auto-detected)[/dim]")
else:
# All non-macOS profile choices (keep server first as the common default).
linux_choices = ["ubuntu_server", "ubuntu_desktop", "centos_server"]
profile = questionary.select(
"Which profile?",
choices=linux_choices,
default=overrides.get("profile", default_profile),
).ask() or default_profile
name = questionary.text(
"Your full name:",
default=str(overrides.get("name", _default_for("name"))),
).ask() or _default_for("name")
email = questionary.text(
"Your git email:",
default=str(overrides.get("email", _default_for("email"))),
).ask() or _default_for("email")
return {"profile": profile, "name": name, "email": email}
def resolve_basics_non_interactive(
pf: Preflight,
overrides: dict[str, object],
*,
name_flag: str | None,
email_flag: str | None,
profile_flag: str | None,
) -> dict[str, object]:
"""Non-interactive basics: flag → bundle override → embedded default → OS auto-detect."""
if profile_flag:
profile = profile_flag
elif "profile" in overrides:
profile = overrides["profile"]
else:
profile = "macos" if pf.os_name == "darwin" else "ubuntu_server"
name = name_flag or overrides.get("name") or _default_for("name")
email = email_flag or overrides.get("email") or _default_for("email")
return {"profile": profile, "name": name, "email": email}
def resolve_features_non_interactive(
pf: Preflight, overrides: dict[str, object], profile: object
) -> dict[str, bool]:
"""Non-interactive features: bundle override → embedded default, skipping
prompts whose `condition` doesn't match this host."""
result: dict[str, bool] = {}
for p in PROMPTS:
if p.kind != "bool" or p.hidden:
continue
if not _prompt_applies(p, pf.os_name, profile, pf.arch):
continue
result[p.key] = bool(overrides.get(p.key, p.default))
return result
def _applicable_choice_prompts(pf: Preflight, profile: object):
"""Yield the non-hidden choice prompts chezmoi will actually prompt for on
this host. Gated prompts (e.g. discordChannel on ubuntu_desktop) are baked
to their else_value by the template elsewhere, so we must NOT pre-answer
them off-host."""
for p in PROMPTS:
if p.kind != "choice" or p.hidden:
continue
if not _prompt_applies(p, pf.os_name, profile, pf.arch):
continue
yield p
def resolve_choices_non_interactive(
pf: Preflight, overrides: dict[str, object], profile: object
) -> dict[str, object]:
"""Non-interactive choice prompts: bundle override → embedded default."""
result: dict[str, object] = {}
for p in _applicable_choice_prompts(pf, profile):
result[p.key] = overrides.get(p.key, p.default)
return result
def ask_choices(
pf: Preflight, overrides: dict[str, object], profile: object
) -> dict[str, object]:
"""Interactive single-select for each applicable non-basics choice prompt
(backupMode, motdStyle, primaryShell, and discordChannel on ubuntu_desktop).
Without this, chezmoi falls back to prompting them itself mid-apply."""
result: dict[str, object] = {}
prompts = list(_applicable_choice_prompts(pf, profile))
if not prompts:
return result
console.print()
console.print("[bold]Other choices[/bold]")
for p in prompts:
default = overrides.get(p.key, p.default)
answer = questionary.select(
f"{p.label}?",
choices=list(p.choices),
default=default if default in p.choices else None,
).ask()
result[p.key] = answer if answer is not None else default
return result
def ask_features(pf: Preflight, overrides: dict[str, object], profile: object) -> dict[str, bool]:
"""Grouped multi-select. Returns {key: bool} for every applicable bool prompt."""
feature_prompts = [p for p in PROMPTS if p.kind == "bool" and not p.hidden
and _prompt_applies(p, pf.os_name, profile, pf.arch)]
groups: dict[str, list[Prompt]] = {}
for p in feature_prompts:
groups.setdefault(p.group, []).append(p)
# Build one flat checkbox preserving group order, with group headers as
# disabled separator rows (purely visual).
choices: list[questionary.Choice] = []
for group, prompts in groups.items():
choices.append(questionary.Separator(f"── {group} ──"))
for p in prompts:
initial = overrides.get(p.key, p.default)
choices.append(questionary.Choice(
title=f"{p.label} — {p.desc}",
value=p.key,
checked=bool(initial),
))
console.print()
console.print("[bold]Feature flags[/bold] — Space to toggle, ↑/↓ to move, Enter to confirm.")
selected_keys = questionary.checkbox(
"Which features?",
choices=choices,
).ask() or []
result: dict[str, bool] = {}
for p in feature_prompts:
result[p.key] = p.key in selected_keys
return result
def confirm_plan(answers: dict[str, object], argv: list[str]) -> bool:
t = Table(title="Answers", show_header=False, box=None, padding=(0, 2))
t.add_column("Key", style="cyan"); t.add_column("Value")
for p in PROMPTS:
if p.key not in answers:
continue
val = answers.get(p.key)
style = "green" if val is True else ("dim" if val is False else "")
t.add_row(p.key, f"[{style}]{val}[/{style}]" if style else str(val))
console.print(t)
console.print()
console.print(Panel(
" ".join(argv),
title="Command about to run",
border_style="blue",
))
return questionary.confirm("Proceed?", default=True).ask() or False
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
def build_chezmoi_argv(
answers: dict[str, object],
*,
chezmoi_bin: str,
repo: str | None,
use_ssh: bool,
apply: bool,
prompt: bool = False,
) -> list[str]:
"""Build the chezmoi init argv. The critical detail is that `chezmoi init`
matches --promptBool / --promptString / --promptChoice flags by the PROMPT
TEXT (3rd arg to promptXOnce in the template), NOT the key name. That's
why every Prompt carries a prompt_text field and we use it here as the
flag key. We only emit flags for prompts present in `answers` (i.e. the
ones applicable to this host); chezmoi harmlessly ignores extras anyway.
`prompt=True` adds `--prompt`, which FORCES the `prompt*Once` template
functions to prompt again. This is mandatory when re-initializing an
already-configured machine: without it, chezmoi reads the existing value
from ~/.config/chezmoi/chezmoi.toml's [data] and our --promptX flags are
silently ignored (so the answers never actually change). With --prompt,
every Once call re-fires and our flags satisfy them non-interactively."""
argv: list[str] = [chezmoi_bin, "init"]
if repo:
argv.append(repo)
if use_ssh:
argv.append("--ssh")
if apply:
argv.append("--apply")
if prompt:
argv.append("--prompt")
by_key = {p.key: p for p in PROMPTS}
# String + choice prompts (basics).
for key in ("email", "name"):
if key in answers:
argv += ["--promptString", f"{by_key[key].prompt_text}={answers[key]}"]
if "profile" in answers:
argv += ["--promptChoice", f"{by_key['profile'].prompt_text}={answers['profile']}"]
# Emit a flag for EVERY non-hidden prompt — not just the host-applicable
# ones. Host-gated prompts whose `condition` doesn't match this machine are
# absent from `answers`; we still pass their `else_value` so the flag set is
# COMPLETE. This matters under --prompt (re-init / reconfigure), which
# re-fires *all* promptXOnce: if the template's `$profile` ever differs from
# the profile we resolved (e.g. a desktop→server migration mid-reconfigure),
# a gated promptBoolOnce can still execute, and without a matching flag it
# falls through to an interactive prompt mid-apply (the very leak this fixes:
# `Install general GUI apps…?` / `Install Traditional Chinese IME?`). chezmoi
# silently ignores any flag whose prompt the template never calls, so the
# extra flags are harmless on hosts where the else-branch is baked instead.
def _flag_value(p: Prompt) -> object | None:
if p.key in answers:
return answers[p.key]
if p.condition is not None: # gated-off here → bake else_value
return p.else_value
return None # applicable but unanswered → skip
# Non-basics choice prompts (e.g. discordChannel, backupMode, motdStyle).
for p in PROMPTS:
if p.kind != "choice" or p.hidden:
continue
val = _flag_value(p)
if val is None:
continue
argv += ["--promptChoice", f"{p.prompt_text}={val}"]
# Bool prompts.
for p in PROMPTS:
if p.kind != "bool" or p.hidden: