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

2560 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `# >>> <marker> >>>` and `# <<< <marker> <<<` 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 <core>` (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.