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/ArduinoArchSetup(matches the existing GitHub repo name). Allimportstatements in subsequent tasks must use this prefix. If a code snippet later in this document showsgithub.com/MDietermann/archduino, treat it as an editing artifact and substitutegithub.com/MDietermann/ArduinoArchSetup. - Go version: 1.22 or newer. All code targets 1.22 features (e.g.
slices,rangeover 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 summaryortest(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 offfeat/renode-dapat 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— addarchduino(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 + oneRegistercall) -
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
archduinobinary with aninstallsubcommand. - 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
hellomodule 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,doctorsubcommands, thebootstrap.shshim, goreleaser, GH Actions (Plan 4).
Self-review notes
- Every task ends in green tests + a commit; none leave the tree broken.
Detectis 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-sudoflag exists for tests/containers; real users will never pass it (privilege.Acquire runs by default). - Module path
github.com/MDietermann/archduinois an assumption; adjust in Task 1 if your owner/repo name differs.