# 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.