# 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/ArduinoArchSetup` (matches the existing GitHub repo name). All `import` statements in subsequent tasks must use this prefix. If a code snippet later in this document shows `github.com/MDietermann/archduino`, treat it as an editing artifact and substitute `github.com/MDietermann/ArduinoArchSetup`. - **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.