diff --git a/docs/superpowers/plans/2026-04-14-plan3a-install-modules.md b/docs/superpowers/plans/2026-04-14-plan3a-install-modules.md new file mode 100644 index 0000000..1f7416c --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-plan3a-install-modules.md @@ -0,0 +1,2559 @@ +# Plan 3a — Install Modules (Simple/Medium Tier) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port 10 of the 13 Bash install modules into Go, each implementing the `module.Module` four-phase contract, unit-tested against a fake `platform.Runner`, plus the shared platform helpers they require and a wizard performance fix. + +**Architecture:** Foundation first — cache the wizard's Detect+Plan results, then add five platform helpers (`textblock`, `download`, `extract`, `git`, `aur`), then extend `config.Config` with typed fields, then port modules in simplest-first order (`packages` → `gittools` → `starship` → `yazi` → `zsh` → `fonts` → `zellij` → `themes` → `arduinocli` → `shellconfig`). Finally register them in the install command. + +**Tech Stack:** Go 1.22+, stdlib only for helpers (`net/http`, `archive/tar`, `archive/zip`, `compress/gzip`, `os/exec`), existing `github.com/charmbracelet/huh` + `bubbletea` for TUI, `gopkg.in/yaml.v3` for config. + +--- + +## Reference: existing types (DO NOT redefine) + +From `internal/module/module.go`: + +```go +type Meta struct { ID, Name, Description string; Deps []string } +type State any +type ActionKind int +const ( ActionUnknown ActionKind = iota; ActionPackageInstall; ActionFileWrite; ActionSymlink; ActionCommand; ActionGitClone; ActionGroupAdd ) +type Action struct { Description string; Kind ActionKind; Exec func(ctx context.Context) error } +type Plan struct { Actions []Action } +func (p Plan) IsEmpty() bool +type Module interface { Meta() Meta; Detect(ctx) (State, error); Plan(ctx, State) (Plan, error); Apply(ctx, Plan, Reporter) error; Verify(ctx) error } +type Reporter interface { Progress(id string, current, total int, message string); Log(id, line string) } +``` + +From `internal/platform/pkgmgr.go`: + +```go +type PackageManager interface { Name() string; IsInstalled(ctx, pkg string) (bool, error); Install(ctx, pkgs []string) error; Update(ctx) error } +type Runner interface { Run(ctx, name string, args ...string) error; Output(ctx, name string, args ...string) ([]byte, error) } +``` + +Reusable fake runner pattern (already in `pacman_test.go`): + +```go +type fakeRunner struct { + runCalls [][]string // each call: [name, args...] + outCalls [][]string + runErr error + outOut []byte + outErr error +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return f.runErr +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + return f.outOut, f.outErr +} +``` + +New tests that need more flexible behavior (e.g. different results per-call) should introduce a richer local fake in the same file rather than trying to extend a single shared type. + +--- + +## Task 1: Wizard caching — Detect+Plan runs once per module per selection + +**Files:** +- Modify: `internal/tui/wizard/wizard.go` (add cache struct, thread it through `renderPlan`) +- Modify: `internal/tui/wizard/wizard_test.go` (add caching test) + +- [ ] **Step 1: Write the failing test** + +Add to `internal/tui/wizard/wizard_test.go`: + +```go +func TestRenderPlanCachesDetectAndPlan(t *testing.T) { + var detects, plans int + m := countingModule{ + meta: module.Meta{ID: "count", Name: "Count"}, + onDetect: func() { detects++ }, + onPlan: func() { plans++ }, + } + reg := module.NewRegistry() + if err := reg.Register(m); err != nil { t.Fatal(err) } + cache := newPlanCache() + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + if detects != 1 || plans != 1 { + t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans) + } +} + +func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) { + var detects int + m := countingModule{ + meta: module.Meta{ID: "count", Name: "Count"}, + onDetect: func() { detects++ }, + } + reg := module.NewRegistry() + _ = reg.Register(m) + cache := newPlanCache() + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + if detects != 2 { + t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects) + } +} + +type countingModule struct { + meta module.Meta + onDetect func() + onPlan func() +} +func (c countingModule) Meta() module.Meta { return c.meta } +func (c countingModule) Detect(context.Context) (module.State, error) { if c.onDetect != nil { c.onDetect() }; return nil, nil } +func (c countingModule) Plan(context.Context, module.State) (module.Plan, error) { if c.onPlan != nil { c.onPlan() }; return module.Plan{}, nil } +func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (c countingModule) Verify(context.Context) error { return nil } +``` + +- [ ] **Step 2: Run the test, confirm it fails** + +``` +go test ./internal/tui/wizard/... -run TestRenderPlan +``` +Expected: `undefined: newPlanCache` / `undefined: renderPlanCached`. + +- [ ] **Step 3: Implement the cache and plumbing** + +In `internal/tui/wizard/wizard.go`, add near the top: + +```go +type planCacheEntry struct { out string } +type planCache struct { entries map[string]planCacheEntry } + +func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} } + +func (c *planCache) get(id string) (string, bool) { + e, ok := c.entries[id] + return e.out, ok +} +func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} } +func (c *planCache) prune(keepIDs []string) { + keep := map[string]bool{} + for _, id := range keepIDs { keep[id] = true } + for k := range c.entries { + if !keep[k] { delete(c.entries, k) } + } +} +``` + +Add a new function `renderPlanCached` that mirrors `renderPlan` but consults/fills the cache per module: + +```go +func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string { + if len(selectedIDs) == 0 { return "No modules selected." } + cache.prune(selectedIDs) + var b strings.Builder + for _, id := range selectedIDs { + if cached, ok := cache.get(id); ok { b.WriteString(cached); continue } + line := renderOne(ctx, reg, id) + cache.put(id, line) + b.WriteString(line) + } + return b.String() +} + +func renderOne(ctx context.Context, reg *module.Registry, id string) string { + m, ok := reg.Get(id) + if !ok { return fmt.Sprintf("• %s — (unknown)\n", id) } + state, err := m.Detect(ctx) + if err != nil { return fmt.Sprintf("• %s — detect failed: %v\n", id, err) } + plan, err := m.Plan(ctx, state) + if err != nil { return fmt.Sprintf("• %s — plan failed: %v\n", id, err) } + if plan.IsEmpty() { return fmt.Sprintf("• %s — already up to date\n", id) } + var b strings.Builder + fmt.Fprintf(&b, "• %s\n", id) + for _, a := range plan.Actions { fmt.Fprintf(&b, " - %s\n", a.Description) } + return b.String() +} +``` + +Replace the call site inside `Run`: + +```go +cache := newPlanCache() +// ... +huh.NewGroup(huh.NewNote(). + Title("Review plan"). + DescriptionFunc(func() string { + s.Modules = syncSelected(s.Modules, selectedModules) + return renderPlanCached(ctx, reg, s.SelectedIDs(), cache) + }, &selectedModules)), +``` + +Delete the old `renderPlan` function (it's replaced by `renderPlanCached` + `renderOne`). + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/tui/wizard/... -v +``` +Expected: all tests pass, including the two new ones. + +- [ ] **Step 5: Commit** + +```bash +git add internal/tui/wizard/wizard.go internal/tui/wizard/wizard_test.go +git commit -m "perf(tui): cache Detect+Plan results in wizard renderPlan" +``` + +--- + +## Task 2: `textblock` helper — idempotent marker-delimited file blocks + +**Files:** +- Create: `internal/platform/textblock.go` +- Create: `internal/platform/textblock_test.go` + +The markers are `# >>> >>>` and `# <<< <<<` so that the same helper works for `.zshrc`, `.bashrc`, and any other `#`-commented config file. + +- [ ] **Step 1: Write the failing test** + +```go +package platform + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureBlockInsertsIntoMissingFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + if err := EnsureBlock(p, "archduino", "export FOO=1"); err != nil { t.Fatal(err) } + got, _ := os.ReadFile(p) + want := "# >>> archduino >>>\nexport FOO=1\n# <<< archduino <<<\n" + if string(got) != want { t.Fatalf("got %q want %q", got, want) } +} + +func TestEnsureBlockReplacesExisting(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + initial := "alias ll='ls -l'\n# >>> archduino >>>\nold content\n# <<< archduino <<<\necho tail\n" + if err := os.WriteFile(p, []byte(initial), 0o644); err != nil { t.Fatal(err) } + if err := EnsureBlock(p, "archduino", "new content"); err != nil { t.Fatal(err) } + got, _ := os.ReadFile(p) + want := "alias ll='ls -l'\n# >>> archduino >>>\nnew content\n# <<< archduino <<<\necho tail\n" + if string(got) != want { t.Fatalf("got %q want %q", got, want) } +} + +func TestEnsureBlockIdempotent(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + _ = EnsureBlock(p, "a", "x") + before, _ := os.ReadFile(p) + _ = EnsureBlock(p, "a", "x") + after, _ := os.ReadFile(p) + if string(before) != string(after) { t.Fatalf("not idempotent: %q vs %q", before, after) } +} + +func TestHasBlockTrueFalse(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + has, err := HasBlock(p, "a"); if err != nil { t.Fatal(err) } + if has { t.Fatal("missing file should report has=false") } + _ = os.WriteFile(p, []byte("no blocks here\n"), 0o644) + has, _ = HasBlock(p, "a"); if has { t.Fatal("unblocked file should report has=false") } + _ = EnsureBlock(p, "a", "x") + has, _ = HasBlock(p, "a"); if !has { t.Fatal("blocked file should report has=true") } +} + +func TestRemoveBlock(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + _ = os.WriteFile(p, []byte("head\n# >>> a >>>\nmid\n# <<< a <<<\ntail\n"), 0o644) + if err := RemoveBlock(p, "a"); err != nil { t.Fatal(err) } + got, _ := os.ReadFile(p) + if strings.Contains(string(got), ">>> a >>>") { t.Fatalf("block not removed: %q", got) } + if !strings.Contains(string(got), "head") || !strings.Contains(string(got), "tail") { + t.Fatalf("surrounding content lost: %q", got) + } +} +``` + +- [ ] **Step 2: Run tests, confirm fail** + +``` +go test ./internal/platform/... -run TestEnsureBlock -v +``` +Expected: `undefined: EnsureBlock`. + +- [ ] **Step 3: Implement `textblock.go`** + +```go +package platform + +import ( + "fmt" + "os" + "strings" +) + +func blockMarkers(marker string) (string, string) { + return fmt.Sprintf("# >>> %s >>>", marker), fmt.Sprintf("# <<< %s <<<", marker) +} + +// EnsureBlock writes a marker-delimited block into path. If the file does +// not exist it is created. If a block with the same marker already exists +// it is replaced in place. Surrounding content is preserved. +func EnsureBlock(path, marker, content string) error { + start, end := blockMarkers(marker) + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { return err } + block := start + "\n" + strings.TrimRight(content, "\n") + "\n" + end + "\n" + + text := string(existing) + si := strings.Index(text, start) + ei := strings.Index(text, end) + if si >= 0 && ei > si { + // Replace including trailing newline after end marker if present. + endPos := ei + len(end) + if endPos < len(text) && text[endPos] == '\n' { endPos++ } + text = text[:si] + block + text[endPos:] + } else { + if text != "" && !strings.HasSuffix(text, "\n") { text += "\n" } + text += block + } + return os.WriteFile(path, []byte(text), 0o644) +} + +// HasBlock reports whether path contains a block with the given marker. +// A missing file yields (false, nil). +func HasBlock(path, marker string) (bool, error) { + start, _ := blockMarkers(marker) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { return false, nil } + return false, err + } + return strings.Contains(string(data), start), nil +} + +// RemoveBlock deletes a marker-delimited block if present. +func RemoveBlock(path, marker string) error { + start, end := blockMarkers(marker) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { return nil } + return err + } + text := string(data) + si := strings.Index(text, start) + ei := strings.Index(text, end) + if si < 0 || ei <= si { return nil } + endPos := ei + len(end) + if endPos < len(text) && text[endPos] == '\n' { endPos++ } + text = text[:si] + text[endPos:] + return os.WriteFile(path, []byte(text), 0o644) +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/platform/... -v +``` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add internal/platform/textblock.go internal/platform/textblock_test.go +git commit -m "feat(platform): add textblock helper for idempotent config-file blocks" +``` + +--- + +## Task 3: `download` helper — atomic HTTP-to-file + +**Files:** +- Create: `internal/platform/download.go` +- Create: `internal/platform/download_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package platform + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestDownloadHappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("payload")) + })) + defer srv.Close() + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + if err := Download(context.Background(), srv.URL, dest, 0o755); err != nil { t.Fatal(err) } + got, _ := os.ReadFile(dest) + if string(got) != "payload" { t.Fatalf("got %q want payload", got) } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o755 { t.Fatalf("mode=%v want 0755", info.Mode().Perm()) } +} + +func TestDownload404Errors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusNotFound) + })) + defer srv.Close() + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + if err := Download(context.Background(), srv.URL, dest, 0o644); err == nil { + t.Fatal("want error on 404") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Fatal("dest should not exist after failed download") + } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/platform/... -run TestDownload -v +``` +Expected: `undefined: Download`. + +- [ ] **Step 3: Implement `download.go`** + +```go +package platform + +import ( + "context" + "fmt" + "io" + "net/http" + "os" +) + +// Download fetches url into destPath atomically (temp file + rename) with +// the given file mode. Any non-2xx response returns an error and leaves +// no file behind. +func Download(ctx context.Context, url, destPath string, mode os.FileMode) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { return err } + resp, err := http.DefaultClient.Do(req) + if err != nil { return err } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("download %s: http %d", url, resp.StatusCode) + } + tmp := destPath + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { return err } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close(); os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { os.Remove(tmp); return err } + return os.Rename(tmp, destPath) +} +``` + +- [ ] **Step 4: Run test, confirm pass** + +``` +go test ./internal/platform/... -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/platform/download.go internal/platform/download_test.go +git commit -m "feat(platform): add atomic HTTP download helper" +``` + +--- + +## Task 4: `extract` helper — tar.gz and zip + +**Files:** +- Create: `internal/platform/extract.go` +- Create: `internal/platform/extract_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package platform + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "os" + "path/filepath" + "testing" +) + +func makeTarGz(t *testing.T, entries map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.tar.gz") + f, err := os.Create(p); if err != nil { t.Fatal(err) } + gz := gzip.NewWriter(f); tw := tar.NewWriter(gz) + for name, body := range entries { + tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}) + tw.Write([]byte(body)) + } + tw.Close(); gz.Close(); f.Close() + return p +} + +func makeZip(t *testing.T, entries map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.zip") + f, _ := os.Create(p); zw := zip.NewWriter(f) + for name, body := range entries { + w, _ := zw.Create(name); w.Write([]byte(body)) + } + zw.Close(); f.Close() + return p +} + +func TestExtractTarGz(t *testing.T) { + src := makeTarGz(t, map[string]string{"hello.txt": "world", "sub/x.txt": "y"}) + dest := t.TempDir() + if err := ExtractTarGz(context.Background(), src, dest); err != nil { t.Fatal(err) } + got, _ := os.ReadFile(filepath.Join(dest, "hello.txt")) + if string(got) != "world" { t.Fatalf("got %q", got) } + got, _ = os.ReadFile(filepath.Join(dest, "sub", "x.txt")) + if string(got) != "y" { t.Fatalf("got %q", got) } +} + +func TestExtractZip(t *testing.T) { + src := makeZip(t, map[string]string{"a.txt": "A"}) + dest := t.TempDir() + if err := ExtractZip(context.Background(), src, dest); err != nil { t.Fatal(err) } + got, _ := os.ReadFile(filepath.Join(dest, "a.txt")) + if string(got) != "A" { t.Fatalf("got %q", got) } +} + +func TestExtractRejectsPathTraversal(t *testing.T) { + src := makeTarGz(t, map[string]string{"../evil.txt": "x"}) + dest := t.TempDir() + if err := ExtractTarGz(context.Background(), src, dest); err == nil { + t.Fatal("want error on path traversal") + } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/platform/... -run TestExtract -v +``` +Expected: `undefined: ExtractTarGz`. + +- [ ] **Step 3: Implement `extract.go`** + +```go +package platform + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func safeJoin(dest, name string) (string, error) { + p := filepath.Join(dest, name) + cleanDest := filepath.Clean(dest) + string(os.PathSeparator) + if !strings.HasPrefix(filepath.Clean(p)+string(os.PathSeparator), cleanDest) { + return "", fmt.Errorf("unsafe path %q escapes %q", name, dest) + } + return p, nil +} + +func ExtractTarGz(ctx context.Context, archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { return err } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { return err } + defer gz.Close() + tr := tar.NewReader(gz) + for { + if err := ctx.Err(); err != nil { return err } + h, err := tr.Next() + if err == io.EOF { break } + if err != nil { return err } + target, err := safeJoin(destDir, h.Name) + if err != nil { return err } + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(h.Mode)); err != nil { return err } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } + w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)) + if err != nil { return err } + if _, err := io.Copy(w, tr); err != nil { w.Close(); return err } + w.Close() + } + } + return nil +} + +func ExtractZip(ctx context.Context, archivePath, destDir string) error { + zr, err := zip.OpenReader(archivePath) + if err != nil { return err } + defer zr.Close() + for _, zf := range zr.File { + if err := ctx.Err(); err != nil { return err } + target, err := safeJoin(destDir, zf.Name) + if err != nil { return err } + if zf.FileInfo().IsDir() { + if err := os.MkdirAll(target, zf.Mode()); err != nil { return err } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } + rc, err := zf.Open(); if err != nil { return err } + w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode()) + if err != nil { rc.Close(); return err } + _, err = io.Copy(w, rc) + rc.Close(); w.Close() + if err != nil { return err } + } + return nil +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/platform/... -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/platform/extract.go internal/platform/extract_test.go +git commit -m "feat(platform): add tar.gz and zip extract helpers with traversal guard" +``` + +--- + +## Task 5: `gitclient` helper — idempotent clone + +**Files:** +- Create: `internal/platform/git.go` +- Create: `internal/platform/git_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package platform + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +type gitFakeRunner struct { calls [][]string; err error } +func (f *gitFakeRunner) Run(_ context.Context, name string, args ...string) error { + f.calls = append(f.calls, append([]string{name}, args...)) + return f.err +} +func (f *gitFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { return nil, nil } + +func TestGitEnsureRepoClonesWhenMissing(t *testing.T) { + r := &gitFakeRunner{} + g := GitClient{Runner: r} + dest := filepath.Join(t.TempDir(), "repo") + if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil { t.Fatal(err) } + if len(r.calls) != 1 { + t.Fatalf("want 1 call, got %d: %v", len(r.calls), r.calls) + } + got := r.calls[0] + want := []string{"git", "clone", "--depth=1", "https://x/y.git", dest} + for i := range want { + if i >= len(got) || got[i] != want[i] { + t.Fatalf("call = %v, want %v", got, want) + } + } +} + +func TestGitEnsureRepoSkipsWhenPresent(t *testing.T) { + dest := t.TempDir() + if err := os.MkdirAll(filepath.Join(dest, ".git"), 0o755); err != nil { t.Fatal(err) } + r := &gitFakeRunner{} + g := GitClient{Runner: r} + if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil { t.Fatal(err) } + if len(r.calls) != 0 { t.Fatalf("expected no calls, got %v", r.calls) } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/platform/... -run TestGit -v +``` +Expected: `undefined: GitClient`. + +- [ ] **Step 3: Implement `git.go`** + +```go +package platform + +import ( + "context" + "os" + "path/filepath" +) + +// GitClient wraps `git` via a Runner. +type GitClient struct { Runner Runner } + +// Clone always runs `git clone --depth=1 url dest`. +func (g GitClient) Clone(ctx context.Context, url, dest string) error { + return g.Runner.Run(ctx, "git", "clone", "--depth=1", url, dest) +} + +// EnsureRepo clones url into dest if dest/.git is missing; otherwise noop. +func (g GitClient) EnsureRepo(ctx context.Context, url, dest string) error { + if _, err := os.Stat(filepath.Join(dest, ".git")); err == nil { + return nil + } + return g.Clone(ctx, url, dest) +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/platform/... -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/platform/git.go internal/platform/git_test.go +git commit -m "feat(platform): add GitClient helper with idempotent EnsureRepo" +``` + +--- + +## Task 6: `AURHelper` — yay bootstrap + install + +**Files:** +- Create: `internal/platform/aur.go` +- Create: `internal/platform/aur_test.go` + +Bootstrap sequence matches what `lib/common.sh` does in bash: pacman-install build deps (`base-devel`, `git`), clone yay from AUR to a tempdir, `makepkg -si --noconfirm`. + +- [ ] **Step 1: Write the failing test** + +```go +package platform + +import ( + "context" + "testing" +) + +type aurFakeRunner struct { + runCalls [][]string + outCalls [][]string + // yayInstalled toggles what `pacman -Q yay` returns. + yayInstalled bool +} +func (f *aurFakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return nil +} +func (f *aurFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" && args[1] == "yay" { + if f.yayInstalled { return []byte("yay 12.0-1\n"), nil } + return nil, &fakeExitErr{} + } + return nil, nil +} +type fakeExitErr struct{} +func (fakeExitErr) Error() string { return "exit 1" } + +func TestAUREnsureInstalledSkipsWhenYayPresent(t *testing.T) { + r := &aurFakeRunner{yayInstalled: true} + y := NewYay(r) + if err := y.EnsureInstalled(context.Background()); err != nil { t.Fatal(err) } + if len(r.runCalls) != 0 { t.Fatalf("no installs expected, got %v", r.runCalls) } +} + +func TestAUREnsureInstalledBootstrapsYay(t *testing.T) { + r := &aurFakeRunner{yayInstalled: false} + y := NewYay(r) + if err := y.EnsureInstalled(context.Background()); err != nil { t.Fatal(err) } + // Expect: pacman install base-devel+git, git clone yay, makepkg -si in that dir. + sawPacman := false; sawClone := false; sawMakepkg := false + for _, c := range r.runCalls { + if len(c) >= 2 && c[0] == "sudo" && c[1] == "pacman" { sawPacman = true } + if len(c) >= 2 && c[0] == "git" && c[1] == "clone" { sawClone = true } + if len(c) >= 1 && c[0] == "makepkg" { sawMakepkg = true } + } + if !sawPacman || !sawClone || !sawMakepkg { + t.Fatalf("missing bootstrap step; calls=%v", r.runCalls) + } +} + +func TestAURInstall(t *testing.T) { + r := &aurFakeRunner{yayInstalled: true} + y := NewYay(r) + if err := y.Install(context.Background(), []string{"ttf-jetbrains-mono-nerd"}); err != nil { t.Fatal(err) } + if len(r.runCalls) == 0 { t.Fatal("expected yay install call") } + c := r.runCalls[len(r.runCalls)-1] + want := []string{"yay", "-S", "--needed", "--noconfirm", "ttf-jetbrains-mono-nerd"} + for i, w := range want { + if i >= len(c) || c[i] != w { + t.Fatalf("call = %v, want %v", c, want) + } + } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/platform/... -run TestAUR -v +``` +Expected: `undefined: NewYay`. + +- [ ] **Step 3: Implement `aur.go`** + +```go +package platform + +import ( + "context" + "os" + "path/filepath" +) + +// AURHelper abstracts an AUR front-end (currently only yay). +type AURHelper interface { + EnsureInstalled(ctx context.Context) error + Install(ctx context.Context, pkgs []string) error +} + +type Yay struct { + Runner Runner + pm *Pacman +} + +func NewYay(r Runner) *Yay { return &Yay{Runner: r, pm: NewPacman(r)} } + +func (y *Yay) EnsureInstalled(ctx context.Context) error { + ok, _ := y.pm.IsInstalled(ctx, "yay") + if ok { return nil } + // Bootstrap: install build deps, clone AUR yay, makepkg -si. + if err := y.pm.Install(ctx, []string{"base-devel", "git"}); err != nil { return err } + tmp, err := os.MkdirTemp("", "archduino-yay-") + if err != nil { return err } + defer os.RemoveAll(tmp) + clone := filepath.Join(tmp, "yay") + if err := y.Runner.Run(ctx, "git", "clone", "https://aur.archlinux.org/yay.git", clone); err != nil { + return err + } + // `makepkg -si --noconfirm` needs to cd into the cloned dir. os/exec does + // not have a per-command cwd without exec.Cmd; our Runner interface does + // not expose that, so we invoke via sh -c with a pushd-style prefix. + return y.Runner.Run(ctx, "sh", "-c", "cd "+shellQuote(clone)+" && makepkg -si --noconfirm") +} + +func (y *Yay) Install(ctx context.Context, pkgs []string) error { + if len(pkgs) == 0 { return nil } + args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...) + return y.Runner.Run(ctx, "yay", args...) +} + +func shellQuote(s string) string { + return "'" + stringsReplace(s, "'", `'\''`) + "'" +} + +// stringsReplace avoids an import cycle check and keeps this file small. +func stringsReplace(s, old, new string) string { + out := "" + for { + i := indexOf(s, old) + if i < 0 { return out + s } + out += s[:i] + new + s = s[i+len(old):] + } +} +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { return i } + } + return -1 +} +``` + +Note: the test’s `makepkg` check matches the `sh -c "cd … && makepkg -si --noconfirm"` invocation via substring scan of the `Run` call — adjust test if needed. Actually, the test checks `c[0] == "makepkg"`. Replace the sh-c call with a direct `makepkg` invocation plus a `Chdir`-aware future Runner; for now, change the test's assertion path: + +```go + if len(c) >= 3 && c[0] == "sh" && c[1] == "-c" && strings.Contains(c[2], "makepkg") { sawMakepkg = true } +``` + +And add `import "strings"` to the test file. Update Step 1's test accordingly before running Step 4. + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/platform/... -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/platform/aur.go internal/platform/aur_test.go +git commit -m "feat(platform): add AURHelper with yay bootstrap and install" +``` + +--- + +## Task 7: Extend `config.Config` with module-input fields + +**Files:** +- Modify: `internal/config/config.go` +- Modify: `internal/config/defaults.yaml` +- Modify: `internal/config/config_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/config/config_test.go`: + +```go +func TestDefaultsCarryModuleInputs(t *testing.T) { + cfg, err := Load("/nonexistent/overlay.yaml") + if err != nil { t.Fatal(err) } + if len(cfg.Packages.Base) == 0 { t.Error("Packages.Base empty") } + if len(cfg.Fonts) == 0 { t.Error("Fonts empty") } + if cfg.ArduinoCLI.Version == "" { t.Error("ArduinoCLI.Version empty") } + if cfg.ArduinoCLI.URL == "" { t.Error("ArduinoCLI.URL empty") } + if cfg.Zsh.OhMyZshRepo == "" { t.Error("Zsh.OhMyZshRepo empty") } + if len(cfg.ShellConfig.Blocks) == 0 { t.Error("ShellConfig.Blocks empty") } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/config/... -run TestDefaultsCarryModuleInputs -v +``` +Expected: `cfg.Packages undefined`. + +- [ ] **Step 3: Extend the Config struct** + +In `internal/config/config.go`, replace the `Config` struct: + +```go +type Packages struct { + Base []string `yaml:"base"` +} +type ArduinoCLIConfig struct { + Version string `yaml:"version"` + URL string `yaml:"url"` + Cores []string `yaml:"cores"` +} +type ZshPlugin struct { + Name string `yaml:"name"` + Repo string `yaml:"repo"` +} +type ZshConfig struct { + OhMyZshRepo string `yaml:"oh_my_zsh_repo"` + Plugins []ZshPlugin `yaml:"plugins"` +} +type ZellijConfig struct { + ConfigURL string `yaml:"config_url"` +} +type ThemesConfig struct { + Source string `yaml:"source"` +} +type ShellConfigBlock struct { + Name string `yaml:"name"` + File string `yaml:"file"` + Content string `yaml:"content"` +} +type ShellConfigSection struct { + Blocks []ShellConfigBlock `yaml:"blocks"` +} + +type Config struct { + Version int `yaml:"version"` + ExtraPackages []string `yaml:"extra_packages"` + DisabledModules []string `yaml:"disabled_modules"` + ThemeOverrides map[string]string `yaml:"theme_overrides"` + Packages Packages `yaml:"packages"` + Fonts []string `yaml:"fonts"` + ArduinoCLI ArduinoCLIConfig `yaml:"arduino_cli"` + Zsh ZshConfig `yaml:"zsh"` + Zellij ZellijConfig `yaml:"zellij"` + Themes ThemesConfig `yaml:"themes"` + ShellConfig ShellConfigSection `yaml:"shell_config"` +} +``` + +Note: overlay merge currently only merges `ExtraPackages`, `DisabledModules`, `ThemeOverrides`. For Plan 3a, module-input fields are overlay-replaceable but not element-merged: an overlay providing `packages.base: [...]` replaces the default list. Update `merge` accordingly: + +```go +func merge(base, over Config) Config { + out := base + if len(over.ExtraPackages) > 0 { + out.ExtraPackages = append(append([]string{}, base.ExtraPackages...), over.ExtraPackages...) + } + if len(over.DisabledModules) > 0 { + out.DisabledModules = append(append([]string{}, base.DisabledModules...), over.DisabledModules...) + } + if len(over.ThemeOverrides) > 0 { + merged := make(map[string]string, len(base.ThemeOverrides)+len(over.ThemeOverrides)) + for k, v := range base.ThemeOverrides { merged[k] = v } + for k, v := range over.ThemeOverrides { merged[k] = v } + out.ThemeOverrides = merged + } + if len(over.Packages.Base) > 0 { out.Packages = over.Packages } + if len(over.Fonts) > 0 { out.Fonts = over.Fonts } + if over.ArduinoCLI.Version != "" { out.ArduinoCLI = over.ArduinoCLI } + if over.Zsh.OhMyZshRepo != "" { out.Zsh = over.Zsh } + if over.Zellij.ConfigURL != "" { out.Zellij = over.Zellij } + if over.Themes.Source != "" { out.Themes = over.Themes } + if len(over.ShellConfig.Blocks) > 0 { out.ShellConfig = over.ShellConfig } + return out +} +``` + +- [ ] **Step 4: Populate `defaults.yaml`** + +Replace `internal/config/defaults.yaml`: + +```yaml +version: 1 +extra_packages: [] +disabled_modules: [] +theme_overrides: {} + +packages: + base: + - base-devel + - curl + - wget + - unzip + - zip + - tree + - htop + - neofetch + +fonts: + - ttf-jetbrains-mono-nerd + +arduino_cli: + version: "0.35.3" + url: "https://github.com/arduino/arduino-cli/releases/download/v0.35.3/arduino-cli_0.35.3_Linux_64bit.tar.gz" + cores: + - arduino:avr + +zsh: + oh_my_zsh_repo: "https://github.com/ohmyzsh/ohmyzsh.git" + plugins: + - name: zsh-autosuggestions + repo: https://github.com/zsh-users/zsh-autosuggestions + - name: zsh-syntax-highlighting + repo: https://github.com/zsh-users/zsh-syntax-highlighting.git + +zellij: + config_url: "" + +themes: + source: "lib/themes" + +shell_config: + blocks: + - name: archduino-path + file: ".zshrc" + content: | + export PATH="$HOME/.local/bin:$PATH" + - name: archduino-starship + file: ".zshrc" + content: | + eval "$(starship init zsh)" +``` + +The engineer should cross-reference `lib/packages.sh` and pull in the actual package set for base — the list above is a safe starter; any additions from the Bash file should be preserved. + +- [ ] **Step 5: Run tests, confirm pass** + +``` +go test ./internal/config/... -v +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/config/config.go internal/config/defaults.yaml internal/config/config_test.go +git commit -m "feat(config): add typed module-input fields with defaults" +``` + +--- + +## Task 8: `packages` module — pacman base list + +**Files:** +- Create: `internal/modules/packages/packages.go` +- Create: `internal/modules/packages/packages_test.go` + +This is the **canonical template** for all pacman-only modules. Later pacman-only tasks (9, 10, 11) will reuse this shape — every module is its own package so code is duplicated, not shared, for easy independent evolution. + +- [ ] **Step 1: Write the failing test** + +```go +package packages + +import ( + "context" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string + outCalls [][]string + installed map[string]bool // key: pkg name +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { return []byte(args[1] + " 1.0-1\n"), nil } + return nil, errExitOne + } + return nil, nil +} + +type exitOneErr struct{} +func (exitOneErr) Error() string { return "exit status 1" } +var errExitOne = exitOneErr{} + +type nopReporter struct{} +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestDetectAbsent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"curl", "wget"}, platform.NewPacman(r)) + st, err := m.Detect(context.Background()) + if err != nil { t.Fatal(err) } + s := st.(state) + if len(s.Missing) != 2 { t.Fatalf("want 2 missing, got %v", s.Missing) } +} + +func TestDetectPresent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"curl": true, "wget": true}} + m := New([]string{"curl", "wget"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + if len(st.(state).Missing) != 0 { t.Fatalf("want 0 missing, got %v", st.(state).Missing) } +} + +func TestPlanAbsentEmitsInstallAction(t *testing.T) { + m := New([]string{"curl", "wget"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: []string{"curl", "wget"}}) + if len(p.Actions) != 1 { t.Fatalf("want 1 action, got %d", len(p.Actions)) } + if !strings.Contains(p.Actions[0].Description, "curl") { t.Fatalf("desc: %s", p.Actions[0].Description) } +} + +func TestPlanPresentEmpty(t *testing.T) { + m := New([]string{"curl"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: nil}) + if !p.IsEmpty() { t.Fatal("want empty plan") } +} + +func TestApplyShellsOut(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"curl"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) } + sawInstall := false + for _, c := range r.runCalls { + if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" { sawInstall = true } + } + if !sawInstall { t.Fatalf("no pacman install call; calls=%v", r.runCalls) } +} + +func TestVerifyAfterApply(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"curl": true}} + m := New([]string{"curl"}, platform.NewPacman(r)) + if err := m.Verify(context.Background()); err != nil { t.Fatal(err) } +} + +var _ module.Module = Module{} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/modules/packages/... -v +``` +Expected: `undefined: New`. + +- [ ] **Step 3: Implement `packages.go`** + +```go +// Package packages installs a declarative list of pacman packages. +package packages + +import ( + "context" + "fmt" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + pkgs []string + pm platform.PackageManager +} + +func New(pkgs []string, pm platform.PackageManager) Module { + return Module{pkgs: pkgs, pm: pm} +} + +type state struct{ Missing []string } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "packages", Name: "Base packages", Description: "Install base pacman package list."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []string + for _, p := range m.pkgs { + ok, err := m.pm.IsInstalled(ctx, p) + if err != nil { return nil, err } + if !ok { missing = append(missing, p) } + } + return state{Missing: missing}, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + if len(s.Missing) == 0 { return module.Plan{}, nil } + missing := append([]string{}, s.Missing...) + return module.Plan{Actions: []module.Action{{ + Description: "pacman install " + strings.Join(missing, ", "), + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) }, + }}}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx) + if err != nil { return err } + if missing := st.(state).Missing; len(missing) > 0 { + return fmt.Errorf("packages not installed after apply: %v", missing) + } + return nil +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/modules/packages/... -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/modules/packages/ +git commit -m "feat(modules): port packages module to Go" +``` + +--- + +## Tasks 9–11: Pacman-only modules (`gittools`, `starship`, `yazi`) + +Each is a structural copy of Task 8 with a single hardcoded package list and a distinct `Meta().ID`. For clarity the engineer should duplicate the Task 8 code verbatim into each new package directory, changing only: + +- Package name (dir + `package X`). +- `Meta().ID` and `.Name` / `.Description`. +- The `New` signature may take `pkgs []string` from config or hardcode the single package name. Use the same `New(pkgs []string, pm platform.PackageManager)` signature for consistency with Task 8. + +At registration time (Task 18), the install command supplies the lists. + +### Task 9: `gittools` + +**Files:** +- Create: `internal/modules/gittools/gittools.go` — copy Task 8 `packages.go`, change `packages` → `gittools`, Meta ID `"gittools"`, Name `"Git tools"`, Description `"Install lazygit."`. +- Create: `internal/modules/gittools/gittools_test.go` — copy Task 8 test file, `s/packages/gittools/g`, adjust package list to `[]string{"lazygit"}` in the Detect/Apply tests. + +- [ ] **Step 1: Duplicate Task 8 files into `internal/modules/gittools/` with `gittools` as the package name and `"gittools"` as `Meta().ID`.** +- [ ] **Step 2: Change test fixture packages from `[]string{"curl", "wget"}` to `[]string{"lazygit"}` in `TestDetectAbsent`, `TestDetectPresent`, `TestApplyShellsOut`, `TestVerifyAfterApply`.** +- [ ] **Step 3: Run tests, confirm pass:** `go test ./internal/modules/gittools/... -v` +- [ ] **Step 4: Commit:** + ```bash + git add internal/modules/gittools/ + git commit -m "feat(modules): port gittools (lazygit) module to Go" + ``` + +### Task 10: `starship` + +- [ ] **Step 1: Duplicate Task 8 into `internal/modules/starship/`; Meta ID `"starship"`, Name `"Starship prompt"`, Description `"Install starship prompt."`.** +- [ ] **Step 2: Change fixture packages to `[]string{"starship"}`.** +- [ ] **Step 3: Run tests:** `go test ./internal/modules/starship/... -v` +- [ ] **Step 4: Commit:** + ```bash + git add internal/modules/starship/ + git commit -m "feat(modules): port starship module to Go" + ``` + +### Task 11: `yazi` + +- [ ] **Step 1: Duplicate Task 8 into `internal/modules/yazi/`; Meta ID `"yazi"`, Name `"Yazi file manager"`, Description `"Install yazi."`.** +- [ ] **Step 2: Change fixture packages to `[]string{"yazi"}`.** +- [ ] **Step 3: Run tests:** `go test ./internal/modules/yazi/... -v` +- [ ] **Step 4: Commit:** + ```bash + git add internal/modules/yazi/ + git commit -m "feat(modules): port yazi module to Go" + ``` + +--- + +## Task 12: `zsh` module — pacman install + oh-my-zsh + plugins via git + +**Files:** +- Create: `internal/modules/zsh/zsh.go` +- Create: `internal/modules/zsh/zsh_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package zsh + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string; outCalls [][]string; installed map[string]bool +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)); return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { return []byte("ok"), nil } + return nil, errOne + } + return nil, nil +} +type eo struct{} +func (eo) Error() string { return "exit 1" } +var errOne = eo{} + +type nopReporter struct{} +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestZshDetectAbsent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.ZshConfig{ + OhMyZshRepo: "https://x/omz.git", + Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}}, + }, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + s := st.(state) + if s.ZshInstalled { t.Error("want zsh absent") } + if s.OMZInstalled { t.Error("want omz absent") } + if len(s.MissingPlugins) != 1 { t.Errorf("want 1 plugin missing, got %v", s.MissingPlugins) } +} + +func TestZshDetectAllPresent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0o755) + os.MkdirAll(filepath.Join(home, ".oh-my-zsh", "custom", "plugins", "zsh-autosuggestions", ".git"), 0o755) + r := &fakeRunner{installed: map[string]bool{"zsh": true}} + m := New(config.ZshConfig{ + OhMyZshRepo: "https://x/omz.git", + Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}}, + }, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + s := st.(state) + if !s.ZshInstalled || !s.OMZInstalled || len(s.MissingPlugins) != 0 { + t.Fatalf("want all present, got %+v", s) + } +} + +func TestZshPlanApplyInstallsEverything(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.ZshConfig{ + OhMyZshRepo: "https://x/omz.git", + Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}}, + }, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if len(p.Actions) == 0 { t.Fatal("want non-empty plan") } + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) } + sawPacman, sawOMZ, sawPlugin := false, false, false + for _, c := range r.runCalls { + if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" { sawPacman = true } + if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/omz.git" { sawOMZ = true } + if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/a.git" { sawPlugin = true } + } + if !sawPacman || !sawOMZ || !sawPlugin { + t.Fatalf("missing step; calls=%v", r.runCalls) + } +} + +var _ module.Module = Module{} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/modules/zsh/... -v +``` +Expected: `undefined: New`. + +- [ ] **Step 3: Implement `zsh.go`** + +```go +// Package zsh installs zsh, oh-my-zsh, and configured plugins. +package zsh + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + cfg config.ZshConfig + pm platform.PackageManager + git platform.GitClient +} + +func New(cfg config.ZshConfig, pm platform.PackageManager, git platform.GitClient) Module { + return Module{cfg: cfg, pm: pm, git: git} +} + +type state struct { + ZshInstalled bool + OMZInstalled bool + MissingPlugins []config.ZshPlugin +} + +func omzHome() (string, error) { + h, err := os.UserHomeDir(); if err != nil { return "", err } + return filepath.Join(h, ".oh-my-zsh"), nil +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "zsh", Name: "Zsh + oh-my-zsh", Description: "Install zsh, oh-my-zsh, and plugins."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var s state + ok, err := m.pm.IsInstalled(ctx, "zsh"); if err != nil { return nil, err } + s.ZshInstalled = ok + omz, err := omzHome(); if err != nil { return nil, err } + if _, err := os.Stat(omz); err == nil { s.OMZInstalled = true } + for _, p := range m.cfg.Plugins { + path := filepath.Join(omz, "custom", "plugins", p.Name, ".git") + if _, err := os.Stat(path); err != nil { s.MissingPlugins = append(s.MissingPlugins, p) } + } + return s, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + if !s.ZshInstalled { + actions = append(actions, module.Action{ + Description: "pacman install zsh", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zsh"}) }, + }) + } + if !s.OMZInstalled { + actions = append(actions, module.Action{ + Description: "clone oh-my-zsh from " + m.cfg.OhMyZshRepo, + Kind: module.ActionGitClone, + Exec: func(ctx context.Context) error { + omz, err := omzHome(); if err != nil { return err } + return m.git.EnsureRepo(ctx, m.cfg.OhMyZshRepo, omz) + }, + }) + } + for _, p := range s.MissingPlugins { + p := p // capture + actions = append(actions, module.Action{ + Description: "clone plugin " + p.Name, + Kind: module.ActionGitClone, + Exec: func(ctx context.Context) error { + omz, err := omzHome(); if err != nil { return err } + return m.git.EnsureRepo(ctx, p.Repo, filepath.Join(omz, "custom", "plugins", p.Name)) + }, + }) + } + return module.Plan{Actions: actions}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx); if err != nil { return err } + s := st.(state) + if !s.ZshInstalled { return fmt.Errorf("zsh not installed") } + if !s.OMZInstalled { return fmt.Errorf("oh-my-zsh not installed") } + if len(s.MissingPlugins) > 0 { return fmt.Errorf("plugins missing: %v", s.MissingPlugins) } + return nil +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/modules/zsh/... -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/modules/zsh/ +git commit -m "feat(modules): port zsh module (zsh + oh-my-zsh + plugins) to Go" +``` + +--- + +## Task 13: `fonts` module — AUR install + +**Files:** +- Create: `internal/modules/fonts/fonts.go` +- Create: `internal/modules/fonts/fonts_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package fonts + +import ( + "context" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string; outCalls [][]string; installed map[string]bool +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)); return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { return []byte("ok"), nil } + return nil, errOne + } + return nil, nil +} +type eo struct{} +func (eo) Error() string { return "exit 1" } +var errOne = eo{} +type nop struct{} +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func TestFontsPlanApplyUsesYay(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"yay": true}} + m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { t.Fatal(err) } + sawYay := false + for _, c := range r.runCalls { + if len(c) >= 2 && c[0] == "yay" && c[1] == "-S" { sawYay = true } + } + if !sawYay { t.Fatalf("no yay install; calls=%v", r.runCalls) } +} + +func TestFontsDetectPresentNoActions(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"yay": true, "ttf-jetbrains-mono-nerd": true}} + m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if !p.IsEmpty() { t.Fatal("want empty plan when already installed") } +} + +var _ module.Module = Module{} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/modules/fonts/... -v +``` + +- [ ] **Step 3: Implement `fonts.go`** + +```go +// Package fonts installs AUR fonts via the yay helper. +package fonts + +import ( + "context" + "fmt" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + fonts []string + pm platform.PackageManager + aur platform.AURHelper +} + +func New(fonts []string, pm platform.PackageManager, aur platform.AURHelper) Module { + return Module{fonts: fonts, pm: pm, aur: aur} +} + +type state struct{ Missing []string } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "fonts", Name: "Nerd fonts", Description: "Install AUR nerd fonts."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []string + for _, f := range m.fonts { + ok, err := m.pm.IsInstalled(ctx, f); if err != nil { return nil, err } + if !ok { missing = append(missing, f) } + } + return state{Missing: missing}, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + if len(s.Missing) == 0 { return module.Plan{}, nil } + missing := append([]string{}, s.Missing...) + return module.Plan{Actions: []module.Action{ + { + Description: "ensure yay installed", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) }, + }, + { + Description: "yay install " + strings.Join(missing, ", "), + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.aur.Install(ctx, missing) }, + }, + }}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx); if err != nil { return err } + if miss := st.(state).Missing; len(miss) > 0 { + return fmt.Errorf("fonts still missing: %v", miss) + } + return nil +} +``` + +- [ ] **Step 4: Run tests, confirm pass:** `go test ./internal/modules/fonts/... -v` +- [ ] **Step 5: Commit:** + ```bash + git add internal/modules/fonts/ + git commit -m "feat(modules): port fonts module (AUR) to Go" + ``` + +--- + +## Task 14: `zellij` module — pacman install + optional config download + +**Files:** +- Create: `internal/modules/zellij/zellij.go` +- Create: `internal/modules/zellij/zellij_test.go` + +Contract: install `zellij` via pacman; if `cfg.ConfigURL` is non-empty, download it to `~/.config/zellij/config.kdl` (idempotent via file-stat check). + +- [ ] **Step 1: Write the failing test** + +```go +package zellij + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string; outCalls [][]string; installed map[string]bool +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)); return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { return []byte("ok"), nil } + return nil, errOne + } + return nil, nil +} +type eo struct{} +func (eo) Error() string { return "exit 1" } +var errOne = eo{} +type nop struct{} +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func TestZellijPlanAppliesInstallAndConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("keybinds {}\n")) + })) + defer srv.Close() + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.ZellijConfig{ConfigURL: srv.URL}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { t.Fatal(err) } + got, err := os.ReadFile(filepath.Join(home, ".config", "zellij", "config.kdl")) + if err != nil { t.Fatalf("config not written: %v", err) } + if string(got) != "keybinds {}\n" { t.Fatalf("unexpected content %q", got) } +} + +func TestZellijInstalledPresentEmptyPlan(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{"zellij": true}} + m := New(config.ZellijConfig{ConfigURL: ""}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if !p.IsEmpty() { t.Fatalf("want empty plan, got %+v", p) } +} + +var _ module.Module = Module{} +``` + +- [ ] **Step 2: Run test, confirm fail:** `go test ./internal/modules/zellij/... -v` + +- [ ] **Step 3: Implement `zellij.go`** + +```go +// Package zellij installs zellij and optionally writes a remote config. +package zellij + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + cfg config.ZellijConfig + pm platform.PackageManager +} + +func New(cfg config.ZellijConfig, pm platform.PackageManager) Module { + return Module{cfg: cfg, pm: pm} +} + +type state struct { + Installed bool + ConfigOK bool + ConfigPath string +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "zellij", Name: "Zellij terminal multiplexer", Description: "Install zellij and config."} +} + +func configPath() (string, error) { + h, err := os.UserHomeDir(); if err != nil { return "", err } + return filepath.Join(h, ".config", "zellij", "config.kdl"), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var s state + ok, err := m.pm.IsInstalled(ctx, "zellij"); if err != nil { return nil, err } + s.Installed = ok + p, err := configPath(); if err != nil { return nil, err } + s.ConfigPath = p + if m.cfg.ConfigURL == "" { + s.ConfigOK = true + } else if _, err := os.Stat(p); err == nil { + s.ConfigOK = true + } + return s, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + if !s.Installed { + actions = append(actions, module.Action{ + Description: "pacman install zellij", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zellij"}) }, + }) + } + if !s.ConfigOK { + actions = append(actions, module.Action{ + Description: "download zellij config", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + if err := os.MkdirAll(filepath.Dir(s.ConfigPath), 0o755); err != nil { return err } + return platform.Download(ctx, m.cfg.ConfigURL, s.ConfigPath, 0o644) + }, + }) + } + return module.Plan{Actions: actions}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx); if err != nil { return err } + s := st.(state) + if !s.Installed { return fmt.Errorf("zellij not installed") } + if !s.ConfigOK { return fmt.Errorf("zellij config missing") } + return nil +} +``` + +- [ ] **Step 4: Run tests:** `go test ./internal/modules/zellij/... -v` +- [ ] **Step 5: Commit:** + ```bash + git add internal/modules/zellij/ + git commit -m "feat(modules): port zellij module to Go" + ``` + +--- + +## Task 15: `themes` module — copy theme files from source dir + +**Files:** +- Create: `internal/modules/themes/themes.go` +- Create: `internal/modules/themes/themes_test.go` + +Contract: copy every file under `cfg.Source` into `~/.config/archduino/themes/`, preserving relative paths. Idempotent via byte-equality check per file. + +- [ ] **Step 1: Write the failing test** + +```go +package themes + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type nop struct{} +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func TestThemesCopiesFiles(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "one.toml"), []byte("A"), 0o644); err != nil { t.Fatal(err) } + os.MkdirAll(filepath.Join(src, "sub"), 0o755) + os.WriteFile(filepath.Join(src, "sub", "two.toml"), []byte("B"), 0o644) + + home := t.TempDir(); t.Setenv("HOME", home) + m := New(config.ThemesConfig{Source: src}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { t.Fatal(err) } + + got, _ := os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "one.toml")) + if string(got) != "A" { t.Fatalf("one.toml: %q", got) } + got, _ = os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "sub", "two.toml")) + if string(got) != "B" { t.Fatalf("two.toml: %q", got) } +} + +func TestThemesIdempotent(t *testing.T) { + src := t.TempDir() + os.WriteFile(filepath.Join(src, "x.toml"), []byte("X"), 0o644) + home := t.TempDir(); t.Setenv("HOME", home) + m := New(config.ThemesConfig{Source: src}) + _ = applyOnce(t, m) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if !p.IsEmpty() { t.Fatalf("second plan not empty: %+v", p.Actions) } +} + +func applyOnce(t *testing.T, m Module) error { + t.Helper() + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + return m.Apply(context.Background(), p, nop{}) +} + +var _ module.Module = Module{} +``` + +- [ ] **Step 2: Run test, confirm fail:** `go test ./internal/modules/themes/... -v` + +- [ ] **Step 3: Implement `themes.go`** + +```go +// Package themes installs archduino theme files into ~/.config/archduino/themes. +package themes + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type Module struct { cfg config.ThemesConfig } + +func New(cfg config.ThemesConfig) Module { return Module{cfg: cfg} } + +type filePair struct{ src, dst string } +type state struct{ Missing []filePair } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "themes", Name: "Colorscheme themes", Description: "Install archduino themes."} +} + +func destRoot() (string, error) { + h, err := os.UserHomeDir(); if err != nil { return "", err } + return filepath.Join(h, ".config", "archduino", "themes"), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + dst, err := destRoot(); if err != nil { return nil, err } + var missing []filePair + err = filepath.WalkDir(m.cfg.Source, func(p string, d fs.DirEntry, werr error) error { + if werr != nil { return werr } + if d.IsDir() { return nil } + rel, _ := filepath.Rel(m.cfg.Source, p) + target := filepath.Join(dst, rel) + a, errA := os.ReadFile(p) + if errA != nil { return errA } + b, errB := os.ReadFile(target) + if errB != nil || !bytes.Equal(a, b) { + missing = append(missing, filePair{src: p, dst: target}) + } + return nil + }) + if err != nil { + if os.IsNotExist(err) { return state{}, nil } + return nil, err + } + return state{Missing: missing}, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + if len(s.Missing) == 0 { return module.Plan{}, nil } + pairs := append([]filePair{}, s.Missing...) + return module.Plan{Actions: []module.Action{{ + Description: fmt.Sprintf("copy %d theme files", len(pairs)), + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + for _, fp := range pairs { + if err := os.MkdirAll(filepath.Dir(fp.dst), 0o755); err != nil { return err } + data, err := os.ReadFile(fp.src); if err != nil { return err } + if err := os.WriteFile(fp.dst, data, 0o644); err != nil { return err } + } + return nil + }, + }}}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx); if err != nil { return err } + if miss := st.(state).Missing; len(miss) > 0 { + return fmt.Errorf("themes still missing: %d files", len(miss)) + } + return nil +} +``` + +- [ ] **Step 4: Run tests:** `go test ./internal/modules/themes/... -v` +- [ ] **Step 5: Commit:** + ```bash + git add internal/modules/themes/ + git commit -m "feat(modules): port themes module to Go" + ``` + +--- + +## Task 16: `arduinocli` module — download, extract, install cores + +**Files:** +- Create: `internal/modules/arduinocli/arduinocli.go` +- Create: `internal/modules/arduinocli/arduinocli_test.go` + +Contract: if `~/.local/bin/arduino-cli` missing → download tarball, extract, place binary. Then for each core in `cfg.Cores`, run `arduino-cli core install ` (only if not already installed, checked via `arduino-cli core list`). + +- [ ] **Step 1: Write the failing test** + +```go +package arduinocli + +import ( + "archive/tar" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type fakeRunner struct { + runCalls [][]string; outCalls [][]string + coreListOut []byte +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)); return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if filepath.Base(name) == "arduino-cli" && len(args) >= 2 && args[0] == "core" && args[1] == "list" { + return f.coreListOut, nil + } + return nil, nil +} +type nop struct{} +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func makeTarGz(t *testing.T, entries map[string][]byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.tgz") + f, _ := os.Create(p); gz := gzip.NewWriter(f); tw := tar.NewWriter(gz) + for name, data := range entries { + tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))}) + tw.Write(data) + } + tw.Close(); gz.Close(); f.Close() + return p +} + +func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\n")}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.ServeFile(w, nil, archive) + })) + defer srv.Close() + + r := &fakeRunner{coreListOut: []byte("ID Installed\n")} + m := New(config.ArduinoCLIConfig{ + Version: "1.0.0", + URL: srv.URL + "/arduino-cli.tgz", + Cores: []string{"arduino:avr"}, + }, r) + + // NOTE: test needs srv to actually serve file bytes — use http.ServeContent style. + // The above ServeFile(nil, ...) is illustrative; an acceptable replacement uses w.Write of archive bytes. + _ = archive + + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { t.Fatal(err) } + + if _, err := os.Stat(filepath.Join(home, ".local", "bin", "arduino-cli")); err != nil { + t.Fatalf("binary not installed: %v", err) + } + sawCore := false + for _, c := range r.runCalls { + if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" && c[3] == "arduino:avr" { sawCore = true } + } + if !sawCore { t.Fatalf("core install not run; calls=%v", r.runCalls) } +} + +var _ module.Module = Module{} +``` + +Note to the engineer: if `http.ServeFile(w, nil, archive)` fails to compile (it requires a non-nil `*http.Request`), rewrite the handler as: + +```go +srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := os.ReadFile(archive); w.Write(data) +})) +``` + +- [ ] **Step 2: Run test, confirm fail:** `go test ./internal/modules/arduinocli/... -v` + +- [ ] **Step 3: Implement `arduinocli.go`** + +```go +// Package arduinocli installs arduino-cli and its configured cores. +package arduinocli + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + cfg config.ArduinoCLIConfig + runner platform.Runner +} + +func New(cfg config.ArduinoCLIConfig, r platform.Runner) Module { + return Module{cfg: cfg, runner: r} +} + +type state struct { + BinaryPath string + BinaryInstalled bool + MissingCores []string +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "arduinocli", Name: "arduino-cli", Description: "Install arduino-cli and cores."} +} + +func binaryPath() (string, error) { + h, err := os.UserHomeDir(); if err != nil { return "", err } + return filepath.Join(h, ".local", "bin", "arduino-cli"), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + bp, err := binaryPath(); if err != nil { return nil, err } + s := state{BinaryPath: bp} + if _, err := os.Stat(bp); err == nil { + s.BinaryInstalled = true + out, _ := m.runner.Output(ctx, bp, "core", "list") + for _, core := range m.cfg.Cores { + if !bytes.Contains(out, []byte(core)) { + s.MissingCores = append(s.MissingCores, core) + } + } + } else { + s.MissingCores = append([]string{}, m.cfg.Cores...) + } + return s, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + if !s.BinaryInstalled { + bp := s.BinaryPath + actions = append(actions, module.Action{ + Description: "download + install arduino-cli " + m.cfg.Version, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + tmpDir, err := os.MkdirTemp("", "arduinocli-"); if err != nil { return err } + defer os.RemoveAll(tmpDir) + archive := filepath.Join(tmpDir, "arduino-cli.tar.gz") + if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil { return err } + if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil { return err } + if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil { return err } + data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli")); if err != nil { return err } + return os.WriteFile(bp, data, 0o755) + }, + }) + } + for _, core := range s.MissingCores { + core := core + actions = append(actions, module.Action{ + Description: "arduino-cli core install " + core, + Kind: module.ActionCommand, + Exec: func(ctx context.Context) error { + return m.runner.Run(ctx, s.BinaryPath, "core", "update-index") + }, + }) + actions = append(actions, module.Action{ + Description: "arduino-cli core install " + core, + Kind: module.ActionCommand, + Exec: func(ctx context.Context) error { + return m.runner.Run(ctx, s.BinaryPath, "core", "install", core) + }, + }) + } + return module.Plan{Actions: actions}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx); if err != nil { return err } + s := st.(state) + if !s.BinaryInstalled { return fmt.Errorf("arduino-cli binary missing") } + if len(s.MissingCores) > 0 { return fmt.Errorf("cores missing: %v", s.MissingCores) } + return nil +} +``` + +- [ ] **Step 4: Run tests:** `go test ./internal/modules/arduinocli/... -v` +- [ ] **Step 5: Commit:** + ```bash + git add internal/modules/arduinocli/ + git commit -m "feat(modules): port arduino-cli module to Go" + ``` + +--- + +## Task 17: `shellconfig` module — text blocks into rc files + +**Files:** +- Create: `internal/modules/shellconfig/shellconfig.go` +- Create: `internal/modules/shellconfig/shellconfig_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package shellconfig + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type nop struct{} +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func TestShellConfigWritesBlocks(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + m := New(config.ShellConfigSection{ + Blocks: []config.ShellConfigBlock{ + {Name: "path", File: ".zshrc", Content: `export PATH="$HOME/.local/bin:$PATH"`}, + }, + }) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { t.Fatal(err) } + data, _ := os.ReadFile(filepath.Join(home, ".zshrc")) + if !strings.Contains(string(data), ">>> path >>>") { t.Fatalf("block not present: %q", data) } + if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) { t.Fatalf("content missing: %q", data) } +} + +func TestShellConfigIdempotent(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + cfg := config.ShellConfigSection{Blocks: []config.ShellConfigBlock{ + {Name: "x", File: ".zshrc", Content: "echo x"}, + }} + m := New(cfg) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + _ = m.Apply(context.Background(), p, nop{}) + st2, _ := m.Detect(context.Background()) + p2, _ := m.Plan(context.Background(), st2) + if !p2.IsEmpty() { t.Fatalf("second plan not empty: %+v", p2.Actions) } +} + +var _ module.Module = Module{} +``` + +- [ ] **Step 2: Run test, confirm fail:** `go test ./internal/modules/shellconfig/... -v` + +- [ ] **Step 3: Implement `shellconfig.go`** + +```go +// Package shellconfig writes marker-delimited blocks into rc files. +package shellconfig + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { cfg config.ShellConfigSection } + +func New(cfg config.ShellConfigSection) Module { return Module{cfg: cfg} } + +type state struct{ Missing []config.ShellConfigBlock } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "shellconfig", Name: "Shell config blocks", Description: "Write archduino blocks into rc files."} +} + +func resolveFile(name string) (string, error) { + h, err := os.UserHomeDir(); if err != nil { return "", err } + return filepath.Join(h, name), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []config.ShellConfigBlock + for _, b := range m.cfg.Blocks { + p, err := resolveFile(b.File); if err != nil { return nil, err } + has, err := platform.HasBlock(p, b.Name); if err != nil { return nil, err } + if !has { missing = append(missing, b) } + } + return state{Missing: missing}, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + for _, b := range s.Missing { + b := b + actions = append(actions, module.Action{ + Description: fmt.Sprintf("write block %s into %s", b.Name, b.File), + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + p, err := resolveFile(b.File); if err != nil { return err } + return platform.EnsureBlock(p, b.Name, b.Content) + }, + }) + } + return module.Plan{Actions: actions}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx); if err != nil { return err } + if miss := st.(state).Missing; len(miss) > 0 { + return fmt.Errorf("shell blocks still missing: %d", len(miss)) + } + return nil +} +``` + +- [ ] **Step 4: Run tests:** `go test ./internal/modules/shellconfig/... -v` +- [ ] **Step 5: Commit:** + ```bash + git add internal/modules/shellconfig/ + git commit -m "feat(modules): port shellconfig module to Go" + ``` + +--- + +## Task 18: Register all 10 modules in the install command + manual smoke + +**Files:** +- Modify: `cmd/archduino/install.go` (or wherever the registry is built — find it with `grep -rn "module.NewRegistry\|Register(" cmd/`) + +- [ ] **Step 1: Locate the registry builder** + +``` +grep -rn "module.NewRegistry\|\.Register(" cmd/ internal/cmd/ 2>/dev/null +``` + +Identify the function that currently registers `hello`. Call it `buildRegistry(cfg config.Config) *module.Registry` in what follows. + +- [ ] **Step 2: Replace the hello registration with the full set** + +Replace the registration block with: + +```go +reg := module.NewRegistry() +runner := platform.ExecRunner{} +pm := platform.NewPacman(runner) +yay := platform.NewYay(runner) +git := platform.GitClient{Runner: runner} + +_ = reg.Register(packages.New(cfg.Packages.Base, pm)) +_ = reg.Register(gittools.New([]string{"lazygit"}, pm)) +_ = reg.Register(starship.New([]string{"starship"}, pm)) +_ = reg.Register(yazi.New([]string{"yazi"}, pm)) +_ = reg.Register(zsh.New(cfg.Zsh, pm, git)) +_ = reg.Register(fonts.New(cfg.Fonts, pm, yay)) +_ = reg.Register(zellij.New(cfg.Zellij, pm)) +_ = reg.Register(themes.New(cfg.Themes)) +_ = reg.Register(arduinocli.New(cfg.ArduinoCLI, runner)) +_ = reg.Register(shellconfig.New(cfg.ShellConfig)) +``` + +Add matching imports: + +```go +import ( + "github.com/MDietermann/ArduinoArchSetup/internal/modules/arduinocli" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/fonts" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/gittools" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/packages" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/shellconfig" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/starship" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/themes" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/yazi" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/zellij" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/zsh" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) +``` + +Keep the `hello` module registration commented out or deleted — its purpose (end-to-end engine smoke) is superseded. + +- [ ] **Step 3: Ensure the full test suite still passes** + +``` +go test ./... +``` +Expected: PASS. + +- [ ] **Step 4: Manual smoke — dry Detect/Plan only, no Apply** + +Run the install command. In the wizard, deselect everything except `packages` and `gittools` (they're quick `IsInstalled` checks). Reach the "Review plan" screen; it should render without UI lag (Task 1 caching). Abort before confirming. + +``` +go run ./cmd/archduino install +``` +Expected: wizard reaches plan review in under a second, displays `• packages — …` and `• gittools — …`. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/archduino/install.go +git commit -m "feat(cmd): register all Plan 3a modules in install command" +``` + +--- + +## Self-review notes + +- Spec coverage: wizard fix (Task 1), five helpers (Tasks 2–6), config extension (Task 7), ten modules (Tasks 8–17), registration (Task 18). All spec sections mapped. +- Type consistency: `Module` struct with `Meta() module.Meta`, `Detect/Plan/Apply/Verify` across all ten module tasks; `New(...)` constructors take config struct + platform deps; state types are module-private `state struct`. `config.ZshConfig`, `config.ZellijConfig`, `config.ThemesConfig`, `config.ArduinoCLIConfig`, `config.ShellConfigSection` referenced consistently. +- Known approximations the engineer should verify as they go: + - `lib/packages.sh` actual package list may include more than the starter set in Task 7's `defaults.yaml` — cross-reference and extend. + - `lib/shell-config.sh` blocks (PATH, starship init, etc.) — verify the full set and extend `defaults.yaml` accordingly. + - Task 6 Step 3 contains a note about adjusting the `makepkg` assertion in the test — ensure the test is updated consistent with the `sh -c` invocation before running Step 4.