8.9 KiB
Plan 3a — Install Modules (Simple/Medium Tier) Design
Status: Approved 2026-04-14 Scope: Port 10 of the 13 Bash install modules to Go. Plan 3b (neovim, renode, scaffold) is a separate brainstorm.
Goal
Port the 10 simple/medium lib/*.sh installer modules into Go packages under internal/modules/, each implementing the module.Module contract (Detect / Plan / Apply / Verify). Every module is unit-tested against an injected fake platform.Runner. Along the way, add the platform helpers these modules need (AUR, download, extract, git-clone, text-block writer) and fix the wizard renderPlan UI-blocking watchpoint flagged at the end of Plan 2.
Architecture
Foundation tasks land first, then modules in simplest-first order:
- Wizard cache fix — cache Detect+Plan results per module; invalidate on selection toggle. One small change, but it unblocks every downstream module from stalling the UI.
- Platform helpers —
AURHelper(yay),Download,Extract,gitclient,textblock. Each gets its own file, each gets TDD against a fake Runner (exceptextractwhich uses real tempdirs with fixture archives). - Ten modules in this order: packages → gittools(lazygit) → starship → yazi → zsh → fonts → zellij → themes → arduinocli → shellconfig.
All module inputs (package lists, versions, URLs, plugin repos, shell-config blocks) are typed fields on config.Config populated from the embedded defaults.yaml. Overlay override plumbing already exists from Plan 1. Modules take their inputs as struct fields at construction time — no globals.
Tech Stack
Go, existing internal/platform + internal/module + internal/engine + internal/tui packages. No new external dependencies expected beyond what Plan 1/2 pulled in.
Files
New — platform helpers
-
internal/platform/aur.go+aur_test.goAURHelperinterface,Yayimplementation.EnsureInstalled(ctx) error— bootstraps yay from AUR viagit clone https://aur.archlinux.org/yay.git /tmp/... && makepkg -si --noconfirmwhen missing.Install(ctx, pkgs []string) error—yay -S --needed --noconfirm <pkgs...>.- Takes a
Runnerand aPackageManager(to check ifyayitself is installed).
-
internal/platform/download.go+download_test.goDownload(ctx, url, destPath string, mode os.FileMode) error.- Atomic-ish: write to
destPath+".tmp"then rename. Clean up on error. - Tested with
httptest.Server.
-
internal/platform/extract.go+extract_test.goExtractTarGz(ctx, archivePath, destDir string) error.ExtractZip(ctx, archivePath, destDir string) error.- Tested against real fixture archives in
testdata/.
-
internal/platform/git.go+git_test.goGitClientstruct taking aRunner.Clone(ctx, url, dest string) error.EnsureRepo(ctx, url, dest string) error— clone ifdest/.gitmissing, otherwise noop.
-
internal/platform/textblock.go+textblock_test.goEnsureBlock(path, marker, content string) error— writes or replaces a block delimited by# >>> marker >>>/# <<< marker <<<markers. Creates the file if missing.RemoveBlock(path, marker string) error.HasBlock(path, marker string) (bool, error).- Table-driven tests: insert, replace, remove, preserve surrounding content, missing-file, empty-file.
New — modules
Each lives in its own package under internal/modules/<name>/ with <name>.go + <name>_test.go.
packages— installs base pacman package list fromcfg.Packages.Base.gittools— installs lazygit (pacman). Namedgittoolsto avoid collision with Go stdlib / pacmangit.starship— installs starship (pacman).yazi— installs yazi (pacman).zsh— installs zsh (pacman) + clones oh-my-zsh + configured plugins viagitclient.fonts— installs AUR fonts (e.g.ttf-jetbrains-mono-nerd) viaAURHelper. First action isEnsureYayInstalledif needed.zellij— installs zellij (pacman) + downloads config.themes— copies theme files from source directory (bundled underlib/themes/or overridable viacfg.Themes.Source).arduinocli— downloads arduino-cli tarball, extracts to~/.local/bin, runsarduino-cli core install <cores...>fromcfg.ArduinoCLI.Cores.shellconfig— writes marker-delimited blocks into~/.zshrc,~/.bashrc, etc., usingtextblock.EnsureBlock. Blocks defined incfg.ShellConfig.Blocks.
Modified
internal/config/config.go+internal/config/defaults.yaml— add typed fields:Packages.Base []stringFonts []stringArduinoCLI{Version string, URL string, Cores []string}Starship{Version string}(reserved; pacman install doesn't need it but overlay may)Zsh{OhMyZshRepo string, Plugins []struct{Name, Repo string}}Zellij{ConfigURL string}Themes{Source string}(defaults to embeddedlib/themes/)ShellConfig{Blocks []struct{Name, File, Content string}}
internal/tui/wizard/wizard.go— cache Detect+Plan per module; invalidate on selection toggle.- Module registry (wherever
cmd/archduino/install.gocurrently registershello) — register the 10 new modules instead of / alongsidehello.
Module Contract Details
All modules follow the same shape:
- Detect — pure read.
PackageManager.IsInstalledper pkg,os.Statper file,textblock.HasBlockper marker. Returns a module-privatestatestruct. Must be fast (wizard caches results, but Detect still shouldn't shell out more than necessary). - Plan — given current state, returns
Plan{Actions: []Action}. Empty actions = already converged (idempotent no-op). No side effects. - Apply — executes each action sequentially, reporting progress via
Reporter.Progress(id, i, total, description). - Verify — re-runs Detect and asserts convergence. Returns a descriptive error on mismatch.
Error semantics from the engine (--halt-on-error flag, sibling-on-failure continuation) apply unchanged.
Module archetypes
- Pacman-only (packages, gittools, starship, yazi, zellij base, themes deps if any): Detect =
IsInstalledper pkg; Plan = install missing; Apply =Pacman.Install; Verify = all installed. - AUR (fonts): same as pacman-only but via
AURHelper. Plan prepends anEnsureYayaction if yay is not installed. - Download + extract (arduinocli): Detect = probe binary +
--version; Plan = download + extract + symlink +arduino-cli core install; Verify = version string matches. - Git-clone configs (zsh oh-my-zsh + plugins, zellij config fetch if URL-based, themes if sourced from repo): Detect = check
.gitor file presence; Plan =gitclient.EnsureRepoorDownload; Verify = path exists. - Text-block writers (shellconfig): Detect =
textblock.HasBlockper block; Plan =textblock.EnsureBlock; Verify = re-scan.
Testing Strategy
Per-module tests (all unit, all fake-Runner-based):
- Detect with nothing installed → absent state.
- Detect with everything installed → present state.
- Plan(absent) → non-empty actions matching config inputs.
- Plan(present) → empty actions (idempotent no-op).
- Apply → records correct shell commands against fake Runner.
- Verify after Apply → passes when fake reports success.
Platform helper tests:
aur_test.go— yay bootstrap path (fake Runner seesgit clone+makepkg -si), install path, already-installed short-circuit.download_test.go—httptest.Serverfor happy path + 404 + mid-download error cleanup.extract_test.go— real tar/zip fixtures intestdata/extracted intot.TempDir().git_test.go— fake Runner for clone-if-missing, skip-if-present.textblock_test.go— table-driven: insert new, replace existing, remove, preserve surrounding content, missing-file, empty-file.
Wizard caching test: assert Detect is called once per module regardless of redraw count.
No integration tests that touch real pacman, real AUR, or the real network. That's Plan 4 (CI with Arch container). Manual smoke after plan completes.
Non-Goals
- Plan 3b modules (neovim, renode, scaffold) — separate brainstorm.
- Subcommands beyond
install(theme/new/doctor) — Plan 4. bootstrap.sh, goreleaser, CI — Plan 4.- Debian/Fedora PackageManager implementations — Plan 4+.
- Integration tests against real package managers — Plan 4.
Open Risks / Watchpoints
- AUR bootstrap fragility — if
makepkg -si --noconfirmprompts despite--noconfirm(sudo password mid-build), the fake-Runner tests won't catch it. Manual smoke on fonts module is essential. - arduino-cli URL drift — hardcoded version in
defaults.yamlwill go stale. Acceptable for v1; doctor subcommand in Plan 4 can warn. - Theme file source — if themes ship bundled via Go
embed, we need to decide that at module time. Default assumption: copy fromlib/themes/on disk (same behavior as bash); switch toembedonly if it's a clear win.