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