Files
Archduino/docs/superpowers/plans/2026-04-13-installer-foundation.md
2026-04-13 18:09:31 +02:00

65 KiB

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

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

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

// 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
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
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
go test ./internal/module/...

Expected: PASS (all four subtests).

  • Step 5: Commit
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

// 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
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
go test ./internal/engine/...

Expected: PASS (3 tests).

  • Step 4: Commit
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

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
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
go test ./internal/engine/...

Expected: PASS (previous 3 + 6 new subtests).

  • Step 4: Commit
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

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
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
go test ./internal/engine/...

Expected: PASS (all reporter + DAG + engine tests).

  • Step 4: Commit
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

go get gopkg.in/yaml.v3
  • Step 2: Write internal/config/defaults.yaml
version: 1
extra_packages: []
disabled_modules: []
theme_overrides: {}
  • Step 3: Write internal/config/config.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
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
go test ./internal/config/...

Expected: PASS (3 tests).

  • Step 6: Commit
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

// 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
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
go test ./internal/platform/...

Expected: PASS (4 tests).

  • Step 4: Commit
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

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
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 <pkg>` 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 <pkgs...>`.
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
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
go test ./internal/platform/...

Expected: PASS (platform + pacman tests).

  • Step 5: Commit
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

// 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
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
go test ./internal/privilege/...

Expected: PASS (3 tests). May take ~50ms due to sleeps.

  • Step 4: Commit
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/<timestamp>.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

// 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/<ts>.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/<ts>.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
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
go test ./internal/state/...

Expected: PASS (3 tests).

  • Step 4: Commit
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

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

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

	"github.com/MDietermann/archduino/internal/modules/hello"

Then add this below the Registry declaration:

func init() {
	if err := Registry.Register(hello.Module{}); err != nil {
		panic(err)
	}
}
  • Step 4: Run tests
go test ./...

Expected: PASS across every package.

  • Step 5: End-to-end smoke test on host
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
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

# 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
#!/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
chmod +x test/integration/run.sh
  • Step 4: Run the integration test
./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
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.