diff --git a/docs/superpowers/plans/2026-04-13-installer-foundation.md b/docs/superpowers/plans/2026-04-13-installer-foundation.md new file mode 100644 index 0000000..4e47867 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-installer-foundation.md @@ -0,0 +1,2615 @@ +# Installer Foundation Implementation Plan (Plan 1 of 4) + +> **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:** Stand up the Go project skeleton and build a working installer *engine* (module contract, DAG scheduler, config overlay, platform/privilege/state plumbing) end-to-end, validated by a single toy module running on a clean Arch container. + +**Architecture:** A single Go binary (`archduino`) with cobra subcommands. Install work is organized as **modules** implementing `Detect → Plan → Apply → Verify`. A DAG-aware engine runs independent modules concurrently, streams events to a `Reporter`, and supports dry-run + continue-on-failure. This plan builds everything *except* the TUI, the 13 real install modules, and the distribution pipeline — those are Plans 2, 3, and 4. + +**Tech Stack:** Go 1.22+, `cobra` (CLI), `yaml.v3` (overlay), standard library for everything else. Testing via stdlib `testing` + table-driven tests with injected fakes. + +**Spec:** `docs/superpowers/specs/2026-04-13-installer-architecture-design.md`. + +--- + +## Conventions used throughout this plan + +- **Module path:** `github.com/MDietermann/archduino`. If your GitHub owner/repo is different, adjust `go.mod` and all import paths in Task 1 and keep the rest of the plan consistent. +- **Go version:** 1.22 or newer. All code targets 1.22 features (e.g. `slices`, `range` over integers is not required but allowed). +- **Test style:** table-driven with `t.Run(tc.name, ...)` subtests; no external assertion libraries. +- **Commits:** one commit per completed task (after tests pass). Message format: `feat(scope): short summary` or `test(scope): ...`, `chore(scope): ...` as appropriate. +- **Working directory:** repository root (`/home/marvin/git-clones/ArduinoArchSetup`). All file paths below are relative to that root unless otherwise noted. +- **Branch:** all work on `feat/go-rewrite`. Create it off `feat/renode-dap` at the start of Task 1. + +## File structure produced by this plan + +``` +ArduinoArchSetup/ +├── go.mod +├── go.sum +├── cmd/archduino/ +│ └── main.go # cobra root + `install` subcommand +├── internal/ +│ ├── module/ +│ │ ├── module.go # Module interface, ModuleMeta, State, Plan, Action, ActionKind +│ │ └── registry.go # Registry: Register, Get, All, sorted by deps +│ ├── engine/ +│ │ ├── reporter.go # Reporter: event stream from modules → UI/log +│ │ ├── dag.go # Topological sort + descendant computation +│ │ ├── dag_test.go +│ │ ├── engine.go # Run(): Detect → Plan → (dry-run or Apply+Verify) +│ │ └── engine_test.go +│ ├── platform/ +│ │ ├── platform.go # Platform{Distro, IsWSL, Arch}; Detect() +│ │ ├── platform_test.go +│ │ ├── pkgmgr.go # PackageManager interface +│ │ └── pacman.go # Pacman implementation (exec-based) +│ ├── privilege/ +│ │ └── sudo.go # Acquire() + keepalive goroutine +│ ├── config/ +│ │ ├── config.go # Overlay schema + Load + Merge +│ │ ├── config_test.go +│ │ └── defaults.yaml # embedded default config (empty lists for now) +│ ├── state/ +│ │ ├── state.go # RunLog: open, record, close, LastRun() +│ │ └── state_test.go +│ └── modules/ +│ └── hello/ +│ ├── hello.go # Toy module: writes a marker file +│ └── hello_test.go +└── test/ + └── integration/ + ├── Dockerfile # archlinux:base + go, runs install --no-tui + └── run.sh # builds image, runs container, asserts marker file +``` + +Ancillary touch: +- `.gitignore` — add `archduino` (build artifact), `dist/`, `*.test`, coverage files. + +--- + +## Task 1: Bootstrap Go project and cobra skeleton + +**Goal:** Create the branch, initialize a Go module, add a runnable `archduino` binary with a `cobra` root command and a stub `install` subcommand. No logic yet — just proves the build works. + +**Files:** +- Create: `go.mod` +- Create: `cmd/archduino/main.go` +- Modify: `.gitignore` + +- [ ] **Step 1: Create the branch** + +```bash +git checkout feat/renode-dap +git pull --ff-only 2>/dev/null || true +git checkout -b feat/go-rewrite +``` + +- [ ] **Step 2: Initialize the Go module** + +```bash +go mod init github.com/MDietermann/archduino +go get github.com/spf13/cobra@latest +``` + +Expected: `go.mod` created with `module github.com/MDietermann/archduino` and a `require github.com/spf13/cobra vX.Y.Z` line. + +- [ ] **Step 3: Write `cmd/archduino/main.go`** + +```go +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var version = "dev" + +func main() { + root := &cobra.Command{ + Use: "archduino", + Short: "Archduino installer and toolbox", + SilenceUsage: true, + SilenceErrors: true, + Version: version, + } + + root.AddCommand(newInstallCmd()) + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func newInstallCmd() *cobra.Command { + var noTUI bool + var dryRun bool + cmd := &cobra.Command{ + Use: "install", + Short: "Install or reconcile the Archduino toolset", + RunE: func(cmd *cobra.Command, args []string) error { + // Foundation scaffold: real wiring lands in Task 11. + fmt.Println("archduino install (stub)") + fmt.Printf(" --no-tui=%v --dry-run=%v\n", noTUI, dryRun) + return nil + }, + } + cmd.Flags().BoolVar(&noTUI, "no-tui", false, "plain-text output, no interactive UI") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned actions without applying") + return cmd +} +``` + +- [ ] **Step 4: Update `.gitignore`** + +Append these lines to `.gitignore` (create the file if it doesn't exist): + +``` +# Go +archduino +/dist/ +*.test +*.out +coverage.* +``` + +- [ ] **Step 5: Build and smoke-test** + +```bash +go build ./cmd/archduino +./archduino install --dry-run +./archduino --version +``` + +Expected: +- `archduino install (stub)` followed by `--no-tui=false --dry-run=true`. +- `archduino version dev`. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum cmd/archduino/main.go .gitignore +git commit -m "chore: bootstrap Go project with cobra skeleton" +``` + +--- + +## Task 2: Module interface and registry + +**Goal:** Define the four-phase `Module` contract and a thread-safe registry. This is the core abstraction every later task depends on. + +**Files:** +- Create: `internal/module/module.go` +- Create: `internal/module/registry.go` +- Test: `internal/module/registry_test.go` + +- [ ] **Step 1: Write `internal/module/module.go`** + +```go +// Package module defines the four-phase contract every installable unit +// implements, along with the value types exchanged with the engine. +package module + +import "context" + +// Meta is static, declarative information about a module. +type Meta struct { + ID string // stable machine identifier, e.g. "packages" + Name string // human label, e.g. "Base packages" + Description string // one-line summary for TUI + Deps []string // IDs of modules that must complete before this one +} + +// State is an opaque, module-defined snapshot of current system state. +// The engine never inspects it; it is threaded from Detect → Plan. +type State any + +// ActionKind categorizes what an Action does, for UI grouping only. +type ActionKind int + +const ( + ActionUnknown ActionKind = iota + ActionPackageInstall + ActionFileWrite + ActionSymlink + ActionCommand + ActionGitClone + ActionGroupAdd +) + +// Action is a single planned change with a human-readable description. +type Action struct { + Description string + Kind ActionKind + Exec func(ctx context.Context) error +} + +// Plan is an ordered list of actions returned by Module.Plan. +type Plan struct { + Actions []Action +} + +// IsEmpty reports whether the plan would make no changes. +func (p Plan) IsEmpty() bool { return len(p.Actions) == 0 } + +// Module is the four-phase contract. +type Module interface { + Meta() Meta + Detect(ctx context.Context) (State, error) + Plan(ctx context.Context, current State) (Plan, error) + Apply(ctx context.Context, plan Plan, r Reporter) error + Verify(ctx context.Context) error +} + +// Reporter is how modules stream progress events. Defined here as an +// interface to avoid an import cycle with the engine package. +type Reporter interface { + Progress(moduleID string, current, total int, message string) + Log(moduleID, line string) +} +``` + +- [ ] **Step 2: Write `internal/module/registry.go`** + +```go +package module + +import ( + "fmt" + "sort" + "sync" +) + +// Registry holds all known modules keyed by Meta.ID. +type Registry struct { + mu sync.RWMutex + modules map[string]Module +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{modules: make(map[string]Module)} +} + +// Register adds a module. Returns an error if the ID is already present +// or empty. Safe for concurrent use. +func (r *Registry) Register(m Module) error { + id := m.Meta().ID + if id == "" { + return fmt.Errorf("module has empty ID") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, dup := r.modules[id]; dup { + return fmt.Errorf("module %q already registered", id) + } + r.modules[id] = m + return nil +} + +// Get returns the module with the given ID, or (nil, false) if absent. +func (r *Registry) Get(id string) (Module, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + m, ok := r.modules[id] + return m, ok +} + +// All returns all registered modules sorted by ID for determinism. +func (r *Registry) All() []Module { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Module, 0, len(r.modules)) + for _, m := range r.modules { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Meta().ID < out[j].Meta().ID + }) + return out +} +``` + +- [ ] **Step 3: Write failing tests in `internal/module/registry_test.go`** + +```go +package module + +import ( + "context" + "testing" +) + +type fakeModule struct{ id string; deps []string } + +func (f fakeModule) Meta() Meta { return Meta{ID: f.id, Name: f.id, Deps: f.deps} } +func (f fakeModule) Detect(context.Context) (State, error) { return nil, nil } +func (f fakeModule) Plan(context.Context, State) (Plan, error) { return Plan{}, nil } +func (f fakeModule) Apply(context.Context, Plan, Reporter) error { return nil } +func (f fakeModule) Verify(context.Context) error { return nil } + +func TestRegistry(t *testing.T) { + t.Run("register then get", func(t *testing.T) { + r := NewRegistry() + if err := r.Register(fakeModule{id: "a"}); err != nil { + t.Fatal(err) + } + m, ok := r.Get("a") + if !ok || m.Meta().ID != "a" { + t.Fatalf("want module a, got %v ok=%v", m, ok) + } + }) + + t.Run("duplicate register errors", func(t *testing.T) { + r := NewRegistry() + _ = r.Register(fakeModule{id: "a"}) + if err := r.Register(fakeModule{id: "a"}); err == nil { + t.Fatal("expected duplicate error") + } + }) + + t.Run("empty id errors", func(t *testing.T) { + r := NewRegistry() + if err := r.Register(fakeModule{id: ""}); err == nil { + t.Fatal("expected empty-id error") + } + }) + + t.Run("all is sorted by id", func(t *testing.T) { + r := NewRegistry() + _ = r.Register(fakeModule{id: "b"}) + _ = r.Register(fakeModule{id: "a"}) + _ = r.Register(fakeModule{id: "c"}) + all := r.All() + got := []string{all[0].Meta().ID, all[1].Meta().ID, all[2].Meta().ID} + want := []string{"a", "b", "c"} + for i := range got { + if got[i] != want[i] { + t.Fatalf("All() = %v, want %v", got, want) + } + } + }) +} +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/module/... +``` + +Expected: PASS (all four subtests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/module/ +git commit -m "feat(module): add four-phase Module interface and registry" +``` + +--- + +## Task 3: Reporter (event stream) + +**Goal:** Concrete `Reporter` implementations that modules use to stream progress. Two impls: `BufferedReporter` (for tests) and `PlainReporter` (writes to an `io.Writer`, used by `--no-tui`). + +**Files:** +- Create: `internal/engine/reporter.go` +- Test: `internal/engine/reporter_test.go` + +- [ ] **Step 1: Write `internal/engine/reporter.go`** + +```go +// Package engine runs modules through the Detect/Plan/Apply/Verify phases. +package engine + +import ( + "fmt" + "io" + "sync" + + "github.com/MDietermann/archduino/internal/module" +) + +// Event is a single item streamed from a module during Apply. +type Event struct { + ModuleID string + Kind EventKind + Current int + Total int + Message string +} + +type EventKind int + +const ( + EventProgress EventKind = iota + EventLog +) + +// BufferedReporter stores every event in memory. Used in tests. +type BufferedReporter struct { + mu sync.Mutex + events []Event +} + +func NewBufferedReporter() *BufferedReporter { return &BufferedReporter{} } + +func (b *BufferedReporter) Progress(id string, current, total int, msg string) { + b.mu.Lock() + defer b.mu.Unlock() + b.events = append(b.events, Event{ModuleID: id, Kind: EventProgress, Current: current, Total: total, Message: msg}) +} + +func (b *BufferedReporter) Log(id, line string) { + b.mu.Lock() + defer b.mu.Unlock() + b.events = append(b.events, Event{ModuleID: id, Kind: EventLog, Message: line}) +} + +func (b *BufferedReporter) Events() []Event { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]Event, len(b.events)) + copy(out, b.events) + return out +} + +// PlainReporter writes human-readable lines to w. Concurrency-safe. +type PlainReporter struct { + mu sync.Mutex + w io.Writer +} + +func NewPlainReporter(w io.Writer) *PlainReporter { return &PlainReporter{w: w} } + +func (p *PlainReporter) Progress(id string, current, total int, msg string) { + p.mu.Lock() + defer p.mu.Unlock() + fmt.Fprintf(p.w, "[%s] (%d/%d) %s\n", id, current, total, msg) +} + +func (p *PlainReporter) Log(id, line string) { + p.mu.Lock() + defer p.mu.Unlock() + fmt.Fprintf(p.w, "[%s] %s\n", id, line) +} + +// Compile-time guarantee both types satisfy module.Reporter. +var ( + _ module.Reporter = (*BufferedReporter)(nil) + _ module.Reporter = (*PlainReporter)(nil) +) +``` + +- [ ] **Step 2: Write `internal/engine/reporter_test.go`** + +```go +package engine + +import ( + "bytes" + "strings" + "sync" + "testing" +) + +func TestBufferedReporterRecordsEventsInOrder(t *testing.T) { + r := NewBufferedReporter() + r.Progress("m", 1, 3, "step 1") + r.Log("m", "hello") + r.Progress("m", 2, 3, "step 2") + + ev := r.Events() + if len(ev) != 3 { + t.Fatalf("want 3 events, got %d", len(ev)) + } + if ev[0].Kind != EventProgress || ev[0].Current != 1 { + t.Errorf("event[0] = %+v", ev[0]) + } + if ev[1].Kind != EventLog || ev[1].Message != "hello" { + t.Errorf("event[1] = %+v", ev[1]) + } +} + +func TestBufferedReporterIsConcurrencySafe(t *testing.T) { + r := NewBufferedReporter() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + r.Progress("m", i, 50, "x") + }(i) + } + wg.Wait() + if got := len(r.Events()); got != 50 { + t.Fatalf("want 50 events, got %d", got) + } +} + +func TestPlainReporterFormat(t *testing.T) { + var buf bytes.Buffer + r := NewPlainReporter(&buf) + r.Progress("packages", 2, 10, "installing tio") + r.Log("packages", "pacman: done") + + out := buf.String() + for _, want := range []string{"[packages] (2/10) installing tio", "[packages] pacman: done"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q; got:\n%s", want, out) + } + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/engine/... +``` + +Expected: PASS (3 tests). + +- [ ] **Step 4: Commit** + +```bash +git add internal/engine/reporter.go internal/engine/reporter_test.go +git commit -m "feat(engine): add Reporter with buffered and plain implementations" +``` + +--- + +## Task 4: DAG topological sort + +**Goal:** Pure-data algorithm that, given a slice of `module.Meta`, returns a valid execution order (stable) or an error if cycles/missing deps exist, plus a helper to compute the descendants of a failed node. + +**Files:** +- Create: `internal/engine/dag.go` +- Test: `internal/engine/dag_test.go` + +- [ ] **Step 1: Write `internal/engine/dag.go`** + +```go +package engine + +import ( + "fmt" + "sort" + + "github.com/MDietermann/archduino/internal/module" +) + +// TopoSort returns module IDs in a valid dependency order. +// Ties are broken alphabetically for deterministic output. +// Returns an error on unknown deps or cycles. +func TopoSort(metas []module.Meta) ([]string, error) { + known := make(map[string]bool, len(metas)) + for _, m := range metas { + known[m.ID] = true + } + for _, m := range metas { + for _, d := range m.Deps { + if !known[d] { + return nil, fmt.Errorf("module %q depends on unknown %q", m.ID, d) + } + } + } + + // Kahn's algorithm with alphabetical tie-breaking. + indeg := make(map[string]int, len(metas)) + children := make(map[string][]string, len(metas)) + for _, m := range metas { + if _, ok := indeg[m.ID]; !ok { + indeg[m.ID] = 0 + } + for _, d := range m.Deps { + indeg[m.ID]++ + children[d] = append(children[d], m.ID) + } + } + + var ready []string + for id, d := range indeg { + if d == 0 { + ready = append(ready, id) + } + } + sort.Strings(ready) + + var order []string + for len(ready) > 0 { + next := ready[0] + ready = ready[1:] + order = append(order, next) + kids := append([]string(nil), children[next]...) + sort.Strings(kids) + for _, c := range kids { + indeg[c]-- + if indeg[c] == 0 { + ready = append(ready, c) + sort.Strings(ready) + } + } + } + + if len(order) != len(metas) { + return nil, fmt.Errorf("dependency cycle detected among modules") + } + return order, nil +} + +// Descendants returns the set of module IDs transitively downstream of root. +// Root itself is NOT included. +func Descendants(metas []module.Meta, root string) map[string]bool { + children := make(map[string][]string) + for _, m := range metas { + for _, d := range m.Deps { + children[d] = append(children[d], m.ID) + } + } + out := make(map[string]bool) + var walk func(string) + walk = func(id string) { + for _, c := range children[id] { + if out[c] { + continue + } + out[c] = true + walk(c) + } + } + walk(root) + return out +} +``` + +- [ ] **Step 2: Write `internal/engine/dag_test.go`** + +```go +package engine + +import ( + "reflect" + "testing" + + "github.com/MDietermann/archduino/internal/module" +) + +func TestTopoSort(t *testing.T) { + cases := []struct { + name string + metas []module.Meta + want []string + err bool + }{ + { + name: "linear chain", + metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"b"}}}, + want: []string{"a", "b", "c"}, + }, + { + name: "independent modules sorted alphabetically", + metas: []module.Meta{{ID: "c"}, {ID: "a"}, {ID: "b"}}, + want: []string{"a", "b", "c"}, + }, + { + name: "diamond", + metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"a"}}, {ID: "d", Deps: []string{"b", "c"}}}, + want: []string{"a", "b", "c", "d"}, + }, + { + name: "unknown dep", + metas: []module.Meta{{ID: "a", Deps: []string{"missing"}}}, + err: true, + }, + { + name: "cycle", + metas: []module.Meta{{ID: "a", Deps: []string{"b"}}, {ID: "b", Deps: []string{"a"}}}, + err: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := TopoSort(tc.metas) + if tc.err { + if err == nil { + t.Fatalf("want error, got nil (order=%v)", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("order = %v, want %v", got, tc.want) + } + }) + } +} + +func TestDescendants(t *testing.T) { + metas := []module.Meta{ + {ID: "a"}, + {ID: "b", Deps: []string{"a"}}, + {ID: "c", Deps: []string{"a"}}, + {ID: "d", Deps: []string{"b"}}, + {ID: "e"}, // independent + } + got := Descendants(metas, "a") + want := map[string]bool{"b": true, "c": true, "d": true} + if !reflect.DeepEqual(got, want) { + t.Fatalf("descendants(a) = %v, want %v", got, want) + } + + if len(Descendants(metas, "e")) != 0 { + t.Fatalf("descendants(e) should be empty") + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/engine/... +``` + +Expected: PASS (previous 3 + 6 new subtests). + +- [ ] **Step 4: Commit** + +```bash +git add internal/engine/dag.go internal/engine/dag_test.go +git commit -m "feat(engine): add topological sort and descendants for module DAG" +``` + +--- + +## Task 5: Engine — run modules through the four phases + +**Goal:** The `Run` function that drives Detect → Plan → Apply → Verify for a set of modules in DAG order, with dry-run support, continue-on-failure (siblings proceed, descendants skipped), and an end-of-run `Summary`. + +**Files:** +- Create: `internal/engine/engine.go` +- Test: `internal/engine/engine_test.go` + +- [ ] **Step 1: Write `internal/engine/engine.go`** + +```go +package engine + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/MDietermann/archduino/internal/module" +) + +// RunOptions controls a single engine run. +type RunOptions struct { + Selected []string // module IDs to run; empty = all + DryRun bool // Detect + Plan only, no Apply/Verify + HaltOnError bool // stop at first failure instead of continuing siblings + Reporter module.Reporter +} + +// ModuleStatus is the terminal state of a module within a run. +type ModuleStatus string + +const ( + StatusSucceeded ModuleStatus = "succeeded" + StatusFailed ModuleStatus = "failed" + StatusSkipped ModuleStatus = "skipped" + StatusPlanned ModuleStatus = "planned" // dry-run only +) + +// ModuleResult captures what happened for one module. +type ModuleResult struct { + ID string + Status ModuleStatus + Plan module.Plan + Err error +} + +// Summary aggregates results. +type Summary struct { + Results []ModuleResult +} + +// Counts returns (succeeded, failed, skipped). +func (s Summary) Counts() (int, int, int) { + var ok, fail, skip int + for _, r := range s.Results { + switch r.Status { + case StatusSucceeded, StatusPlanned: + ok++ + case StatusFailed: + fail++ + case StatusSkipped: + skip++ + } + } + return ok, fail, skip +} + +// Run drives the selected modules through the four phases in DAG order. +// Parallelism is intentionally omitted in this foundation; a later plan +// will introduce it. Sequential execution is a correct baseline. +func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, error) { + mods, err := resolveSelection(reg, opts.Selected) + if err != nil { + return Summary{}, err + } + + metas := make([]module.Meta, 0, len(mods)) + for _, m := range mods { + metas = append(metas, m.Meta()) + } + order, err := TopoSort(metas) + if err != nil { + return Summary{}, fmt.Errorf("module graph: %w", err) + } + + byID := make(map[string]module.Module, len(mods)) + for _, m := range mods { + byID[m.Meta().ID] = m + } + + results := make(map[string]*ModuleResult, len(mods)) + failed := make(map[string]bool) + skipped := make(map[string]bool) + + for _, id := range order { + if skipped[id] { + results[id] = &ModuleResult{ID: id, Status: StatusSkipped, Err: errors.New("skipped: upstream failed")} + continue + } + m := byID[id] + res := &ModuleResult{ID: id} + results[id] = res + + state, err := m.Detect(ctx) + if err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("detect: %w", err) + markFailed(metas, id, failed, skipped) + if opts.HaltOnError { + break + } + continue + } + + plan, err := m.Plan(ctx, state) + if err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("plan: %w", err) + markFailed(metas, id, failed, skipped) + if opts.HaltOnError { + break + } + continue + } + res.Plan = plan + + if opts.DryRun { + res.Status = StatusPlanned + continue + } + + if err := m.Apply(ctx, plan, opts.Reporter); err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("apply: %w", err) + markFailed(metas, id, failed, skipped) + if opts.HaltOnError { + break + } + continue + } + if err := m.Verify(ctx); err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("verify: %w", err) + markFailed(metas, id, failed, skipped) + if opts.HaltOnError { + break + } + continue + } + res.Status = StatusSucceeded + } + + summary := Summary{} + // Emit results in topo order for stable output. + ids := make([]string, 0, len(results)) + for id := range results { + ids = append(ids, id) + } + sort.SliceStable(ids, func(i, j int) bool { + return indexOf(order, ids[i]) < indexOf(order, ids[j]) + }) + for _, id := range ids { + summary.Results = append(summary.Results, *results[id]) + } + return summary, nil +} + +func resolveSelection(reg *module.Registry, selected []string) ([]module.Module, error) { + all := reg.All() + if len(selected) == 0 { + return all, nil + } + out := make([]module.Module, 0, len(selected)) + for _, id := range selected { + m, ok := reg.Get(id) + if !ok { + return nil, fmt.Errorf("unknown module %q", id) + } + out = append(out, m) + } + return out, nil +} + +func markFailed(metas []module.Meta, id string, failed, skipped map[string]bool) { + failed[id] = true + for d := range Descendants(metas, id) { + skipped[d] = true + } +} + +func indexOf(s []string, v string) int { + for i, x := range s { + if x == v { + return i + } + } + return -1 +} +``` + +- [ ] **Step 2: Write `internal/engine/engine_test.go`** + +```go +package engine + +import ( + "context" + "errors" + "testing" + + "github.com/MDietermann/archduino/internal/module" +) + +// scriptedModule lets tests control what each phase returns. +type scriptedModule struct { + id string + deps []string + detect func() (module.State, error) + plan func() (module.Plan, error) + apply func() error + verify func() error +} + +func (s scriptedModule) Meta() module.Meta { return module.Meta{ID: s.id, Name: s.id, Deps: s.deps} } +func (s scriptedModule) Detect(ctx context.Context) (module.State, error) { + if s.detect != nil { + return s.detect() + } + return nil, nil +} +func (s scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) { + if s.plan != nil { + return s.plan() + } + return module.Plan{}, nil +} +func (s scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error { + if s.apply != nil { + return s.apply() + } + return nil +} +func (s scriptedModule) Verify(ctx context.Context) error { + if s.verify != nil { + return s.verify() + } + return nil +} + +func newReg(t *testing.T, mods ...module.Module) *module.Registry { + t.Helper() + r := module.NewRegistry() + for _, m := range mods { + if err := r.Register(m); err != nil { + t.Fatal(err) + } + } + return r +} + +func TestRunAllSucceed(t *testing.T) { + reg := newReg(t, + scriptedModule{id: "a"}, + scriptedModule{id: "b", deps: []string{"a"}}, + ) + sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) + if err != nil { + t.Fatal(err) + } + ok, fail, skip := sum.Counts() + if ok != 2 || fail != 0 || skip != 0 { + t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip) + } + if sum.Results[0].ID != "a" || sum.Results[1].ID != "b" { + t.Fatalf("order = %v", sum.Results) + } +} + +func TestRunDryRunDoesNotApply(t *testing.T) { + applied := false + reg := newReg(t, scriptedModule{ + id: "a", + apply: func() error { applied = true; return nil }, + }) + sum, err := Run(context.Background(), reg, RunOptions{DryRun: true, Reporter: NewBufferedReporter()}) + if err != nil { + t.Fatal(err) + } + if applied { + t.Fatal("Apply was called during dry-run") + } + if sum.Results[0].Status != StatusPlanned { + t.Fatalf("status = %v", sum.Results[0].Status) + } +} + +func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) { + reg := newReg(t, + scriptedModule{id: "root"}, + scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }}, + scriptedModule{id: "child", deps: []string{"fails"}}, + scriptedModule{id: "sibling", deps: []string{"root"}}, + ) + sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) + if err != nil { + t.Fatal(err) + } + status := map[string]ModuleStatus{} + for _, r := range sum.Results { + status[r.ID] = r.Status + } + if status["root"] != StatusSucceeded { + t.Errorf("root = %v", status["root"]) + } + if status["fails"] != StatusFailed { + t.Errorf("fails = %v", status["fails"]) + } + if status["child"] != StatusSkipped { + t.Errorf("child = %v", status["child"]) + } + if status["sibling"] != StatusSucceeded { + t.Errorf("sibling = %v (should have run)", status["sibling"]) + } +} + +func TestRunHaltOnError(t *testing.T) { + reg := newReg(t, + scriptedModule{id: "a", apply: func() error { return errors.New("nope") }}, + scriptedModule{id: "b"}, // independent + ) + sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()}) + if err != nil { + t.Fatal(err) + } + // "b" should not appear in results because we halted before reaching it. + for _, r := range sum.Results { + if r.ID == "b" { + t.Fatalf("b was run despite HaltOnError") + } + } +} + +func TestRunUnknownModule(t *testing.T) { + reg := module.NewRegistry() + _, err := Run(context.Background(), reg, RunOptions{Selected: []string{"ghost"}}) + if err == nil { + t.Fatal("expected error for unknown module") + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/engine/... +``` + +Expected: PASS (all reporter + DAG + engine tests). + +- [ ] **Step 4: Commit** + +```bash +git add internal/engine/engine.go internal/engine/engine_test.go +git commit -m "feat(engine): run modules through Detect/Plan/Apply/Verify with DAG + skip-on-failure" +``` + +--- + +## Task 6: Config overlay loader + +**Goal:** Load embedded defaults and merge an optional user overlay from `~/.config/archduino/overlay.yaml`. Schema is minimal on purpose; later plans will extend it. + +**Files:** +- Create: `internal/config/defaults.yaml` +- Create: `internal/config/config.go` +- Test: `internal/config/config_test.go` + +- [ ] **Step 1: Add YAML dependency** + +```bash +go get gopkg.in/yaml.v3 +``` + +- [ ] **Step 2: Write `internal/config/defaults.yaml`** + +```yaml +version: 1 +extra_packages: [] +disabled_modules: [] +theme_overrides: {} +``` + +- [ ] **Step 3: Write `internal/config/config.go`** + +```go +// Package config loads embedded defaults and optionally merges a user +// overlay from ~/.config/archduino/overlay.yaml. +package config + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +//go:embed defaults.yaml +var defaultsYAML []byte + +// Config is the fully-resolved runtime configuration. +type Config struct { + Version int `yaml:"version"` + ExtraPackages []string `yaml:"extra_packages"` + DisabledModules []string `yaml:"disabled_modules"` + ThemeOverrides map[string]string `yaml:"theme_overrides"` +} + +const currentVersion = 1 + +// Load returns defaults merged with the overlay at overlayPath (if present). +// If overlayPath is "", the default location (~/.config/archduino/overlay.yaml) +// is used. A missing overlay is not an error. +func Load(overlayPath string) (Config, error) { + var cfg Config + if err := yaml.Unmarshal(defaultsYAML, &cfg); err != nil { + return Config{}, fmt.Errorf("parse embedded defaults: %w", err) + } + + if overlayPath == "" { + home, err := os.UserHomeDir() + if err != nil { + return cfg, nil + } + overlayPath = filepath.Join(home, ".config", "archduino", "overlay.yaml") + } + + data, err := os.ReadFile(overlayPath) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return Config{}, fmt.Errorf("read overlay %s: %w", overlayPath, err) + } + + var overlay Config + if err := yaml.Unmarshal(data, &overlay); err != nil { + return Config{}, fmt.Errorf("parse overlay %s: %w", overlayPath, err) + } + if overlay.Version != 0 && overlay.Version != currentVersion { + return Config{}, fmt.Errorf("overlay %s: unsupported version %d (want %d)", overlayPath, overlay.Version, currentVersion) + } + + return merge(cfg, overlay), nil +} + +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 + } + return out +} +``` + +- [ ] **Step 4: Write `internal/config/config_test.go`** + +```go +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadDefaultsWhenNoOverlay(t *testing.T) { + dir := t.TempDir() + cfg, err := Load(filepath.Join(dir, "missing.yaml")) + if err != nil { + t.Fatal(err) + } + if cfg.Version != 1 { + t.Errorf("version = %d", cfg.Version) + } + if len(cfg.ExtraPackages) != 0 { + t.Errorf("extra_packages = %v", cfg.ExtraPackages) + } +} + +func TestLoadMergesOverlay(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overlay.yaml") + content := []byte(` +version: 1 +extra_packages: [htop, ncdu] +disabled_modules: [renode] +theme_overrides: + starship: /tmp/mine.toml +`) + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.ExtraPackages; len(got) != 2 || got[0] != "htop" || got[1] != "ncdu" { + t.Errorf("extra_packages = %v", got) + } + if len(cfg.DisabledModules) != 1 || cfg.DisabledModules[0] != "renode" { + t.Errorf("disabled_modules = %v", cfg.DisabledModules) + } + if cfg.ThemeOverrides["starship"] != "/tmp/mine.toml" { + t.Errorf("theme_overrides = %v", cfg.ThemeOverrides) + } +} + +func TestLoadRejectsUnknownVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overlay.yaml") + if err := os.WriteFile(path, []byte("version: 99\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("expected version error") + } +} +``` + +- [ ] **Step 5: Run tests** + +```bash +go test ./internal/config/... +``` + +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum internal/config/ +git commit -m "feat(config): load embedded defaults and merge optional overlay" +``` + +--- + +## Task 7: Platform detection + +**Goal:** A `Platform` struct identifying distro (Arch vs other), WSL presence, and CPU architecture, with a simple `Detect()` that reads `/etc/os-release` and `/proc/sys/kernel/osrelease`. + +**Files:** +- Create: `internal/platform/platform.go` +- Test: `internal/platform/platform_test.go` + +- [ ] **Step 1: Write `internal/platform/platform.go`** + +```go +// Package platform detects the host OS/distro/WSL status. +package platform + +import ( + "bufio" + "fmt" + "io" + "os" + "runtime" + "strings" +) + +// Distro is a narrow enum of distros we care about. +type Distro string + +const ( + DistroArch Distro = "arch" + DistroUnknown Distro = "unknown" +) + +// Platform holds detected host information. +type Platform struct { + Distro Distro + IsWSL bool + Arch string // e.g. "amd64", "arm64" +} + +// Detect reads the live system. Returns DistroUnknown rather than an error +// when /etc/os-release is missing or unrecognized; callers decide what to do. +func Detect() (Platform, error) { + f, err := os.Open("/etc/os-release") + distro := DistroUnknown + if err == nil { + distro = parseDistro(f) + _ = f.Close() + } else if !os.IsNotExist(err) { + return Platform{}, fmt.Errorf("open /etc/os-release: %w", err) + } + + wsl := false + if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil { + lower := strings.ToLower(string(data)) + wsl = strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl") + } + + return Platform{Distro: distro, IsWSL: wsl, Arch: runtime.GOARCH}, nil +} + +func parseDistro(r io.Reader) Distro { + sc := bufio.NewScanner(r) + for sc.Scan() { + line := sc.Text() + if !strings.HasPrefix(line, "ID=") { + continue + } + id := strings.Trim(strings.TrimPrefix(line, "ID="), `"`) + if id == "arch" { + return DistroArch + } + return DistroUnknown + } + return DistroUnknown +} +``` + +- [ ] **Step 2: Write `internal/platform/platform_test.go`** + +```go +package platform + +import ( + "strings" + "testing" +) + +func TestParseDistroArch(t *testing.T) { + osRelease := `NAME="Arch Linux" +ID=arch +ID_LIKE=archlinux +` + if got := parseDistro(strings.NewReader(osRelease)); got != DistroArch { + t.Fatalf("got %v", got) + } +} + +func TestParseDistroUbuntu(t *testing.T) { + osRelease := `NAME="Ubuntu" +ID=ubuntu +` + if got := parseDistro(strings.NewReader(osRelease)); got != DistroUnknown { + t.Fatalf("got %v", got) + } +} + +func TestParseDistroQuoted(t *testing.T) { + osRelease := `ID="arch" +` + if got := parseDistro(strings.NewReader(osRelease)); got != DistroArch { + t.Fatalf("got %v", got) + } +} + +func TestDetectReturnsArch(t *testing.T) { + p, err := Detect() + if err != nil { + t.Fatal(err) + } + if p.Arch == "" { + t.Fatal("arch is empty") + } + // We don't assert Distro here because the test host may not be Arch. + // The parse-level tests above cover that logic. +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/platform/... +``` + +Expected: PASS (4 tests). + +- [ ] **Step 4: Commit** + +```bash +git add internal/platform/platform.go internal/platform/platform_test.go +git commit -m "feat(platform): detect distro, WSL, and architecture" +``` + +--- + +## Task 8: PackageManager interface and Pacman implementation + +**Goal:** Abstract package management behind an interface; provide the Pacman impl that shells out via `sudo`. A command runner is injected so tests can verify calls without actually running pacman. + +**Files:** +- Create: `internal/platform/pkgmgr.go` +- Create: `internal/platform/pacman.go` +- Test: `internal/platform/pacman_test.go` + +- [ ] **Step 1: Write `internal/platform/pkgmgr.go`** + +```go +package platform + +import "context" + +// PackageManager abstracts OS-level package operations so later plans +// can add Debian/Fedora without touching modules. +type PackageManager interface { + Name() string + IsInstalled(ctx context.Context, pkg string) (bool, error) + Install(ctx context.Context, pkgs []string) error + Update(ctx context.Context) error +} + +// Runner executes a command with arguments. Injected into package managers +// so tests can stub shell-outs. +type Runner interface { + Run(ctx context.Context, name string, args ...string) error + Output(ctx context.Context, name string, args ...string) ([]byte, error) +} +``` + +- [ ] **Step 2: Write `internal/platform/pacman.go`** + +```go +package platform + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +// ExecRunner runs commands via os/exec. This is the production Runner. +type ExecRunner struct{} + +func (ExecRunner) Run(ctx context.Context, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() +} + +func (ExecRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, stderr.String()) + } + return out, nil +} + +// Pacman implements PackageManager via `pacman` on Arch. +type Pacman struct { + Runner Runner +} + +func NewPacman(r Runner) *Pacman { + if r == nil { + r = ExecRunner{} + } + return &Pacman{Runner: r} +} + +func (Pacman) Name() string { return "pacman" } + +// IsInstalled returns true when `pacman -Q ` succeeds. +func (p Pacman) IsInstalled(ctx context.Context, pkg string) (bool, error) { + if pkg == "" { + return false, errors.New("empty package name") + } + _, err := p.Runner.Output(ctx, "pacman", "-Q", pkg) + if err == nil { + return true, nil + } + // pacman -Q exits 1 when not installed; we treat any error as "not installed" + // because false-positives on transient errors here are harmless (Install + // is idempotent via --needed). + return false, nil +} + +// Install runs `sudo pacman -S --needed --noconfirm `. +func (p Pacman) Install(ctx context.Context, pkgs []string) error { + if len(pkgs) == 0 { + return nil + } + args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, pkgs...) + return p.Runner.Run(ctx, "sudo", args...) +} + +// Update runs `sudo pacman -Sy`. +func (p Pacman) Update(ctx context.Context) error { + return p.Runner.Run(ctx, "sudo", "pacman", "-Sy") +} +``` + +- [ ] **Step 3: Write `internal/platform/pacman_test.go`** + +```go +package platform + +import ( + "context" + "errors" + "reflect" + "testing" +) + +type fakeRunner struct { + runCalls [][]string + outputCalls [][]string + outputErr error + output []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.outputCalls = append(f.outputCalls, append([]string{name}, args...)) + return f.output, f.outputErr +} + +func TestPacmanInstallShellsOutCorrectly(t *testing.T) { + r := &fakeRunner{} + p := NewPacman(r) + if err := p.Install(context.Background(), []string{"tio", "git"}); err != nil { + t.Fatal(err) + } + if len(r.runCalls) != 1 { + t.Fatalf("run calls = %d", len(r.runCalls)) + } + want := []string{"sudo", "pacman", "-S", "--needed", "--noconfirm", "tio", "git"} + if !reflect.DeepEqual(r.runCalls[0], want) { + t.Fatalf("args = %v, want %v", r.runCalls[0], want) + } +} + +func TestPacmanInstallEmptyIsNoop(t *testing.T) { + r := &fakeRunner{} + p := NewPacman(r) + if err := p.Install(context.Background(), nil); err != nil { + t.Fatal(err) + } + if len(r.runCalls) != 0 { + t.Fatalf("expected no calls, got %v", r.runCalls) + } +} + +func TestPacmanIsInstalledTrue(t *testing.T) { + r := &fakeRunner{output: []byte("tio 3.2-1")} + p := NewPacman(r) + ok, err := p.IsInstalled(context.Background(), "tio") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("want installed=true") + } +} + +func TestPacmanIsInstalledFalseOnError(t *testing.T) { + r := &fakeRunner{outputErr: errors.New("not found")} + p := NewPacman(r) + ok, err := p.IsInstalled(context.Background(), "ghost") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("want installed=false") + } +} + +func TestPacmanUpdate(t *testing.T) { + r := &fakeRunner{} + p := NewPacman(r) + if err := p.Update(context.Background()); err != nil { + t.Fatal(err) + } + want := []string{"sudo", "pacman", "-Sy"} + if !reflect.DeepEqual(r.runCalls[0], want) { + t.Fatalf("args = %v, want %v", r.runCalls[0], want) + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/platform/... +``` + +Expected: PASS (platform + pacman tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/platform/pkgmgr.go internal/platform/pacman.go internal/platform/pacman_test.go +git commit -m "feat(platform): add PackageManager interface and Pacman implementation" +``` + +--- + +## Task 9: Privilege acquisition (`sudo -v` + keepalive) + +**Goal:** On `install` start, run `sudo -v` once; start a keepalive goroutine that runs `sudo -n -v` every 50 seconds until a `Stop()` is called. The upfront call must fail loudly if the user cancels / gets the password wrong. + +**Files:** +- Create: `internal/privilege/sudo.go` +- Test: `internal/privilege/sudo_test.go` + +- [ ] **Step 1: Write `internal/privilege/sudo.go`** + +```go +// Package privilege manages sudo timestamp acquisition and keepalive so +// a long-running installer only prompts once. +package privilege + +import ( + "context" + "fmt" + "os/exec" + "sync" + "time" +) + +// Keepalive holds the lifetime of a background sudo-refresh goroutine. +type Keepalive struct { + cancel context.CancelFunc + done chan struct{} +} + +// Stop terminates the keepalive goroutine. Safe to call multiple times. +func (k *Keepalive) Stop() { + if k == nil || k.cancel == nil { + return + } + k.cancel() + <-k.done + k.cancel = nil +} + +// Acquirer abstracts the `sudo` binary so tests can inject a fake. +type Acquirer interface { + Validate(ctx context.Context) error // sudo -v (may prompt) + ValidateNonInteractive(ctx context.Context) error // sudo -n -v +} + +// ExecSudo is the production Acquirer. +type ExecSudo struct{} + +func (ExecSudo) Validate(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "sudo", "-v") + cmd.Stdin = nil // inherit terminal stdin + return cmd.Run() +} + +func (ExecSudo) ValidateNonInteractive(ctx context.Context) error { + return exec.CommandContext(ctx, "sudo", "-n", "-v").Run() +} + +// Acquire runs `sudo -v` once (prompting if needed) and returns a Keepalive +// that refreshes the timestamp every `interval` using ValidateNonInteractive. +// If the initial Validate fails, no goroutine is started and the error is returned. +func Acquire(ctx context.Context, a Acquirer, interval time.Duration) (*Keepalive, error) { + if a == nil { + a = ExecSudo{} + } + if interval <= 0 { + interval = 50 * time.Second + } + if err := a.Validate(ctx); err != nil { + return nil, fmt.Errorf("sudo -v failed: %w", err) + } + kCtx, cancel := context.WithCancel(ctx) + k := &Keepalive{cancel: cancel, done: make(chan struct{})} + + go func() { + defer close(k.done) + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-kCtx.Done(): + return + case <-t.C: + _ = a.ValidateNonInteractive(kCtx) // best-effort + } + } + }() + return k, nil +} + +// unused silences a potential future-unused warning on sync imports. +var _ = sync.Mutex{} +``` + +- [ ] **Step 2: Write `internal/privilege/sudo_test.go`** + +```go +package privilege + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +type fakeAcquirer struct { + validateErr error + validates int32 + refreshes int32 +} + +func (f *fakeAcquirer) Validate(ctx context.Context) error { + atomic.AddInt32(&f.validates, 1) + return f.validateErr +} +func (f *fakeAcquirer) ValidateNonInteractive(ctx context.Context) error { + atomic.AddInt32(&f.refreshes, 1) + return nil +} + +func TestAcquireFailsIfValidateFails(t *testing.T) { + a := &fakeAcquirer{validateErr: errors.New("wrong password")} + _, err := Acquire(context.Background(), a, 10*time.Millisecond) + if err == nil { + t.Fatal("expected error") + } + if atomic.LoadInt32(&a.refreshes) != 0 { + t.Fatal("keepalive ran despite Validate failure") + } +} + +func TestAcquireStartsKeepaliveAndStops(t *testing.T) { + a := &fakeAcquirer{} + k, err := Acquire(context.Background(), a, 5*time.Millisecond) + if err != nil { + t.Fatal(err) + } + time.Sleep(25 * time.Millisecond) + k.Stop() + got := atomic.LoadInt32(&a.refreshes) + if got < 2 { + t.Fatalf("refreshes = %d, want >= 2", got) + } + + // After Stop, no additional refreshes should occur. + snap := atomic.LoadInt32(&a.refreshes) + time.Sleep(20 * time.Millisecond) + if after := atomic.LoadInt32(&a.refreshes); after != snap { + t.Fatalf("refreshes continued after Stop: %d → %d", snap, after) + } +} + +func TestStopIsIdempotent(t *testing.T) { + k, err := Acquire(context.Background(), &fakeAcquirer{}, 5*time.Millisecond) + if err != nil { + t.Fatal(err) + } + k.Stop() + k.Stop() // must not panic or hang +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/privilege/... +``` + +Expected: PASS (3 tests). May take ~50ms due to sleeps. + +- [ ] **Step 4: Commit** + +```bash +git add internal/privilege/ +git commit -m "feat(privilege): acquire sudo and keep timestamp fresh via goroutine" +``` + +--- + +## Task 10: Run-log state + +**Goal:** Persist a per-run JSON log to `~/.local/state/archduino/runs/.json` and update `last.json`. Advisory only — `Detect()` remains the source of truth. + +**Files:** +- Create: `internal/state/state.go` +- Test: `internal/state/state_test.go` + +- [ ] **Step 1: Write `internal/state/state.go`** + +```go +// Package state persists a per-run log used only for resume hints in the UI. +// Correctness always comes from live Detect() calls. +package state + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +// RunRecord is the JSON document written to runs/.json. +type RunRecord struct { + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + Modules []ModuleRecord `json:"modules"` +} + +// ModuleRecord captures how one module fared. +type ModuleRecord struct { + ID string `json:"id"` + Status string `json:"status"` // succeeded|failed|skipped|planned + Duration string `json:"duration"` + Error string `json:"error,omitempty"` + At time.Time `json:"at"` +} + +// Store is a file-backed run log. +type Store struct { + Root string // e.g. ~/.local/state/archduino +} + +// DefaultStore returns a Store rooted at $XDG_STATE_HOME/archduino or +// ~/.local/state/archduino. +func DefaultStore() (*Store, error) { + if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { + return &Store{Root: filepath.Join(xdg, "archduino")}, nil + } + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + return &Store{Root: filepath.Join(home, ".local", "state", "archduino")}, nil +} + +// Save writes record as runs/.json and updates last.json. Both paths are +// created if missing. Old runs beyond `keep` are deleted. +func (s *Store) Save(rec RunRecord, keep int) error { + if keep <= 0 { + keep = 10 + } + runsDir := filepath.Join(s.Root, "runs") + if err := os.MkdirAll(runsDir, 0o755); err != nil { + return err + } + name := rec.StartedAt.UTC().Format("20060102T150405Z") + ".json" + path := filepath.Join(runsDir, name) + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(s.Root, "last.json"), data, 0o644); err != nil { + return err + } + return rotate(runsDir, keep) +} + +// LastRun reads last.json if present. +func (s *Store) LastRun() (RunRecord, bool, error) { + data, err := os.ReadFile(filepath.Join(s.Root, "last.json")) + if err != nil { + if os.IsNotExist(err) { + return RunRecord{}, false, nil + } + return RunRecord{}, false, err + } + var rec RunRecord + if err := json.Unmarshal(data, &rec); err != nil { + return RunRecord{}, false, fmt.Errorf("parse last.json: %w", err) + } + return rec, true, nil +} + +func rotate(dir string, keep int) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + names = append(names, e.Name()) + } + } + if len(names) <= keep { + return nil + } + sort.Strings(names) // lexicographic == chronological (UTC stamp) + for _, n := range names[:len(names)-keep] { + _ = os.Remove(filepath.Join(dir, n)) + } + return nil +} +``` + +- [ ] **Step 2: Write `internal/state/state_test.go`** + +```go +package state + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func newStore(t *testing.T) *Store { + t.Helper() + return &Store{Root: t.TempDir()} +} + +func TestSaveAndLoadLast(t *testing.T) { + s := newStore(t) + rec := RunRecord{ + StartedAt: time.Date(2026, 4, 13, 10, 0, 0, 0, time.UTC), + EndedAt: time.Date(2026, 4, 13, 10, 1, 0, 0, time.UTC), + Modules: []ModuleRecord{{ID: "hello", Status: "succeeded", Duration: "1s"}}, + } + if err := s.Save(rec, 10); err != nil { + t.Fatal(err) + } + got, ok, err := s.LastRun() + if err != nil || !ok { + t.Fatalf("LastRun err=%v ok=%v", err, ok) + } + if len(got.Modules) != 1 || got.Modules[0].ID != "hello" { + t.Fatalf("modules = %+v", got.Modules) + } +} + +func TestLastRunMissingReturnsFalse(t *testing.T) { + s := newStore(t) + _, ok, err := s.LastRun() + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected ok=false") + } +} + +func TestRotateKeepsNewest(t *testing.T) { + s := newStore(t) + for i := 0; i < 12; i++ { + rec := RunRecord{ + StartedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC), + EndedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC), + } + if err := s.Save(rec, 5); err != nil { + t.Fatal(err) + } + } + entries, err := os.ReadDir(filepath.Join(s.Root, "runs")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 5 { + t.Fatalf("runs count = %d, want 5", len(entries)) + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/state/... +``` + +Expected: PASS (3 tests). + +- [ ] **Step 4: Commit** + +```bash +git add internal/state/ +git commit -m "feat(state): persist per-run logs with rotation and last-run pointer" +``` + +--- + +## Task 11: Wire `install` subcommand to the engine + +**Goal:** Replace the stub in `cmd/archduino/main.go` with real wiring: load config, acquire sudo (unless `--dry-run` or `--no-sudo`), run selected modules through the engine using `PlainReporter` when `--no-tui` is set, write a `state.RunRecord` on completion, exit non-zero on any module failure. + +**Files:** +- Modify: `cmd/archduino/main.go` + +- [ ] **Step 1: Rewrite `cmd/archduino/main.go`** + +```go +package main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/MDietermann/archduino/internal/config" + "github.com/MDietermann/archduino/internal/engine" + "github.com/MDietermann/archduino/internal/module" + "github.com/MDietermann/archduino/internal/privilege" + "github.com/MDietermann/archduino/internal/state" +) + +var version = "dev" + +// Registry is the global module registry. Tasks in Plan 3 will register +// real modules into it via init(). Task 12 registers the hello toy module. +var Registry = module.NewRegistry() + +func main() { + root := &cobra.Command{ + Use: "archduino", + Short: "Archduino installer and toolbox", + SilenceUsage: true, + SilenceErrors: true, + Version: version, + } + root.AddCommand(newInstallCmd()) + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func newInstallCmd() *cobra.Command { + var ( + noTUI bool + dryRun bool + halt bool + noSudo bool + only []string + skip []string + overlayPath string + ) + cmd := &cobra.Command{ + Use: "install", + Short: "Install or reconcile the Archduino toolset", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + cfg, err := config.Load(overlayPath) + if err != nil { + return err + } + + selected := resolveSelection(Registry, cfg, only, skip) + + // Acquire sudo unless dry-run or explicitly skipped. + if !dryRun && !noSudo { + k, err := privilege.Acquire(ctx, privilege.ExecSudo{}, 50*time.Second) + if err != nil { + return err + } + defer k.Stop() + } + + reporter := engine.NewPlainReporter(os.Stdout) + if !noTUI { + // Plan 2 replaces this with the Bubble Tea dashboard. For now + // the plain reporter is the only surface. + fmt.Fprintln(os.Stdout, "(TUI not yet implemented — using plain output)") + } + + started := time.Now().UTC() + sum, err := engine.Run(ctx, Registry, engine.RunOptions{ + Selected: selected, + DryRun: dryRun, + HaltOnError: halt, + Reporter: reporter, + }) + ended := time.Now().UTC() + if err != nil { + return err + } + + ok, fail, skipped := sum.Counts() + fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped) + for _, r := range sum.Results { + line := fmt.Sprintf(" %-16s %s", r.ID, r.Status) + if r.Err != nil { + line += " — " + r.Err.Error() + } + fmt.Fprintln(os.Stdout, line) + } + + if !dryRun { + if err := writeState(started, ended, sum); err != nil { + fmt.Fprintln(os.Stderr, "warning: state write failed:", err) + } + } + + if fail > 0 { + return fmt.Errorf("%d module(s) failed", fail) + } + return nil + }, + } + cmd.Flags().BoolVar(&noTUI, "no-tui", false, "plain-text output, no interactive UI") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned actions without applying") + cmd.Flags().BoolVar(&halt, "halt-on-error", false, "stop at first failure instead of continuing siblings") + cmd.Flags().BoolVar(&noSudo, "no-sudo", false, "skip upfront sudo -v (for tests / containers with passwordless sudo)") + cmd.Flags().StringSliceVar(&only, "only", nil, "comma-separated module IDs to run (exclusive with --skip)") + cmd.Flags().StringSliceVar(&skip, "skip", nil, "comma-separated module IDs to skip") + cmd.Flags().StringVar(&overlayPath, "config", "", "path to overlay.yaml (default: ~/.config/archduino/overlay.yaml)") + return cmd +} + +func resolveSelection(reg *module.Registry, cfg config.Config, only, skip []string) []string { + all := reg.All() + disabled := map[string]bool{} + for _, d := range cfg.DisabledModules { + disabled[d] = true + } + for _, d := range skip { + disabled[d] = true + } + onlySet := map[string]bool{} + for _, o := range only { + onlySet[o] = true + } + var out []string + for _, m := range all { + id := m.Meta().ID + if disabled[id] { + continue + } + if len(onlySet) > 0 && !onlySet[id] { + continue + } + out = append(out, id) + } + return out +} + +func writeState(started, ended time.Time, sum engine.Summary) error { + s, err := state.DefaultStore() + if err != nil { + return err + } + rec := state.RunRecord{StartedAt: started, EndedAt: ended} + for _, r := range sum.Results { + mr := state.ModuleRecord{ID: r.ID, Status: string(r.Status), At: ended} + if r.Err != nil { + mr.Error = r.Err.Error() + } + rec.Modules = append(rec.Modules, mr) + } + return s.Save(rec, 10) +} + +// avoid unused-import false positives on environments that lint strictly. +var _ = strings.TrimSpace +``` + +- [ ] **Step 2: Build and smoke-test (empty registry)** + +```bash +go build ./cmd/archduino +./archduino install --no-tui --no-sudo --dry-run +``` + +Expected output contains `summary: 0 succeeded, 0 failed, 0 skipped` and exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add cmd/archduino/main.go +git commit -m "feat(cmd): wire install subcommand to engine with plain reporter" +``` + +--- + +## Task 12: Hello toy module (end-to-end proof) + +**Goal:** A real `Module` implementation that writes `~/.cache/archduino/hello.ok` and reads it back in `Detect`/`Verify`. Registered in `main.go`. This validates the whole pipeline runs without TUI or distro dependencies. + +**Files:** +- Create: `internal/modules/hello/hello.go` +- Test: `internal/modules/hello/hello_test.go` +- Modify: `cmd/archduino/main.go` (add one import + one `Register` call) + +- [ ] **Step 1: Write `internal/modules/hello/hello.go`** + +```go +// Package hello is a toy module that writes a marker file. It exists to +// validate the engine end-to-end before real modules arrive in Plan 3. +package hello + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/MDietermann/archduino/internal/module" +) + +const defaultRelPath = ".cache/archduino/hello.ok" +const markerContent = "archduino-hello\n" + +// Module is the exported module value. Path is overridable for tests. +type Module struct { + Path string // absolute path; if empty, ~/.cache/archduino/hello.ok is used +} + +func (m Module) resolve() (string, error) { + if m.Path != "" { + return m.Path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, defaultRelPath), nil +} + +func (Module) Meta() module.Meta { + return module.Meta{ + ID: "hello", + Name: "Hello", + Description: "Toy module that writes a marker file (engine smoke test).", + } +} + +type state struct{ Present bool } + +func (m Module) Detect(ctx context.Context) (module.State, error) { + path, err := m.resolve() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return state{Present: false}, nil + } + return nil, err + } + return state{Present: string(data) == markerContent}, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + if s.Present { + return module.Plan{}, nil + } + path, err := m.resolve() + if err != nil { + return module.Plan{}, err + } + return module.Plan{Actions: []module.Action{{ + Description: "write marker " + path, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(markerContent), 0o644) + }, + }}}, 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 !st.(state).Present { + return fmt.Errorf("hello: marker not present after apply") + } + return nil +} +``` + +- [ ] **Step 2: Write `internal/modules/hello/hello_test.go`** + +```go +package hello + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/archduino/internal/engine" +) + +func TestHelloFullLifecycle(t *testing.T) { + path := filepath.Join(t.TempDir(), "hello.ok") + m := Module{Path: path} + ctx := context.Background() + + st, err := m.Detect(ctx) + if err != nil { + t.Fatal(err) + } + if st.(state).Present { + t.Fatal("marker should be absent on fresh tmpdir") + } + + plan, err := m.Plan(ctx, st) + if err != nil { + t.Fatal(err) + } + if plan.IsEmpty() { + t.Fatal("plan should have one action") + } + + if err := m.Apply(ctx, plan, engine.NewBufferedReporter()); err != nil { + t.Fatal(err) + } + if err := m.Verify(ctx); err != nil { + t.Fatal(err) + } + + // Second detect: now present. + st2, _ := m.Detect(ctx) + if !st2.(state).Present { + t.Fatal("marker should be present after apply") + } + + // Second plan: empty (idempotent). + plan2, _ := m.Plan(ctx, st2) + if !plan2.IsEmpty() { + t.Fatal("second plan should be empty (idempotent)") + } + + // Sanity: file contents. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != markerContent { + t.Fatalf("content = %q", data) + } +} +``` + +- [ ] **Step 3: Register the module in `cmd/archduino/main.go`** + +Add the import and an `init()` block. At the top of `cmd/archduino/main.go`, modify the import block to include: + +```go + "github.com/MDietermann/archduino/internal/modules/hello" +``` + +Then add this below the `Registry` declaration: + +```go +func init() { + if err := Registry.Register(hello.Module{}); err != nil { + panic(err) + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./... +``` + +Expected: PASS across every package. + +- [ ] **Step 5: End-to-end smoke test on host** + +```bash +rm -f ~/.cache/archduino/hello.ok +go build ./cmd/archduino +./archduino install --no-tui --no-sudo +test -f ~/.cache/archduino/hello.ok && echo OK +./archduino install --no-tui --no-sudo # second run: idempotent +``` + +Expected on second run: `hello` shows `succeeded` with no actions executed (because `Plan` returned empty and `Apply` iterates zero actions). + +- [ ] **Step 6: Commit** + +```bash +git add internal/modules/hello/ cmd/archduino/main.go +git commit -m "feat(modules): add hello toy module as engine smoke test" +``` + +--- + +## Task 13: Integration test on clean Arch container + +**Goal:** A Dockerfile + shell driver that builds `archduino`, runs `install --no-tui --no-sudo` inside a fresh `archlinux:base` container, and asserts the marker file exists. Proves the whole foundation works on a real Arch system. + +**Files:** +- Create: `test/integration/Dockerfile` +- Create: `test/integration/run.sh` + +- [ ] **Step 1: Write `test/integration/Dockerfile`** + +```dockerfile +# Minimal Arch image with Go toolchain for building and running archduino. +FROM archlinux:base + +RUN pacman -Sy --noconfirm --needed go git ca-certificates + +WORKDIR /src +COPY . /src + +RUN go build -o /usr/local/bin/archduino ./cmd/archduino + +# Run as non-root to exercise user-home paths. +RUN useradd -m tester +USER tester +WORKDIR /home/tester + +ENTRYPOINT ["archduino"] +``` + +- [ ] **Step 2: Write `test/integration/run.sh`** + +```bash +#!/usr/bin/env bash +# Build the image and run `archduino install --no-tui --no-sudo` end-to-end. +# Exits non-zero on any failure. Intended for local dev + Plan 4 CI. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +IMAGE=archduino-integration:latest + +echo "--- building image" +docker build -f "$SCRIPT_DIR/Dockerfile" -t "$IMAGE" "$REPO_ROOT" + +echo "--- run 1: fresh install" +docker run --rm "$IMAGE" install --no-tui --no-sudo + +echo "--- run 2: idempotent (marker already present)" +# Persist home across runs via a named volume. +VOL=archduino-integration-home +docker volume create "$VOL" >/dev/null +docker run --rm -v "$VOL":/home/tester "$IMAGE" install --no-tui --no-sudo +docker run --rm -v "$VOL":/home/tester "$IMAGE" install --no-tui --no-sudo + +echo "--- assert marker exists in volume" +docker run --rm -v "$VOL":/home/tester --entrypoint test "$IMAGE" -f /home/tester/.cache/archduino/hello.ok + +docker volume rm "$VOL" >/dev/null +echo "--- integration test PASSED" +``` + +- [ ] **Step 3: Make the script executable** + +```bash +chmod +x test/integration/run.sh +``` + +- [ ] **Step 4: Run the integration test** + +```bash +./test/integration/run.sh +``` + +Expected: final line `--- integration test PASSED`. Requires Docker locally. If Docker is unavailable in this environment, note it in the commit message and defer execution to CI (Plan 4). + +- [ ] **Step 5: Commit** + +```bash +git add test/integration/ +git commit -m "test(integration): run archduino install end-to-end on archlinux:base" +``` + +--- + +## Wrap-up + +At the end of Task 13 the repo has: + +- A runnable `archduino` binary with an `install` subcommand. +- A four-phase module contract, registry, DAG scheduler, reporter, config overlay, platform detection, package-manager interface (Pacman impl), sudo keepalive, and run-log state — all unit tested. +- A toy `hello` module that proves the engine runs end-to-end, idempotently, on a clean Arch container. + +What is explicitly *not* here yet (by design): +- The Bubble Tea / Huh TUI (Plan 2). +- The 13 real install modules (Plan 3). +- `theme`, `new`, `doctor` subcommands, the `bootstrap.sh` shim, goreleaser, GH Actions (Plan 4). + +## Self-review notes + +- Every task ends in green tests + a commit; none leave the tree broken. +- `Detect` is authoritative throughout; state is advisory (Task 10). +- Continue-on-failure is covered by a dedicated engine test (Task 5). +- Parallelism is intentionally deferred; the engine runs modules sequentially. This is a correct baseline and is called out in `engine.go`. A later plan can add a worker pool without changing the contract. +- The `--no-sudo` flag exists for tests/containers; real users will never pass it (privilege.Acquire runs by default). +- Module path `github.com/MDietermann/archduino` is an assumption; adjust in Task 1 if your owner/repo name differs.