Files
Archduino/docs/superpowers/plans/2026-04-14-plan3a-install-modules.md

83 KiB
Raw Blame History

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:

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:

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):

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:

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:

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:

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:

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
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 # >>> <marker> >>> and # <<< <marker> <<< so that the same helper works for .zshrc, .bashrc, and any other #-commented config file.

  • Step 1: Write the failing test
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
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
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

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

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

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
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
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
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
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:

        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
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:

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:

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:

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:

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
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
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
// 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
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:

    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:
    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:
    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

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
// 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
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

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
// 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:
    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
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

// 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:
    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
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

// 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:
    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 <core> (only if not already installed, checked via arduino-cli core list).

  • Step 1: Write the failing test
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:

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

// 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:
    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

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

// 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:
    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:

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:

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