Compare commits
73 Commits
rewrite/UI
...
4fa580f226
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fa580f226 | ||
|
|
b422402ffc | ||
|
|
b628898c6d | ||
|
|
465d10a5ef | ||
|
|
e000e8d22c | ||
|
|
fcbd2201b3 | ||
|
|
f177d86255 | ||
|
|
bf2d8de82c | ||
|
|
66f02edfb2 | ||
|
|
daa20b1dca | ||
|
|
8677a1b950 | ||
|
|
e0c1fc3c5d | ||
|
|
e2637bc093 | ||
|
|
e830e4dfda | ||
|
|
adfc9232ac | ||
|
|
b9f9f0306d | ||
|
|
027b7a88bc | ||
|
|
7914b7ef51 | ||
|
|
5587d793df | ||
|
|
fa946e9403 | ||
|
|
7de9d6c72c | ||
|
|
99db34d70f | ||
|
|
9650cd0c82 | ||
|
|
78ad784c7e | ||
|
|
4b3ce22491 | ||
|
|
1735f66bf0 | ||
|
|
6dbfc41ce2 | ||
|
|
f5a45c4be6 | ||
|
|
2b30b371e4 | ||
|
|
cba883376a | ||
|
|
ffd18c5382 | ||
|
|
fcc24b99b2 | ||
|
|
1903217b5f | ||
|
|
3f4223879b | ||
|
|
2d73114b5f | ||
|
|
afed8ab600 | ||
|
|
83bb622232 | ||
|
|
05dc8aa7f5 | ||
|
|
42dabf85a5 | ||
|
|
6318362e99 | ||
|
|
2ebaa6b5c6 | ||
|
|
ea5667e040 | ||
|
|
4f3d736bd7 | ||
|
|
341250d8f4 | ||
|
|
7baf0fceaa | ||
|
|
5c5076f1f1 | ||
|
|
feb01242f2 | ||
|
|
e7aa75735d | ||
|
|
394fb33d0c | ||
|
|
d21ab3fd30 | ||
|
|
c0983284b3 | ||
|
|
258b53b6a0 | ||
|
|
777a3fe49b | ||
|
|
07c13551ce | ||
|
|
8b43a0f273 | ||
|
|
40ac730719 | ||
|
|
3a4855b92a | ||
|
|
7a3ebbb146 | ||
|
|
7a382c9bdd | ||
|
|
e4f945146b | ||
|
|
8f9e34174c | ||
|
|
83cf7f0e72 | ||
|
|
3b666615d7 | ||
|
|
ea0d793461 | ||
|
|
0f0ea7e925 | ||
|
|
94c7084ec3 | ||
|
|
37761e017e | ||
|
|
30643e385d | ||
|
|
eb54225ad3 | ||
|
|
5b5e677749 | ||
|
|
94dbea001c | ||
|
|
b952704be5 | ||
|
|
5b8de40b4c |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,2 +1,10 @@
|
|||||||
.idea
|
.idea
|
||||||
WokwiKey.txt
|
WokwiKey.txt
|
||||||
|
|
||||||
|
# Go
|
||||||
|
/archduino
|
||||||
|
/dist/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.*
|
||||||
|
.worktrees/
|
||||||
|
|||||||
298
cmd/archduino/main.go
Normal file
298
cmd/archduino/main.go
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/arduinocli"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/fonts"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/gittools"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/neovim"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/packages"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/renode"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/shellconfig"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/starship"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/themes"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/yazi"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/zellij"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/zsh"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/privilege"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/state"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui/wizard"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
// buildRegistry constructs a module registry populated with all Plan 3a
|
||||||
|
// modules, wired against the provided config and platform dependencies.
|
||||||
|
// It replaces the earlier init()-time hello-module registration now that
|
||||||
|
// the real modules need runtime config to be instantiated.
|
||||||
|
func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
runner := platform.ExecRunner{}
|
||||||
|
pm := platform.NewPacman(runner)
|
||||||
|
yay := platform.NewYay(runner)
|
||||||
|
git := platform.GitClient{Runner: runner}
|
||||||
|
|
||||||
|
mods := []module.Module{
|
||||||
|
packages.New(cfg.Packages.Base, pm),
|
||||||
|
gittools.New([]string{"lazygit"}, pm),
|
||||||
|
starship.New([]string{"starship"}, pm),
|
||||||
|
yazi.New([]string{"yazi"}, pm),
|
||||||
|
zsh.New(cfg.Zsh, pm, git),
|
||||||
|
fonts.New(cfg.Fonts, pm, yay),
|
||||||
|
zellij.New(cfg.Zellij, pm),
|
||||||
|
themes.New(cfg.Themes),
|
||||||
|
arduinocli.New(cfg.ArduinoCLI, runner),
|
||||||
|
shellconfig.New(cfg.ShellConfig),
|
||||||
|
neovim.New(cfg.Neovim, themeID, tui.BuiltinThemes, pm, git),
|
||||||
|
renode.New(cfg.Renode, pm, yay),
|
||||||
|
}
|
||||||
|
for _, m := range mods {
|
||||||
|
if err := reg.Register(m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "archduino",
|
||||||
|
Short: "Archduino installer and toolbox",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
Version: version,
|
||||||
|
}
|
||||||
|
root.AddCommand(newInstallCmd())
|
||||||
|
|
||||||
|
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases
|
||||||
|
// (engine.Run, sudo keepalive) observe the cancellation promptly.
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := root.ExecuteContext(ctx); 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate: v1 supports Arch only. Non-Arch hosts would silently run
|
||||||
|
// the hello module today but fail on Plan 3's pacman-dependent
|
||||||
|
// modules — fail fast with a clear message instead.
|
||||||
|
if !noSudo { // --no-sudo implies a test/container context; skip the gate
|
||||||
|
p, derr := platform.Detect()
|
||||||
|
if derr != nil {
|
||||||
|
return fmt.Errorf("platform detect: %w", derr)
|
||||||
|
}
|
||||||
|
if p.Distro != platform.DistroArch {
|
||||||
|
return fmt.Errorf("archduino requires Arch Linux (detected distro: %s)", p.Distro)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(overlayPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := buildRegistry(cfg, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, err := resolveSelection(reg, cfg, only, skip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now().UTC()
|
||||||
|
var (
|
||||||
|
sum engine.Summary
|
||||||
|
runErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
if noTUI || dryRun {
|
||||||
|
// Plain-output path: preserve the Plan 1 behaviour. Dry-run
|
||||||
|
// always uses plain output because the dashboard has nothing
|
||||||
|
// to animate when no Apply happens.
|
||||||
|
reporter := engine.NewPlainReporter(os.Stdout)
|
||||||
|
sum, runErr = engine.Run(ctx, reg, engine.RunOptions{
|
||||||
|
Selected: selected,
|
||||||
|
DryRun: dryRun,
|
||||||
|
HaltOnError: halt,
|
||||||
|
Reporter: reporter,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// TUI path: wizard → dashboard. If the user does not confirm,
|
||||||
|
// we exit cleanly with a zero-summary and no error.
|
||||||
|
initial := tui.NewWizardState(cfg, reg)
|
||||||
|
// Pre-filter the wizard's module list by --only/--skip so the
|
||||||
|
// user cannot accidentally widen selection in the TUI beyond
|
||||||
|
// what they asked for on the CLI.
|
||||||
|
if len(selected) < len(initial.Modules) {
|
||||||
|
wanted := map[string]bool{}
|
||||||
|
for _, id := range selected {
|
||||||
|
wanted[id] = true
|
||||||
|
}
|
||||||
|
for i := range initial.Modules {
|
||||||
|
initial.Modules[i].Selected = wanted[initial.Modules[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalState, confirmed, werr := wizard.Run(ctx, reg, initial)
|
||||||
|
if werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Fprintln(os.Stdout, "installation cancelled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Rebuild the registry with the theme the user chose in the
|
||||||
|
// wizard so theme-aware modules (e.g. neovim in Task 7) pick
|
||||||
|
// up the selection instead of the empty pre-wizard default.
|
||||||
|
reg, err = buildRegistry(cfg, finalState.ThemeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build registry: %w", err)
|
||||||
|
}
|
||||||
|
// Either the CLI flag or the wizard toggle can force halt-on-error.
|
||||||
|
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
|
||||||
|
}
|
||||||
|
|
||||||
|
ended := time.Now().UTC()
|
||||||
|
if runErr != nil {
|
||||||
|
return runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
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)")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("only", "skip")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSelection(reg *module.Registry, cfg config.Config, only, skip []string) ([]string, error) {
|
||||||
|
all := reg.All()
|
||||||
|
known := map[string]bool{}
|
||||||
|
for _, m := range all {
|
||||||
|
known[m.Meta().ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate every explicit ID the user passed so a typo in --only does not
|
||||||
|
// silently fall through to "run all modules".
|
||||||
|
for _, id := range only {
|
||||||
|
if !known[id] {
|
||||||
|
return nil, fmt.Errorf("unknown module in --only: %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range skip {
|
||||||
|
if !known[id] {
|
||||||
|
return nil, fmt.Errorf("unknown module in --skip: %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
2615
docs/superpowers/plans/2026-04-13-installer-foundation.md
Normal file
2615
docs/superpowers/plans/2026-04-13-installer-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
1489
docs/superpowers/plans/2026-04-13-installer-tui.md
Normal file
1489
docs/superpowers/plans/2026-04-13-installer-tui.md
Normal file
File diff suppressed because it is too large
Load Diff
2559
docs/superpowers/plans/2026-04-14-plan3a-install-modules.md
Normal file
2559
docs/superpowers/plans/2026-04-14-plan3a-install-modules.md
Normal file
File diff suppressed because it is too large
Load Diff
1370
docs/superpowers/plans/2026-04-14-plan3b-install-modules.md
Normal file
1370
docs/superpowers/plans/2026-04-14-plan3b-install-modules.md
Normal file
File diff suppressed because it is too large
Load Diff
1514
docs/superpowers/plans/2026-04-14-plan4a-subcommands.md
Normal file
1514
docs/superpowers/plans/2026-04-14-plan4a-subcommands.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
|||||||
|
# Installer Architecture Redesign
|
||||||
|
|
||||||
|
**Status:** Design approved, pending implementation plan
|
||||||
|
**Date:** 2026-04-13
|
||||||
|
**Scope:** Installer architecture only. Toolset overhaul, CLI browser, and TTY-font story are queued as follow-up specs.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current Archduino installer is a Bash entry point (`install.sh`) that sources 13 files under `lib/`. It has grown beyond what Bash handles comfortably:
|
||||||
|
|
||||||
|
- **Monolithic / ordering-fragile.** `install.sh` sources lib files in a fixed order at top level; hard to run a single step and hard to re-run safely.
|
||||||
|
- **Not idempotent / no resume.** Re-running either duplicates work or silently skips things.
|
||||||
|
- **Mixed concerns per file.** Detection, templating, package install, and theme application are intertwined in every lib.
|
||||||
|
- **Thin UX.** Only choices are theme + verbose; no dry-run, no module selection, no preview.
|
||||||
|
- **Platform coupling.** Arch vs Ubuntu-WSL branches are scattered throughout.
|
||||||
|
|
||||||
|
The goal is a principled rebuild across all five axes.
|
||||||
|
|
||||||
|
## Decision summary
|
||||||
|
|
||||||
|
| Area | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Language / runtime | Go, single static binary |
|
||||||
|
| UI | Bubble Tea + Huh (wizard for input, dashboard for execution) |
|
||||||
|
| Pre-install shim | Minimal Bash script: ensures `sudo`, `git`, `curl`, `ca-certificates`, fetches binary |
|
||||||
|
| Module contract | Four-phase: `Detect → Plan → Apply → Verify` |
|
||||||
|
| Configuration | Embedded defaults + optional `~/.config/archduino/overlay.yaml` |
|
||||||
|
| Privilege model | User-mode binary; `sudo` per privileged command; upfront `sudo -v` + keepalive goroutine |
|
||||||
|
| State / logging | `Detect()` is authoritative; run log is advisory UX only |
|
||||||
|
| Failure policy | DAG execution; failed node fails its descendants; siblings proceed; `--halt-on-error` flag available |
|
||||||
|
| Subcommands | One binary: `install`, `theme`, `new`, `doctor` |
|
||||||
|
| Platform scope (v1) | Arch-only native + WSL2; `PackageManager` interface ready for Debian/Fedora later |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Module contract
|
||||||
|
|
||||||
|
Every installable unit implements:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Module interface {
|
||||||
|
Meta() ModuleMeta // id, human name, description, deps []string
|
||||||
|
Detect(ctx) (State, error) // read current system
|
||||||
|
Plan(ctx, State) (Plan, error) // diff vs desired; []Action with descriptions
|
||||||
|
Apply(ctx, Plan, *Reporter) error // execute; stream progress to Reporter
|
||||||
|
Verify(ctx) error // confirm desired state reached
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `State` is module-defined.
|
||||||
|
- `Plan` is a flat list of `Action{Description, Kind, Exec}` so the engine can render plans without knowing modules.
|
||||||
|
- `Reporter` decouples modules from the TUI.
|
||||||
|
- **`Detect()` is authoritative.** No cached state overrides what the system reports.
|
||||||
|
|
||||||
|
Initial module set (1:1 with today's `lib/*.sh`):
|
||||||
|
`packages`, `fonts`, `zsh`, `starship`, `zellij`, `neovim`, `yazi`, `arduino-cli`, `renode`, `git` (lazygit), `scaffold`, `shell-config`, `themes`.
|
||||||
|
|
||||||
|
### Execution engine
|
||||||
|
|
||||||
|
- **DAG-scheduled.** Modules declare `deps []string`. Engine topologically sorts and runs independent branches concurrently, default parallelism 4.
|
||||||
|
- **Failure policy.** Failed node marks its descendants `skipped-due-to-dep`; siblings proceed. End-of-run summary: `X succeeded, Y failed, Z skipped`.
|
||||||
|
- **Dry-run** (`--dry-run`, or wizard "Review plan" step): runs Detect + Plan for every selected module, renders the combined plan, exits.
|
||||||
|
- **Halt mode** (`--halt-on-error`): opts into fail-fast for scripted use.
|
||||||
|
- **Resume hint.** If a prior run log exists, TUI offers "last run: `starship` failed — resume? / start over? / pick modules?" Hint only; Detect decides what actually runs.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- **Embedded defaults** via `//go:embed`: package lists, theme files, Zellij layouts, config templates.
|
||||||
|
- **Optional overlay** at `~/.config/archduino/overlay.yaml`:
|
||||||
|
```yaml
|
||||||
|
version: 1
|
||||||
|
extra_packages: [htop, ncdu]
|
||||||
|
disabled_modules: [renode]
|
||||||
|
theme_overrides:
|
||||||
|
starship: ~/my-starship.toml
|
||||||
|
```
|
||||||
|
- Overlay is merged at load time. Schema is versioned; a migration path exists when it grows.
|
||||||
|
|
||||||
|
### Privilege model
|
||||||
|
|
||||||
|
- Binary runs as the invoking user.
|
||||||
|
- On `install` start, one `sudo -v` call; a keepalive goroutine refreshes the timestamp every 50 seconds while the installer is live.
|
||||||
|
- Privileged operations shell out to `sudo pacman …`, `sudo usermod …`. If sudo needs to re-prompt (timestamp expired), the TUI is suspended via `tea.ExecProcess`.
|
||||||
|
- Config files, git clones, and anything under `$HOME` run as the user — no root-owned files in home.
|
||||||
|
|
||||||
|
### Platform abstraction
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PackageManager interface {
|
||||||
|
Install(pkgs []string) error
|
||||||
|
IsInstalled(pkg string) (bool, error)
|
||||||
|
Update() error
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- V1 ships `Pacman` only. Debian/Ubuntu/Fedora impls are stubbed; wiring them is a later spec.
|
||||||
|
- `Platform{Distro, IsWSL, Arch}` detection is centralized. WSL path sets `/etc/wsl.conf` hints and adds the user to `dialout`/`uucp`.
|
||||||
|
|
||||||
|
### State and logs
|
||||||
|
|
||||||
|
- `~/.local/state/archduino/runs/<timestamp>.json` — per-run structured log: `{module, phase, status, duration, error}`.
|
||||||
|
- `~/.local/state/archduino/last.json` — most recent run; read by TUI for the resume hint.
|
||||||
|
- Log rotation: keep the last 10 runs.
|
||||||
|
- Safe to delete at any time; never read for correctness decisions.
|
||||||
|
|
||||||
|
## User-facing surface
|
||||||
|
|
||||||
|
### Pre-install shim (`scripts/bootstrap.sh`)
|
||||||
|
|
||||||
|
~30 lines of Bash, served from a stable URL. Invocation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://archduino.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities in order:
|
||||||
|
|
||||||
|
1. Refuse to run as root.
|
||||||
|
2. Detect distro; abort with a clear message if not Arch (v1 scope).
|
||||||
|
3. Ensure `sudo`, `git`, `curl`, `ca-certificates` are installed (`pacman -S --needed`).
|
||||||
|
4. Detect architecture, fetch the matching release binary from GitHub Releases, verify its SHA256 against the published checksum file, install to `~/.local/bin/archduino`, `chmod +x`.
|
||||||
|
5. `exec archduino install`.
|
||||||
|
|
||||||
|
Everything further (base-devel, multilib, user groups, toolset) is handled by modules inside the binary.
|
||||||
|
|
||||||
|
### Subcommands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `archduino install` | Default; wizard + dashboard flow |
|
||||||
|
| `archduino theme [name]` | Switch colorscheme across all tools; no arg = TUI picker |
|
||||||
|
| `archduino new <name> <board>` | Scaffold a project (current `arduino-new` behavior) |
|
||||||
|
| `archduino doctor` | Run Detect across all modules; report drift in a read-only dashboard |
|
||||||
|
| `archduino --version` / `completion <shell>` | Standard |
|
||||||
|
|
||||||
|
Global flags: `--dry-run`, `--halt-on-error`, `--verbose`, `--no-tui`, `--only <mods>`, `--skip <mods>`.
|
||||||
|
|
||||||
|
Shell aliases `theme` and `arduino-new` are preserved via thin wrappers dropped in `~/.local/bin` by the `shell-config` module.
|
||||||
|
|
||||||
|
### TUI flow (`install`)
|
||||||
|
|
||||||
|
**Wizard phase (Huh forms):**
|
||||||
|
|
||||||
|
1. Welcome — detected platform, WSL status, binary version.
|
||||||
|
2. Theme picker — 5 options, palette preview.
|
||||||
|
3. Module selector — checkbox list; pre-ticked from overlay or "all" by default; disabled modules grayed with reason.
|
||||||
|
4. Advanced (collapsible) — parallelism, halt-on-error toggle, overlay path override.
|
||||||
|
5. Review plan — rendered `Plan()` grouped by module, expandable. Back-navigable.
|
||||||
|
6. Confirm — `[y/N]` gate into Apply.
|
||||||
|
|
||||||
|
**Apply phase (Bubble Tea dashboard):**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ archduino install ──────────────────────────────────┐
|
||||||
|
│ ✓ packages 12/12 2.4s │
|
||||||
|
│ ✓ fonts 1/1 0.8s │
|
||||||
|
│ ⠋ neovim 3/7 running: :MasonInstall… │
|
||||||
|
│ ⠋ arduino-cli 2/4 running: core install… │
|
||||||
|
│ · zellij pending (waits: packages) │
|
||||||
|
│ ✗ starship failed — config template parse │
|
||||||
|
│ ⊘ shell-config skipped (dep: starship) │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ [tab] focus module [enter] expand log [q] abort │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Concurrent modules visible live.
|
||||||
|
- `enter` on a row expands a scrollable log tail.
|
||||||
|
- On completion: summary view; footer offers `[r]etry failed / [v]iew full log / [q]uit`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
archduino/
|
||||||
|
├── cmd/archduino/ # main.go, cobra wiring
|
||||||
|
├── internal/
|
||||||
|
│ ├── engine/ # DAG scheduler, Reporter, dry-run, resume
|
||||||
|
│ ├── module/ # Module interface, Plan/Action types, registry
|
||||||
|
│ ├── modules/ # one package per module
|
||||||
|
│ │ ├── packages/
|
||||||
|
│ │ ├── zsh/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── platform/ # detection; PackageManager interface + pacman impl
|
||||||
|
│ ├── privilege/ # sudo -v + keepalive
|
||||||
|
│ ├── config/ # embedded defaults + overlay merge
|
||||||
|
│ ├── state/ # run log, last-run tracking
|
||||||
|
│ ├── theme/ # theme model + switcher
|
||||||
|
│ └── tui/
|
||||||
|
│ ├── wizard/ # Huh forms
|
||||||
|
│ └── dashboard/ # Bubble Tea Apply view
|
||||||
|
├── assets/ # embedded configs, themes, zellij layout, templates
|
||||||
|
│ ├── themes/
|
||||||
|
│ ├── templates/
|
||||||
|
│ └── layouts/
|
||||||
|
├── scripts/bootstrap.sh
|
||||||
|
├── .github/workflows/
|
||||||
|
│ ├── ci.yml
|
||||||
|
│ └── release.yml # goreleaser
|
||||||
|
└── docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit** — every module's `Detect` and `Plan` against an injected `System` fake; table-driven cases for clean / already-installed / partial / drifted.
|
||||||
|
- **Engine** — DAG scheduling, failure propagation, dry-run, resume; pure Go, fully covered.
|
||||||
|
- **Integration** — Dockerfile on `archlinux:base` runs `archduino install --no-tui --yes` end-to-end in CI; asserts key files/packages/services. One matrix entry for native Arch. WSL smoke test remains manual for v1.
|
||||||
|
- **TUI** — `teatest` for wizard navigation and dashboard state transitions. No screenshot tests.
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
- **GitHub Releases** via `goreleaser`: `linux/amd64`, `linux/arm64` static binaries; `SHA256SUMS` file.
|
||||||
|
- No AUR package for v1; the shim is the distribution channel. AUR can follow later.
|
||||||
|
- Semver; breaking changes bump major.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
- Existing `install.sh` + `lib/*.sh` stay on `master` at a tagged `v0-bash-final` so users can still run the old installer if they want.
|
||||||
|
- `feat/renode-dap` merges first; a new `feat/go-rewrite` branch hosts the rewrite.
|
||||||
|
- First Go release (`v1.0.0`) lands when feature parity with the current Bash installer is verified on a clean Arch VM.
|
||||||
|
|
||||||
|
## Out of scope (queued for follow-up specs)
|
||||||
|
|
||||||
|
- Toolset redesign (re-evaluating which CLI tools are curated).
|
||||||
|
- CLI browser integration.
|
||||||
|
- Nerd Font rendering on bare TTY (constrained by the Linux console's 512-glyph PSF limit; needs a separate design covering `fbterm`/`kmscon` or the "SSH-in-from-a-real-terminal" fallback).
|
||||||
|
- Non-Arch platform support (Debian/Ubuntu/Fedora).
|
||||||
|
- AUR packaging.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Plan 3a — Install Modules (Simple/Medium Tier) Design
|
||||||
|
|
||||||
|
**Status:** Approved 2026-04-14
|
||||||
|
**Scope:** Port 10 of the 13 Bash install modules to Go. Plan 3b (neovim, renode, scaffold) is a separate brainstorm.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Port the 10 simple/medium `lib/*.sh` installer modules into Go packages under `internal/modules/`, each implementing the `module.Module` contract (Detect / Plan / Apply / Verify). Every module is unit-tested against an injected fake `platform.Runner`. Along the way, add the platform helpers these modules need (AUR, download, extract, git-clone, text-block writer) and fix the wizard `renderPlan` UI-blocking watchpoint flagged at the end of Plan 2.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Foundation tasks land first, then modules in simplest-first order:
|
||||||
|
|
||||||
|
1. **Wizard cache fix** — cache Detect+Plan results per module; invalidate on selection toggle. One small change, but it unblocks every downstream module from stalling the UI.
|
||||||
|
2. **Platform helpers** — `AURHelper` (yay), `Download`, `Extract`, `gitclient`, `textblock`. Each gets its own file, each gets TDD against a fake Runner (except `extract` which uses real tempdirs with fixture archives).
|
||||||
|
3. **Ten modules** in this order: packages → gittools(lazygit) → starship → yazi → zsh → fonts → zellij → themes → arduinocli → shellconfig.
|
||||||
|
|
||||||
|
All module inputs (package lists, versions, URLs, plugin repos, shell-config blocks) are typed fields on `config.Config` populated from the embedded `defaults.yaml`. Overlay override plumbing already exists from Plan 1. Modules take their inputs as struct fields at construction time — no globals.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
Go, existing `internal/platform` + `internal/module` + `internal/engine` + `internal/tui` packages. No new external dependencies expected beyond what Plan 1/2 pulled in.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### New — platform helpers
|
||||||
|
|
||||||
|
- `internal/platform/aur.go` + `aur_test.go`
|
||||||
|
- `AURHelper` interface, `Yay` implementation.
|
||||||
|
- `EnsureInstalled(ctx) error` — bootstraps yay from AUR via `git clone https://aur.archlinux.org/yay.git /tmp/... && makepkg -si --noconfirm` when missing.
|
||||||
|
- `Install(ctx, pkgs []string) error` — `yay -S --needed --noconfirm <pkgs...>`.
|
||||||
|
- Takes a `Runner` and a `PackageManager` (to check if `yay` itself is installed).
|
||||||
|
|
||||||
|
- `internal/platform/download.go` + `download_test.go`
|
||||||
|
- `Download(ctx, url, destPath string, mode os.FileMode) error`.
|
||||||
|
- Atomic-ish: write to `destPath+".tmp"` then rename. Clean up on error.
|
||||||
|
- Tested with `httptest.Server`.
|
||||||
|
|
||||||
|
- `internal/platform/extract.go` + `extract_test.go`
|
||||||
|
- `ExtractTarGz(ctx, archivePath, destDir string) error`.
|
||||||
|
- `ExtractZip(ctx, archivePath, destDir string) error`.
|
||||||
|
- Tested against real fixture archives in `testdata/`.
|
||||||
|
|
||||||
|
- `internal/platform/git.go` + `git_test.go`
|
||||||
|
- `GitClient` struct taking a `Runner`.
|
||||||
|
- `Clone(ctx, url, dest string) error`.
|
||||||
|
- `EnsureRepo(ctx, url, dest string) error` — clone if `dest/.git` missing, otherwise noop.
|
||||||
|
|
||||||
|
- `internal/platform/textblock.go` + `textblock_test.go`
|
||||||
|
- `EnsureBlock(path, marker, content string) error` — writes or replaces a block delimited by `# >>> marker >>>` / `# <<< marker <<<` markers. Creates the file if missing.
|
||||||
|
- `RemoveBlock(path, marker string) error`.
|
||||||
|
- `HasBlock(path, marker string) (bool, error)`.
|
||||||
|
- Table-driven tests: insert, replace, remove, preserve surrounding content, missing-file, empty-file.
|
||||||
|
|
||||||
|
### New — modules
|
||||||
|
|
||||||
|
Each lives in its own package under `internal/modules/<name>/` with `<name>.go` + `<name>_test.go`.
|
||||||
|
|
||||||
|
- `packages` — installs base pacman package list from `cfg.Packages.Base`.
|
||||||
|
- `gittools` — installs lazygit (pacman). Named `gittools` to avoid collision with Go stdlib / pacman `git`.
|
||||||
|
- `starship` — installs starship (pacman).
|
||||||
|
- `yazi` — installs yazi (pacman).
|
||||||
|
- `zsh` — installs zsh (pacman) + clones oh-my-zsh + configured plugins via `gitclient`.
|
||||||
|
- `fonts` — installs AUR fonts (e.g. `ttf-jetbrains-mono-nerd`) via `AURHelper`. First action is `EnsureYayInstalled` if needed.
|
||||||
|
- `zellij` — installs zellij (pacman) + downloads config.
|
||||||
|
- `themes` — copies theme files from source directory (bundled under `lib/themes/` or overridable via `cfg.Themes.Source`).
|
||||||
|
- `arduinocli` — downloads arduino-cli tarball, extracts to `~/.local/bin`, runs `arduino-cli core install <cores...>` from `cfg.ArduinoCLI.Cores`.
|
||||||
|
- `shellconfig` — writes marker-delimited blocks into `~/.zshrc`, `~/.bashrc`, etc., using `textblock.EnsureBlock`. Blocks defined in `cfg.ShellConfig.Blocks`.
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
|
||||||
|
- `internal/config/config.go` + `internal/config/defaults.yaml` — add typed fields:
|
||||||
|
- `Packages.Base []string`
|
||||||
|
- `Fonts []string`
|
||||||
|
- `ArduinoCLI{Version string, URL string, Cores []string}`
|
||||||
|
- `Starship{Version string}` (reserved; pacman install doesn't need it but overlay may)
|
||||||
|
- `Zsh{OhMyZshRepo string, Plugins []struct{Name, Repo string}}`
|
||||||
|
- `Zellij{ConfigURL string}`
|
||||||
|
- `Themes{Source string}` (defaults to embedded `lib/themes/`)
|
||||||
|
- `ShellConfig{Blocks []struct{Name, File, Content string}}`
|
||||||
|
- `internal/tui/wizard/wizard.go` — cache Detect+Plan per module; invalidate on selection toggle.
|
||||||
|
- Module registry (wherever `cmd/archduino/install.go` currently registers `hello`) — register the 10 new modules instead of / alongside `hello`.
|
||||||
|
|
||||||
|
## Module Contract Details
|
||||||
|
|
||||||
|
All modules follow the same shape:
|
||||||
|
|
||||||
|
- **Detect** — pure read. `PackageManager.IsInstalled` per pkg, `os.Stat` per file, `textblock.HasBlock` per marker. Returns a module-private `state` struct. Must be fast (wizard caches results, but Detect still shouldn't shell out more than necessary).
|
||||||
|
- **Plan** — given current state, returns `Plan{Actions: []Action}`. Empty actions = already converged (idempotent no-op). No side effects.
|
||||||
|
- **Apply** — executes each action sequentially, reporting progress via `Reporter.Progress(id, i, total, description)`.
|
||||||
|
- **Verify** — re-runs Detect and asserts convergence. Returns a descriptive error on mismatch.
|
||||||
|
|
||||||
|
Error semantics from the engine (`--halt-on-error` flag, sibling-on-failure continuation) apply unchanged.
|
||||||
|
|
||||||
|
### Module archetypes
|
||||||
|
|
||||||
|
1. **Pacman-only** (packages, gittools, starship, yazi, zellij base, themes deps if any): Detect = `IsInstalled` per pkg; Plan = install missing; Apply = `Pacman.Install`; Verify = all installed.
|
||||||
|
2. **AUR** (fonts): same as pacman-only but via `AURHelper`. Plan prepends an `EnsureYay` action if yay is not installed.
|
||||||
|
3. **Download + extract** (arduinocli): Detect = probe binary + `--version`; Plan = download + extract + symlink + `arduino-cli core install`; Verify = version string matches.
|
||||||
|
4. **Git-clone configs** (zsh oh-my-zsh + plugins, zellij config fetch if URL-based, themes if sourced from repo): Detect = check `.git` or file presence; Plan = `gitclient.EnsureRepo` or `Download`; Verify = path exists.
|
||||||
|
5. **Text-block writers** (shellconfig): Detect = `textblock.HasBlock` per block; Plan = `textblock.EnsureBlock`; Verify = re-scan.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
**Per-module tests (all unit, all fake-Runner-based):**
|
||||||
|
|
||||||
|
1. Detect with nothing installed → absent state.
|
||||||
|
2. Detect with everything installed → present state.
|
||||||
|
3. Plan(absent) → non-empty actions matching config inputs.
|
||||||
|
4. Plan(present) → empty actions (idempotent no-op).
|
||||||
|
5. Apply → records correct shell commands against fake Runner.
|
||||||
|
6. Verify after Apply → passes when fake reports success.
|
||||||
|
|
||||||
|
**Platform helper tests:**
|
||||||
|
|
||||||
|
- `aur_test.go` — yay bootstrap path (fake Runner sees `git clone` + `makepkg -si`), install path, already-installed short-circuit.
|
||||||
|
- `download_test.go` — `httptest.Server` for happy path + 404 + mid-download error cleanup.
|
||||||
|
- `extract_test.go` — real tar/zip fixtures in `testdata/` extracted into `t.TempDir()`.
|
||||||
|
- `git_test.go` — fake Runner for clone-if-missing, skip-if-present.
|
||||||
|
- `textblock_test.go` — table-driven: insert new, replace existing, remove, preserve surrounding content, missing-file, empty-file.
|
||||||
|
|
||||||
|
**Wizard caching test:** assert `Detect` is called once per module regardless of redraw count.
|
||||||
|
|
||||||
|
**No integration tests** that touch real pacman, real AUR, or the real network. That's Plan 4 (CI with Arch container). Manual smoke after plan completes.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Plan 3b modules (neovim, renode, scaffold) — separate brainstorm.
|
||||||
|
- Subcommands beyond `install` (theme/new/doctor) — Plan 4.
|
||||||
|
- `bootstrap.sh`, goreleaser, CI — Plan 4.
|
||||||
|
- Debian/Fedora PackageManager implementations — Plan 4+.
|
||||||
|
- Integration tests against real package managers — Plan 4.
|
||||||
|
|
||||||
|
## Open Risks / Watchpoints
|
||||||
|
|
||||||
|
- **AUR bootstrap fragility** — if `makepkg -si --noconfirm` prompts despite `--noconfirm` (sudo password mid-build), the fake-Runner tests won't catch it. Manual smoke on fonts module is essential.
|
||||||
|
- **arduino-cli URL drift** — hardcoded version in `defaults.yaml` will go stale. Acceptable for v1; doctor subcommand in Plan 4 can warn.
|
||||||
|
- **Theme file source** — if themes ship bundled via Go `embed`, we need to decide that at module time. Default assumption: copy from `lib/themes/` on disk (same behavior as bash); switch to `embed` only if it's a clear win.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Plan 3b — Install Modules (Heavy Tier) Design
|
||||||
|
|
||||||
|
**Status:** Approved 2026-04-14
|
||||||
|
**Scope:** Port 2 of the 3 remaining Bash install modules (`neovim`, `renode`). The third (`scaffold`) is deferred to Plan 4, where its functionality is absorbed natively into the `archduino new` and `archduino theme` subcommands rather than shipped as bash helper scripts.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Port the `neovim` and `renode` Bash modules to Go, each implementing the `module.Module` four-phase contract, unit-tested against a fake `platform.Runner`. Add one small platform helper (`WriteUserFile`), extend `config.Config` with the new modules' inputs, extend `tui.Theme` so the wizard's theme selection flows into NvChad's `chadrc.lua`, and register the two modules in the install command.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **neovim**: pacman-install neovim (Arch ships 0.11.x, no upstream-tarball fallback); clone the NvChad starter to `~/.config/nvim` if no existing config; if a non-NvChad config is detected, back it up to `~/.config/nvim.bak.<RFC3339>` and install fresh (no LazyVim-specific salvage — deliberate scope cut); strip the starter's `nvim-lspconfig` block (we use native `vim.lsp`); render `chadrc.lua` from an embedded template with the selected theme's `NvChadTheme`; write five static Lua asset files (`arduino.lua`, `clangd.lua`, `lspconfig.lua`, `dap.lua`, `mappings.lua`); clear NvChad's base46 theme cache.
|
||||||
|
- **renode**: AUR-install `renode-bin` (via `AURHelper`); pacman-install `arm-none-eabi-gdb`; render and write `~/.local/bin/arduino-sim` from an embedded bash template driven by a platform map in config; download `wokwi-cli` from a version-pinned URL into `~/.local/bin/wokwi-cli` (mode 0755).
|
||||||
|
- Both modules' artifacts (Lua files, bash helper) live as real files under `internal/modules/<name>/assets/` and are bundled via `go:embed`. No sudo required at runtime; everything lands in user-owned paths.
|
||||||
|
- One new platform helper, `platform.WriteUserFile(path, data, mode)`, consolidates the repeated `os.MkdirAll + os.WriteFile` pattern and does an atomic tmp+rename write. Plan 3a modules are not retrofitted — they stay as-is.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
Go stdlib only for the new helper and modules (`embed`, `text/template`, `os`, `path/filepath`). Relies on all Plan 3a platform helpers (pacman, AUR, download, extract, git) and on `internal/module` / `internal/engine` / `internal/tui` from Plans 1-2.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### New — platform helper
|
||||||
|
|
||||||
|
- `internal/platform/writefile.go` + `writefile_test.go`
|
||||||
|
- `WriteUserFile(path string, data []byte, mode os.FileMode) error`
|
||||||
|
- Creates parent directory (0755) if missing, writes to `path+".tmp"`, renames to `path`.
|
||||||
|
- Tests: happy path (content + mode), missing parent dir (creates it), overwrite preserves atomicity (crash-proof against partial writes is out of scope; atomic rename is sufficient).
|
||||||
|
|
||||||
|
### New — modules
|
||||||
|
|
||||||
|
- `internal/modules/neovim/neovim.go` + `neovim_test.go` + `assets/`
|
||||||
|
- `assets/chadrc.lua.tmpl` — `text/template` with `{{.NvChadTheme}}`.
|
||||||
|
- `assets/arduino.lua`, `assets/clangd.lua`, `assets/lspconfig.lua`, `assets/dap.lua`, `assets/mappings.lua` — static, copied verbatim from the bash heredocs in `lib/neovim.sh`.
|
||||||
|
- Constructor: `New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module`.
|
||||||
|
- State: `NvimInstalled, StarterCloned, ChadrcPresent, BackupNeeded bool`, `MissingAssets []string`, `ThemeName string` (resolved at Detect time).
|
||||||
|
|
||||||
|
- `internal/modules/renode/renode.go` + `renode_test.go` + `assets/`
|
||||||
|
- `assets/arduino-sim.tmpl` — `text/template` with `{{range .Platforms}}...{{end}}` rendering the case-statement platform map from config.
|
||||||
|
- Constructor: `New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module`.
|
||||||
|
- State: `RenodeInstalled, GdbInstalled, SimScriptCurrent, WokwiInstalled bool`.
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
|
||||||
|
- `internal/config/config.go` + `internal/config/defaults.yaml`
|
||||||
|
- `type NeovimConfig struct { NvChadStarterRepo string }`
|
||||||
|
- `type RenodePlatform struct { Name, ReplPath, Machine string }`
|
||||||
|
- `type RenodeConfig struct { WokwiVersion, WokwiURL string; Platforms []RenodePlatform }`
|
||||||
|
- `Config` gains `Neovim NeovimConfig` and `Renode RenodeConfig`.
|
||||||
|
- `merge` gains overlay-replace clauses for both (matching Plan 3a's pattern).
|
||||||
|
- `defaults.yaml` populated with NvChad starter URL, pinned wokwi version/URL, and the three platform entries from `lib/renode.sh`.
|
||||||
|
|
||||||
|
- `internal/tui` (wherever `BuiltinThemes` is declared — likely `internal/tui/state.go` or a themes file)
|
||||||
|
- `Theme` gains `NvChadTheme string`. Populate for each builtin entry (one-line edits per theme).
|
||||||
|
- Helper `Theme(themes []Theme, id string) (Theme, bool)` for the neovim module to resolve its themeID. (If an equivalent helper already exists, reuse it.)
|
||||||
|
|
||||||
|
- `cmd/archduino/main.go` — `buildRegistry` gains a `selectedThemeID string` parameter; registers neovim and renode. TUI path: call `buildRegistry(cfg, tui.DefaultThemeID())` before the wizard, then after the wizard returns with a theme selection, call it again with the selected ID before invoking the dashboard. Plain-output path: pass `cfg`'s default theme directly.
|
||||||
|
|
||||||
|
## Module Contract Details
|
||||||
|
|
||||||
|
Both modules use the Plan 3a archetype pattern:
|
||||||
|
|
||||||
|
- **Detect** — pure reads: `PackageManager.IsInstalled`, `os.Stat`, byte-compare of rendered/static asset files against their destination paths. For neovim, `~/.config/nvim/lua/chadrc.lua` acts as the NvChad-marker; any `lua/` dir without chadrc triggers `BackupNeeded`.
|
||||||
|
- **Plan** — emit only missing actions; empty plan = already converged (idempotent).
|
||||||
|
- **Apply** — sequential actions with `Reporter.Progress`; no action is retried.
|
||||||
|
- **Verify** — re-run Detect; every boolean must be true and `MissingAssets`/related slices must be empty.
|
||||||
|
|
||||||
|
Error semantics unchanged from Plan 3a — engine's `--halt-on-error` and sibling-continuation rules apply.
|
||||||
|
|
||||||
|
## Theme Propagation
|
||||||
|
|
||||||
|
The wizard's `ThemeID` is captured into `tui.WizardState.ThemeID` as before. `buildRegistry` takes the themeID as a second parameter; for the TUI install path, the registry is rebuilt after the wizard confirms selection and before the dashboard runs. For the plain-output path (`--no-tui` / `--dry-run`), the default theme ID is used. The `neovim` module resolves the ID to a `tui.Theme` at construction and stores the `NvChadTheme` for Detect/Plan. If an unknown ID is passed, neovim falls back to the first builtin theme's NvChad name (no hard error — wizards shouldn't crash the installer on a config bug).
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### neovim (all unit, tempdir-based)
|
||||||
|
|
||||||
|
- `TestDetectFreshSystem` — no nvim, no config → plans pacman install + clone starter + strip lspconfig + write assets.
|
||||||
|
- `TestDetectExistingNvChad` — chadrc.lua present → no backup planned; missing-assets test still exercises Plan.
|
||||||
|
- `TestDetectNonNvChadTriggersBackup` — `lua/` with no chadrc → `BackupNeeded=true`; Plan includes a rename-to-bak action.
|
||||||
|
- `TestPlanApplyInstallsEverything` — fake Runner + fake GitClient + tempdir HOME; asserts pacman call, git clone call, backup done, assets written with expected content, chadrc contains the theme name.
|
||||||
|
- `TestPlanAfterApplyIsEmpty` — idempotency.
|
||||||
|
- `TestUnknownThemeFallsBackToFirst` — construction with a bogus ID still produces a valid chadrc.
|
||||||
|
|
||||||
|
### renode (all unit)
|
||||||
|
|
||||||
|
- `TestDetectNothingInstalled` — plans AUR install + pacman install + sim-script write + wokwi download.
|
||||||
|
- `TestDetectAllInstalled` — empty plan.
|
||||||
|
- `TestApplyWritesSimScript` — rendered script contains each configured platform's case-branch.
|
||||||
|
- `TestApplyDownloadsWokwi` — httptest server serves a short payload; verify file written to `~/.local/bin/wokwi-cli` with mode 0755.
|
||||||
|
|
||||||
|
### platform.WriteUserFile
|
||||||
|
|
||||||
|
- `TestWriteUserFileCreatesParent` — target under a non-existent dir → parent created.
|
||||||
|
- `TestWriteUserFileAtomicity` — two sequential writes of different content → final content matches the second; no `.tmp` leaked after success.
|
||||||
|
- `TestWriteUserFileErrorCleanup` — a failed write leaves no `.tmp` turd (simulate via read-only parent or unwritable tmp; accept either form if OS makes it awkward).
|
||||||
|
|
||||||
|
No integration tests against real pacman, yay, neovim, renode, or GitHub; that's Plan 4's CI.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- `scaffold` module — deferred to Plan 4 as native Go subcommands (`archduino new`, `archduino theme`).
|
||||||
|
- Upstream Neovim tarball fallback (`/opt/nvim-linux-x86_64`) — pacman is sufficient; version-drift concerns go to Plan 4's `doctor`.
|
||||||
|
- LazyVim → NvChad plugin salvage — existing non-NvChad configs get a full backup; user re-adds plugins manually.
|
||||||
|
- Wokwi GitHub-API "latest" lookup — version is pinned in config, overrideable via overlay.
|
||||||
|
- Retrofitting Plan 3a modules to use `WriteUserFile` — opportunistic cleanup only, not a Plan 3b deliverable.
|
||||||
|
- Retroactive theme-sync across every tool (starship, zellij, yazi, lazygit) — Plan 4's `archduino theme` subcommand.
|
||||||
|
|
||||||
|
## Open Risks / Watchpoints
|
||||||
|
|
||||||
|
- **NvChad starter drift** — if upstream changes `lua/plugins/init.lua`'s `nvim-lspconfig` block format, our sed-equivalent strip-logic in Go will miss it. Mitigation: implement as "find the `{` / `},` block containing the string `neovim/nvim-lspconfig` and delete it", unit-test against a fixture copy of the current starter's `init.lua`.
|
||||||
|
- **arduino-sim pinned to Renode's REPL paths** — if Renode renames `platforms/cpus/rp2040.repl`, the sim script breaks. Acceptable for v1; surface via Plan 4 doctor.
|
||||||
|
- **Theme propagation race** — rebuilding the registry twice around the wizard is ugly. Alternative considered (module-level `SetTheme` after construction) was rejected as un-idiomatic. Acceptable for 2 modules; revisit if more theme-aware modules appear.
|
||||||
|
- **Asset byte-equality idempotency** — if a user edits their `mappings.lua` by hand, every Apply will overwrite it. Documented behavior; `--force-assets` flag is a Plan 4 concern.
|
||||||
200
docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md
Normal file
200
docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Plan 4a — User-Facing Subcommands Design
|
||||||
|
|
||||||
|
**Status:** Approved 2026-04-14
|
||||||
|
**Scope:** Ship three new subcommands — `archduino theme`, `archduino new`, `archduino doctor` — plus the shared `theming` package they all build on. This plan absorbs the functionality of `lib/scaffold.sh` (originally deferred from Plan 3b) and `lib/themes/*.sh` natively into Go, without shipping bash wrappers.
|
||||||
|
|
||||||
|
Plan 4b (reinstall/reset mode), 4c (bootstrap + goreleaser), and 4d (CI) are separate plans with independent specs.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Complete user-facing feature parity with the Bash installer by adding three subcommands: a theme switcher that updates starship/zellij/yazi/lazygit/NvChad configs in place; a project scaffolder for Arduino sketches; and a doctor command that reports module convergence and pinned-tool version drift.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
One new shared package and two subcommand packages:
|
||||||
|
|
||||||
|
- **`internal/theming/`** — reusable package that embeds per-theme config assets (5 themes × 4 tool configs + a manifest each) and exposes `List() []Theme`, `Get(id) (Theme, bool)`, and `Apply(cfg, themeID) error`. Called from both the `install` command (applies the wizard's selected theme at the end of an install run) and the new `theme` subcommand.
|
||||||
|
- **`internal/subcommands/newproject/`** — owns `archduino new <project> <board>`. Board list lives in `config.Config.Scaffolding.Boards`; `.ino` starter templates embedded.
|
||||||
|
- **`internal/subcommands/doctor/`** — owns `archduino doctor`. Iterates the module registry, calls `Detect` + `Plan` on each, reports per-module status and pinned-tool version drift.
|
||||||
|
|
||||||
|
`tui.BuiltinThemes` and the `tui.Theme.NvChadTheme` field retire: the canonical theme list is now discovered from `theming`'s embedded manifests. The wizard calls `theming.List()`. `tui.Theme` stays as a thin display value (`ID`, `Name`) used by the wizard's dropdown.
|
||||||
|
|
||||||
|
The existing `install` command gains a final step: after `engine.Run` succeeds, call `theming.Apply(cfg, finalState.ThemeID)` so the initial install leaves tools themed, not just installed. This is an improvement over the Bash behavior, where the theme was never applied during install.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
Go stdlib + existing deps. `go:embed` for all assets. `gopkg.in/yaml.v3` for overlay round-trip updates. `huh` (already a dep) for the interactive theme picker. `text/template` used only where needed (neovim chadrc already uses it; theming's tool configs are static and don't).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### New — `internal/theming/`
|
||||||
|
|
||||||
|
- `theming.go`
|
||||||
|
- Exported: `type Theme struct { ID, DisplayName, NvChadTheme string }`
|
||||||
|
- `func List() []Theme` — returns themes in manifest-discovery order (sort by ID for determinism).
|
||||||
|
- `func Get(id string) (Theme, bool)`
|
||||||
|
- `func Apply(cfg config.Config, themeID string) error`
|
||||||
|
- Private: `discoverThemes()` run once at package init; reads `assets/*/theme.yaml`.
|
||||||
|
- `theming_test.go`
|
||||||
|
- `assets/<theme>/theme.yaml` — five files (`gruvbox`, `catppuccin`, `tokyonight`, `nord`, `dracula`), each containing:
|
||||||
|
```yaml
|
||||||
|
display_name: "Gruvbox Dark"
|
||||||
|
nvchad: "gruvbox"
|
||||||
|
```
|
||||||
|
- `assets/<theme>/starship.toml`, `zellij.kdl`, `yazi.toml`, `lazygit.yml` — 20 static config files lifted verbatim from the corresponding `write_*_config` heredocs in `lib/themes/<theme>.sh`. Tool config paths on disk are:
|
||||||
|
- starship.toml → `~/.config/starship.toml`
|
||||||
|
- zellij.kdl → `~/.config/zellij/config.kdl`
|
||||||
|
- yazi.toml → `~/.config/yazi/theme.toml`
|
||||||
|
- lazygit.yml → `~/.config/lazygit/config.yml`
|
||||||
|
|
||||||
|
### New — `internal/subcommands/newproject/`
|
||||||
|
|
||||||
|
- `newproject.go`
|
||||||
|
- `func Run(cfg config.Config, project, board string) error`.
|
||||||
|
- `newproject_test.go`
|
||||||
|
- `assets/<board>.ino` — six starter sketches (`uno`, `mega`, `nano`, `esp32`, `esp32s3`, `esp8266`) plus `default.ino`. Content lifted from `lib/scaffold.sh` heredocs.
|
||||||
|
|
||||||
|
### New — `internal/subcommands/doctor/`
|
||||||
|
|
||||||
|
- `doctor.go`
|
||||||
|
- `func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) (exitCode int)`
|
||||||
|
- `doctor_test.go`
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
|
||||||
|
- `internal/config/config.go` + `internal/config/defaults.yaml`
|
||||||
|
- Add:
|
||||||
|
```go
|
||||||
|
type Board struct { Name, FQBN, Template string `yaml:"name,fqbn,template"` }
|
||||||
|
type Scaffolding struct { Boards []Board `yaml:"boards"` }
|
||||||
|
```
|
||||||
|
(adjust tag syntax — yaml tags per field as with existing types).
|
||||||
|
- `Config.Scaffolding Scaffolding`
|
||||||
|
- `merge` clause: `if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }`.
|
||||||
|
- Defaults: the 6 boards from `lib/scaffold.sh` FQBN_MAP with `template` set to the board name (so `template: "uno"` maps to `internal/subcommands/newproject/assets/uno.ino`).
|
||||||
|
|
||||||
|
- `internal/modules/neovim/neovim.go`
|
||||||
|
- Extract the chadrc render+write logic into an exported helper the theming package can call. Suggested signature: `func RewriteChadrc(themeID string, themes []Theme) error` (takes themes so the module doesn't depend on the theming package — avoid import cycle). Or simpler: `func WriteChadrc(nvChadThemeName string) error` — takes the already-resolved NvChad name, no theme-registry knowledge needed. The helper no-ops if `~/.config/nvim/lua/chadrc.lua` doesn't exist.
|
||||||
|
|
||||||
|
- `internal/tui/state.go`
|
||||||
|
- Remove `BuiltinThemes`, remove `NvChadTheme` field from `Theme`, remove `ThemeByID`. Wizard takes a `[]Theme` parameter or reads from `theming.List()` directly.
|
||||||
|
- Option: delete `tui.Theme` entirely and have the wizard use `theming.Theme` directly. Weigh the import coupling: `internal/tui` already imports `internal/config`, and `theming` imports `config` too, so `tui → theming` is fine.
|
||||||
|
|
||||||
|
- `internal/tui/wizard/wizard.go`
|
||||||
|
- Populate the theme dropdown from `theming.List()`.
|
||||||
|
|
||||||
|
- `cmd/archduino/main.go`
|
||||||
|
- Add three `cobra.Command` constructors: `newThemeCmd()`, `newProjectCmd()`, `newDoctorCmd()`.
|
||||||
|
- Register them on the root command alongside `install`.
|
||||||
|
- In the `install` RunE, after `engine.Run` returns success, call `theming.Apply(cfg, finalState.ThemeID)` and surface any error.
|
||||||
|
- `buildRegistry` no longer needs to receive `themeID` for neovim's chadrc — neovim's install path writes chadrc based on the ID threaded through `neovim.New`, and `theming.Apply` overwrites it afterward. Keeping the parameter is fine; it remains useful for the initial chadrc write.
|
||||||
|
|
||||||
|
## Command surfaces
|
||||||
|
|
||||||
|
- `archduino theme` → interactive huh-based picker listing themes; pre-selects `cfg.ThemeOverrides["__active"]` (or the first theme if unset). On confirm, calls `theming.Apply`.
|
||||||
|
- `archduino theme <id>` → non-interactive; calls `theming.Apply(cfg, id)`. Errors if unknown.
|
||||||
|
- `archduino theme list` → prints `<id> — <display_name>` one per line to stdout.
|
||||||
|
- `archduino new <project> <board>` → see data-flow below.
|
||||||
|
- `archduino doctor` → see data-flow below.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### theming.Apply
|
||||||
|
|
||||||
|
1. Look up theme by ID; return error if unknown (message lists valid IDs).
|
||||||
|
2. For each of the four tool configs under `assets/<theme>/`, read via `embed.FS` and write to its target path via `platform.WriteUserFile` with mode 0o644.
|
||||||
|
3. If `~/.config/nvim/lua/chadrc.lua` exists, call `neovim.WriteChadrc(theme.NvChadTheme)` to re-render it. If it doesn't exist, skip silently (user hasn't run install yet, or removed nvim config).
|
||||||
|
4. Idempotently clear NvChad's theme cache: `os.RemoveAll(~/.cache/nvim/base46)` and `os.RemoveAll(~/.local/share/nvim/lazy/base46/lua/base46/themes)`. Errors ignored — cache may not exist.
|
||||||
|
5. Update the user's overlay file at `~/.config/archduino/overlay.yaml`: load existing YAML (or empty), set `theme_overrides.__active: <id>`, write back via a YAML round-trip using `gopkg.in/yaml.v3` decode-into-map-then-encode. First-run creates the file with the single key.
|
||||||
|
|
||||||
|
### newproject.Run
|
||||||
|
|
||||||
|
1. Validate `project`: non-empty, no `/` or `\\`, doesn't start with `.`.
|
||||||
|
2. Look up board in `cfg.Scaffolding.Boards`. If absent, return an error whose message lists all valid board names, one per line.
|
||||||
|
3. `os.Stat(project)`; if it exists, return "project directory already exists". Do not overwrite.
|
||||||
|
4. `os.MkdirAll(project, 0o755)`.
|
||||||
|
5. Determine `.ino` asset path: `<board.Template>.ino`, falling back to `default.ino` if the board-specific asset is missing. Write to `<project>/<project>.ino` via `platform.WriteUserFile` mode 0o644.
|
||||||
|
6. Write `<project>/sketch.yaml`:
|
||||||
|
```yaml
|
||||||
|
default_fqbn: <FQBN>
|
||||||
|
default_port: auto
|
||||||
|
```
|
||||||
|
7. Write `<project>/.clangd`:
|
||||||
|
```yaml
|
||||||
|
CompileFlags:
|
||||||
|
Add:
|
||||||
|
- -xc++
|
||||||
|
- -std=c++17
|
||||||
|
```
|
||||||
|
8. Print a success line to stdout: `Created <project>/ (fqbn: <FQBN>). Next: cd <project> && nvim <project>.ino`.
|
||||||
|
|
||||||
|
### doctor.Run
|
||||||
|
|
||||||
|
1. For each module in `reg.All()`: call `Detect(ctx)`, then `Plan(ctx, state)`. Format output one row per module:
|
||||||
|
- `[✓] <id>` — empty plan (converged).
|
||||||
|
- `[✗] <id> (N actions pending)` — non-empty plan.
|
||||||
|
- `[!] <id> — detect failed: <err>` — Detect/Plan returned error. Never propagate the error up; record and continue.
|
||||||
|
2. Version drift checks (only if `arduino-cli` / `wokwi-cli` are found on PATH via the injected `Runner`):
|
||||||
|
- `arduino-cli version --format json` → parse `VersionString`, compare to `cfg.ArduinoCLI.Version`. Print `[✓] arduino-cli <ver>` or `[!] arduino-cli drift: got <X>, want <Y>`.
|
||||||
|
- `wokwi-cli --version` → parse first line's version-like token, compare to `cfg.Renode.WokwiVersion`. Same output format.
|
||||||
|
3. Summary line: `<converged>/<total> converged, <pending> need-install, <drift> drift, <errors> errors`.
|
||||||
|
4. Exit code: 0 if all modules converged AND no drift AND no errors; else 1. Return value from `Run` is the exit code; the cobra RunE wrapper calls `os.Exit(code)`.
|
||||||
|
|
||||||
|
No network calls. No writes. Fast enough to run interactively (<5s on a typical install).
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- `theming.Apply`: first write error aborts. No rollback. User re-runs after fixing (partial state is a benign overlap of two themes' configs on disk until re-run). Overlay update happens last; if a config write fails, the overlay is not touched, so `__active` still reflects the previously-applied theme.
|
||||||
|
- `newproject.Run`: refuses to overwrite an existing project directory. All other failures (mkdir, write) bubble up to the cobra layer and print via `RunE`'s default error handling.
|
||||||
|
- `doctor.Run`: never returns a Go error. Module failures are reported inline; only a programmer error (e.g., nil registry) would panic.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### theming_test.go (all unit, tempdir-based $HOME)
|
||||||
|
|
||||||
|
- `TestListReadsManifests` — asserts 5 themes discovered, each with non-empty DisplayName and NvChadTheme.
|
||||||
|
- `TestGetKnownAndUnknown` — happy path + false return for bogus ID.
|
||||||
|
- `TestApplyWritesAllFourConfigs` — `t.Setenv("HOME", …)`; `Apply(cfg, "dracula")`; assert each of the 4 target files exists with non-empty content and the first line matches the asset's first line (robust to copy-paste drift in tests).
|
||||||
|
- `TestApplyUpdatesOverlayActive` — after Apply, `~/.config/archduino/overlay.yaml` contains `theme_overrides.__active: dracula` (round-trip through `gopkg.in/yaml.v3`).
|
||||||
|
- `TestApplyPreservesOtherOverlayKeys` — seed overlay with `extra_packages: [foo]`, run Apply, assert `extra_packages` still present after write.
|
||||||
|
- `TestApplyRewritesChadrcIfPresent` — seed `~/.config/nvim/lua/chadrc.lua` with gruvbox; Apply dracula; assert file now contains `theme = "dracula"`.
|
||||||
|
- `TestApplyNoChadrcIsFine` — no chadrc on disk; Apply succeeds.
|
||||||
|
- `TestApplyUnknownThemeErrors`.
|
||||||
|
|
||||||
|
### newproject_test.go
|
||||||
|
|
||||||
|
- `TestNewCreatesProject` — config with `uno` board; project dir doesn't exist; `Run(cfg, "blinky", "uno")` in a tempdir (chdir); assert `blinky/blinky.ino` + `sketch.yaml` (contains `arduino:avr:uno`) + `.clangd` exist.
|
||||||
|
- `TestNewRefusesExistingDir` — pre-create the target dir; `Run` returns error; dir untouched.
|
||||||
|
- `TestNewFallsBackToDefaultTemplate` — config board entry whose `.template` points at an asset that doesn't exist (use a made-up board with `template: "xyz"`); Run succeeds and the generated `.ino` matches `default.ino` content.
|
||||||
|
- `TestNewUnknownBoardErrors` — error message lists all valid board names.
|
||||||
|
- `TestNewRejectsSlashInProjectName`.
|
||||||
|
|
||||||
|
### doctor_test.go
|
||||||
|
|
||||||
|
- `TestDoctorAllConvergedExitsZero` — single stub module returning empty Plan; runner that makes version commands not-found (`exec: "arduino-cli": executable file not found in $PATH`-style error — skip version checks cleanly). Exit code 0.
|
||||||
|
- `TestDoctorDivergedExitsOne` — single stub module returning a Plan with actions; exit code 1.
|
||||||
|
- `TestDoctorDetectErrorIsReported` — module whose Detect returns an error; doctor prints `[!]` and exits 1.
|
||||||
|
- `TestDoctorArduinoCLIDriftIsReported` — injected Runner returns a different version for `arduino-cli version --format json`; exit code 1; output contains "drift".
|
||||||
|
- `TestDoctorMissingBinariesSkipVersionCheck` — runner returns "not found"; no false drift report; exit reflects module status only.
|
||||||
|
|
||||||
|
### install-command integration (smoke-level)
|
||||||
|
|
||||||
|
- Existing `install` tests continue to pass; add a unit test that stubs `theming.Apply` and asserts `install` calls it after a successful `engine.Run` with the wizard's ThemeID.
|
||||||
|
|
||||||
|
No integration tests against real starship/zellij/arduino-cli/wokwi binaries — Plan 4d (CI) introduces those.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Per-user theme creation / custom palettes — pinned to the five built-in themes.
|
||||||
|
- Live reload: users must restart their shell, zellij session, etc. manually (bash also required this).
|
||||||
|
- `archduino doctor --fix` mode — reserved for a future plan, possibly merged with Plan 4b's reinstall/reset.
|
||||||
|
- Version-drift remediation — doctor only reports.
|
||||||
|
- `archduino new` support for non-Arduino-CLI ecosystems (PlatformIO, etc.).
|
||||||
|
|
||||||
|
## Open Risks / Watchpoints
|
||||||
|
|
||||||
|
- **NvChad chadrc extraction:** pulling the render-and-write out of `neovim.Module.Plan`'s closures into a free function requires care — currently that closure is built inside `Plan`. Acceptable fix: factor a `writeChadrc(cfg string, nvChadTheme string) error` free function in the neovim package, have both the Plan action and the new `WriteChadrc` export use it.
|
||||||
|
- **tui.Theme retirement:** wizard test coverage is decent but `BuiltinThemes` is referenced in `internal/tui/state_test.go`; removing the symbol requires updating those tests. Grep before deleting.
|
||||||
|
- **Overlay YAML round-trip:** `gopkg.in/yaml.v3`'s node-based decoder preserves comments and order; using `map[string]any` decode/encode will lose both. Acceptable for v1 (the user's overlay is config, not hand-commented prose); document the behavior in the `theming.Apply` godoc.
|
||||||
|
- **Doctor version parsing:** `arduino-cli version --format json` shape is stable, but `wokwi-cli --version`'s output format is upstream-controlled. A tiny regex `v?\d+\.\d+\.\d+` covers the common case; if it grows fragile, fall back to byte-for-byte compare with `cfg.Renode.WokwiVersion`.
|
||||||
|
- **Theme-applied-during-install is a behavior change** vs. Bash. It means a fresh install writes tool configs. If the user had pre-existing starship/zellij configs they liked, they get overwritten. Mitigation: `install` preserves a `.bak.<timestamp>` copy before the theme apply step. Document clearly in the install command's help text.
|
||||||
42
go.mod
Normal file
42
go.mod
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
module github.com/MDietermann/ArduinoArchSetup
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/huh v1.0.0
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
|
||||||
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
|
)
|
||||||
88
go.sum
Normal file
88
go.sum
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
|
||||||
|
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
|
||||||
|
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||||
|
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913 h1:MvJqk9htSLLxNlNQRqfRMhObcytCT4+xfO4830kNlVs=
|
||||||
|
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
165
internal/config/config.go
Normal file
165
internal/config/config.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
type Packages struct {
|
||||||
|
Base []string `yaml:"base"`
|
||||||
|
}
|
||||||
|
type ArduinoCLIConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Cores []string `yaml:"cores"`
|
||||||
|
}
|
||||||
|
type ZshPlugin struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Repo string `yaml:"repo"`
|
||||||
|
}
|
||||||
|
type ZshConfig struct {
|
||||||
|
OhMyZshRepo string `yaml:"oh_my_zsh_repo"`
|
||||||
|
Plugins []ZshPlugin `yaml:"plugins"`
|
||||||
|
}
|
||||||
|
type ZellijConfig struct {
|
||||||
|
ConfigURL string `yaml:"config_url"`
|
||||||
|
}
|
||||||
|
type ThemesConfig struct {
|
||||||
|
Source string `yaml:"source"`
|
||||||
|
}
|
||||||
|
type ShellConfigBlock struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
File string `yaml:"file"`
|
||||||
|
Content string `yaml:"content"`
|
||||||
|
}
|
||||||
|
type ShellConfigSection struct {
|
||||||
|
Blocks []ShellConfigBlock `yaml:"blocks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NeovimConfig struct {
|
||||||
|
NvChadStarterRepo string `yaml:"nvchad_starter_repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenodePlatform struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
ReplPath string `yaml:"repl_path"`
|
||||||
|
Machine string `yaml:"machine"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenodeConfig struct {
|
||||||
|
WokwiVersion string `yaml:"wokwi_version"`
|
||||||
|
WokwiURL string `yaml:"wokwi_url"`
|
||||||
|
Platforms []RenodePlatform `yaml:"platforms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
Packages Packages `yaml:"packages"`
|
||||||
|
Fonts []string `yaml:"fonts"`
|
||||||
|
ArduinoCLI ArduinoCLIConfig `yaml:"arduino_cli"`
|
||||||
|
Zsh ZshConfig `yaml:"zsh"`
|
||||||
|
Zellij ZellijConfig `yaml:"zellij"`
|
||||||
|
Themes ThemesConfig `yaml:"themes"`
|
||||||
|
ShellConfig ShellConfigSection `yaml:"shell_config"`
|
||||||
|
Neovim NeovimConfig `yaml:"neovim"`
|
||||||
|
Renode RenodeConfig `yaml:"renode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if len(over.Packages.Base) > 0 {
|
||||||
|
out.Packages = over.Packages
|
||||||
|
}
|
||||||
|
if len(over.Fonts) > 0 {
|
||||||
|
out.Fonts = over.Fonts
|
||||||
|
}
|
||||||
|
if over.ArduinoCLI.Version != "" {
|
||||||
|
out.ArduinoCLI = over.ArduinoCLI
|
||||||
|
}
|
||||||
|
if over.Zsh.OhMyZshRepo != "" {
|
||||||
|
out.Zsh = over.Zsh
|
||||||
|
}
|
||||||
|
if over.Zellij.ConfigURL != "" {
|
||||||
|
out.Zellij = over.Zellij
|
||||||
|
}
|
||||||
|
if over.Themes.Source != "" {
|
||||||
|
out.Themes = over.Themes
|
||||||
|
}
|
||||||
|
if len(over.ShellConfig.Blocks) > 0 {
|
||||||
|
out.ShellConfig = over.ShellConfig
|
||||||
|
}
|
||||||
|
if over.Neovim.NvChadStarterRepo != "" {
|
||||||
|
out.Neovim = over.Neovim
|
||||||
|
}
|
||||||
|
if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 {
|
||||||
|
out.Renode = over.Renode
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
110
internal/config/config_test.go
Normal file
110
internal/config/config_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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 TestDefaultsCarryModuleInputs(t *testing.T) {
|
||||||
|
cfg, err := Load("/nonexistent/overlay.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cfg.Packages.Base) == 0 {
|
||||||
|
t.Error("Packages.Base empty")
|
||||||
|
}
|
||||||
|
if len(cfg.Fonts) == 0 {
|
||||||
|
t.Error("Fonts empty")
|
||||||
|
}
|
||||||
|
if cfg.ArduinoCLI.Version == "" {
|
||||||
|
t.Error("ArduinoCLI.Version empty")
|
||||||
|
}
|
||||||
|
if cfg.ArduinoCLI.URL == "" {
|
||||||
|
t.Error("ArduinoCLI.URL empty")
|
||||||
|
}
|
||||||
|
if cfg.Zsh.OhMyZshRepo == "" {
|
||||||
|
t.Error("Zsh.OhMyZshRepo empty")
|
||||||
|
}
|
||||||
|
if len(cfg.ShellConfig.Blocks) == 0 {
|
||||||
|
t.Error("ShellConfig.Blocks empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultsCarryPlan3bInputs(t *testing.T) {
|
||||||
|
cfg, err := Load("/nonexistent/overlay.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Neovim.NvChadStarterRepo == "" {
|
||||||
|
t.Error("Neovim.NvChadStarterRepo empty")
|
||||||
|
}
|
||||||
|
if cfg.Renode.WokwiVersion == "" {
|
||||||
|
t.Error("Renode.WokwiVersion empty")
|
||||||
|
}
|
||||||
|
if cfg.Renode.WokwiURL == "" {
|
||||||
|
t.Error("Renode.WokwiURL empty")
|
||||||
|
}
|
||||||
|
if len(cfg.Renode.Platforms) == 0 {
|
||||||
|
t.Error("Renode.Platforms empty")
|
||||||
|
}
|
||||||
|
want := map[string]string{"stm32f4": "STM32F4_Discovery", "rp2040": "RP2040", "nrf52840": "nRF52840"}
|
||||||
|
for _, p := range cfg.Renode.Platforms {
|
||||||
|
if want[p.Name] != p.Machine {
|
||||||
|
t.Errorf("platform %q: machine=%q want %q", p.Name, p.Machine, want[p.Name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
99
internal/config/defaults.yaml
Normal file
99
internal/config/defaults.yaml
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
version: 1
|
||||||
|
extra_packages: []
|
||||||
|
disabled_modules: []
|
||||||
|
theme_overrides: {}
|
||||||
|
|
||||||
|
packages:
|
||||||
|
base:
|
||||||
|
- base-devel
|
||||||
|
- git
|
||||||
|
- curl
|
||||||
|
- wget
|
||||||
|
- unzip
|
||||||
|
- zip
|
||||||
|
- tree
|
||||||
|
- htop
|
||||||
|
- fastfetch
|
||||||
|
- ripgrep
|
||||||
|
- fd
|
||||||
|
- bat
|
||||||
|
- eza
|
||||||
|
- jq
|
||||||
|
- zsh
|
||||||
|
- clang
|
||||||
|
- ca-certificates
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
- ttf-jetbrains-mono-nerd
|
||||||
|
|
||||||
|
arduino_cli:
|
||||||
|
version: "0.35.3"
|
||||||
|
url: "https://github.com/arduino/arduino-cli/releases/download/v0.35.3/arduino-cli_0.35.3_Linux_64bit.tar.gz"
|
||||||
|
cores:
|
||||||
|
- arduino:avr
|
||||||
|
|
||||||
|
zsh:
|
||||||
|
oh_my_zsh_repo: "https://github.com/ohmyzsh/ohmyzsh.git"
|
||||||
|
plugins:
|
||||||
|
- name: zsh-autosuggestions
|
||||||
|
repo: https://github.com/zsh-users/zsh-autosuggestions
|
||||||
|
- name: zsh-syntax-highlighting
|
||||||
|
repo: https://github.com/zsh-users/zsh-syntax-highlighting.git
|
||||||
|
|
||||||
|
zellij:
|
||||||
|
config_url: ""
|
||||||
|
|
||||||
|
themes:
|
||||||
|
source: "lib/themes"
|
||||||
|
|
||||||
|
shell_config:
|
||||||
|
blocks:
|
||||||
|
- name: archduino-path
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
- name: archduino-editor
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
export EDITOR="nvim"
|
||||||
|
export VISUAL="nvim"
|
||||||
|
- name: archduino-starship
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
eval "$(starship init zsh)"
|
||||||
|
- name: archduino-zellij
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
if [[ -z "$ZELLIJ" ]]; then
|
||||||
|
zellij attach --create archduino
|
||||||
|
fi
|
||||||
|
- name: archduino-aliases
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
alias adev="zellij --layout arduino"
|
||||||
|
alias ac="arduino-cli"
|
||||||
|
alias acc="arduino-cli compile"
|
||||||
|
alias acu="arduino-cli upload"
|
||||||
|
alias acm="arduino-cli monitor"
|
||||||
|
alias acb="arduino-cli board list"
|
||||||
|
alias lg="lazygit"
|
||||||
|
alias v="nvim"
|
||||||
|
alias y="yazi"
|
||||||
|
alias theme="archduino-theme"
|
||||||
|
|
||||||
|
neovim:
|
||||||
|
nvchad_starter_repo: "https://github.com/NvChad/starter"
|
||||||
|
|
||||||
|
renode:
|
||||||
|
wokwi_version: "0.26.1"
|
||||||
|
wokwi_url: "https://github.com/wokwi/wokwi-cli/releases/download/v0.26.1/wokwi-cli-linuxstatic-x64"
|
||||||
|
platforms:
|
||||||
|
- name: stm32f4
|
||||||
|
repl_path: "platforms/boards/stm32f4_discovery.repl"
|
||||||
|
machine: "STM32F4_Discovery"
|
||||||
|
- name: rp2040
|
||||||
|
repl_path: "platforms/cpus/rp2040.repl"
|
||||||
|
machine: "RP2040"
|
||||||
|
- name: nrf52840
|
||||||
|
repl_path: "platforms/cpus/nrf52840.repl"
|
||||||
|
machine: "nRF52840"
|
||||||
91
internal/engine/dag.go
Normal file
91
internal/engine/dag.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/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
|
||||||
|
}
|
||||||
79
internal/engine/dag_test.go
Normal file
79
internal/engine/dag_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/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")
|
||||||
|
}
|
||||||
|
}
|
||||||
187
internal/engine/engine.go
Normal file
187
internal/engine/engine.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/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))
|
||||||
|
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, 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, 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, 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, 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, skipped map[string]bool) {
|
||||||
|
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
|
||||||
|
}
|
||||||
146
internal/engine/engine_test.go
Normal file
146
internal/engine/engine_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/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")
|
||||||
|
}
|
||||||
|
}
|
||||||
80
internal/engine/reporter.go
Normal file
80
internal/engine/reporter.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Package engine runs modules through the Detect/Plan/Apply/Verify phases.
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/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)
|
||||||
|
)
|
||||||
56
internal/engine/reporter_test.go
Normal file
56
internal/engine/reporter_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
internal/module/module.go
Normal file
61
internal/module/module.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
56
internal/module/registry.go
Normal file
56
internal/module/registry.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
57
internal/module/registry_test.go
Normal file
57
internal/module/registry_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
138
internal/modules/arduinocli/arduinocli.go
Normal file
138
internal/modules/arduinocli/arduinocli.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Package arduinocli installs arduino-cli and its configured cores.
|
||||||
|
package arduinocli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg config.ArduinoCLIConfig
|
||||||
|
runner platform.Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ArduinoCLIConfig, r platform.Runner) Module {
|
||||||
|
return Module{cfg: cfg, runner: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
BinaryPath string
|
||||||
|
BinaryInstalled bool
|
||||||
|
MissingCores []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "arduinocli", Name: "arduino-cli", Description: "Install arduino-cli and cores."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func binaryPath() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".local", "bin", "arduino-cli"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
bp, err := binaryPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := state{BinaryPath: bp}
|
||||||
|
if _, err := os.Stat(bp); err == nil {
|
||||||
|
s.BinaryInstalled = true
|
||||||
|
out, _ := m.runner.Output(ctx, bp, "core", "list")
|
||||||
|
for _, core := range m.cfg.Cores {
|
||||||
|
if !bytes.Contains(out, []byte(core)) {
|
||||||
|
s.MissingCores = append(s.MissingCores, core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.MissingCores = append([]string{}, m.cfg.Cores...)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
if !s.BinaryInstalled {
|
||||||
|
bp := s.BinaryPath
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "download + install arduino-cli " + m.cfg.Version,
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "arduinocli-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
archive := filepath.Join(tmpDir, "arduino-cli.tar.gz")
|
||||||
|
if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(bp, data, 0o755)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, core := range s.MissingCores {
|
||||||
|
core := core
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "arduino-cli core update-index (for " + core + ")",
|
||||||
|
Kind: module.ActionCommand,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
return m.runner.Run(ctx, s.BinaryPath, "core", "update-index")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "arduino-cli core install " + core,
|
||||||
|
Kind: module.ActionCommand,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
return m.runner.Run(ctx, s.BinaryPath, "core", "install", core)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, 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
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.BinaryInstalled {
|
||||||
|
return fmt.Errorf("arduino-cli binary missing")
|
||||||
|
}
|
||||||
|
if len(s.MissingCores) > 0 {
|
||||||
|
return fmt.Errorf("cores missing: %v", s.MissingCores)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
94
internal/modules/arduinocli/arduinocli_test.go
Normal file
94
internal/modules/arduinocli/arduinocli_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package arduinocli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
coreListOut []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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if filepath.Base(name) == "arduino-cli" && len(args) >= 2 && args[0] == "core" && args[1] == "list" {
|
||||||
|
return f.coreListOut, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func makeTarGz(t *testing.T, entries map[string][]byte) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "a.tgz")
|
||||||
|
f, _ := os.Create(p)
|
||||||
|
gz := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for name, data := range entries {
|
||||||
|
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))})
|
||||||
|
_, _ = tw.Write(data)
|
||||||
|
}
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
f.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\n")})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, _ := os.ReadFile(archive)
|
||||||
|
w.Write(data)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
r := &fakeRunner{coreListOut: []byte("ID Installed\n")}
|
||||||
|
m := New(config.ArduinoCLIConfig{
|
||||||
|
Version: "1.0.0",
|
||||||
|
URL: srv.URL + "/arduino-cli.tgz",
|
||||||
|
Cores: []string{"arduino:avr"},
|
||||||
|
}, r)
|
||||||
|
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(home, ".local", "bin", "arduino-cli")); err != nil {
|
||||||
|
t.Fatalf("binary not installed: %v", err)
|
||||||
|
}
|
||||||
|
sawCore := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" && c[3] == "arduino:avr" {
|
||||||
|
sawCore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawCore {
|
||||||
|
t.Fatalf("core install not run; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
82
internal/modules/fonts/fonts.go
Normal file
82
internal/modules/fonts/fonts.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Package fonts installs AUR fonts via the yay helper.
|
||||||
|
package fonts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
fonts []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
aur platform.AURHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(fonts []string, pm platform.PackageManager, aur platform.AURHelper) Module {
|
||||||
|
return Module{fonts: fonts, pm: pm, aur: aur}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "fonts", Name: "Nerd fonts", Description: "Install AUR nerd fonts."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, f := range m.fonts {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{
|
||||||
|
{
|
||||||
|
Description: "ensure yay installed",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "yay install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.aur.Install(ctx, missing) },
|
||||||
|
},
|
||||||
|
}}, 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 miss := st.(state).Missing; len(miss) > 0 {
|
||||||
|
return fmt.Errorf("fonts still missing: %v", miss)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
72
internal/modules/fonts/fonts_test.go
Normal file
72
internal/modules/fonts/fonts_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package fonts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type eo struct{}
|
||||||
|
|
||||||
|
func (eo) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errOne = eo{}
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestFontsPlanApplyUsesYay(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yay": true}}
|
||||||
|
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawYay := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 2 && c[0] == "yay" && c[1] == "-S" {
|
||||||
|
sawYay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawYay {
|
||||||
|
t.Fatalf("no yay install; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFontsDetectPresentNoActions(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yay": true, "ttf-jetbrains-mono-nerd": true}}
|
||||||
|
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan when already installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
74
internal/modules/gittools/gittools.go
Normal file
74
internal/modules/gittools/gittools.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package gittools installs a declarative list of pacman packages.
|
||||||
|
package gittools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
pkgs []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pkgs []string, pm platform.PackageManager) Module {
|
||||||
|
return Module{pkgs: pkgs, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "gittools", Name: "Git tools", Description: "Install lazygit."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: "pacman install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
|
||||||
|
}}}, 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 missing := st.(state).Missing; len(missing) > 0 {
|
||||||
|
return fmt.Errorf("packages not installed after apply: %v", missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/modules/gittools/gittools_test.go
Normal file
113
internal/modules/gittools/gittools_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package gittools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool // key: pkg name
|
||||||
|
}
|
||||||
|
|
||||||
|
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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte(args[1] + " 1.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, errExitOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOneErr struct{}
|
||||||
|
|
||||||
|
func (exitOneErr) Error() string { return "exit status 1" }
|
||||||
|
|
||||||
|
var errExitOne = exitOneErr{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if len(s.Missing) != 1 {
|
||||||
|
t.Fatalf("want 1 missing, got %v", s.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
|
||||||
|
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if len(st.(state).Missing) != 0 {
|
||||||
|
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
|
||||||
|
m := New([]string{"lazygit"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: []string{"lazygit"}})
|
||||||
|
if len(p.Actions) != 1 {
|
||||||
|
t.Fatalf("want 1 action, got %d", len(p.Actions))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Actions[0].Description, "lazygit") {
|
||||||
|
t.Fatalf("desc: %s", p.Actions[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPresentEmpty(t *testing.T) {
|
||||||
|
m := New([]string{"lazygit"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: nil})
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyShellsOut(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawInstall := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
|
||||||
|
sawInstall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawInstall {
|
||||||
|
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAfterApply(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
|
||||||
|
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
||||||
|
if err := m.Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
98
internal/modules/hello/hello.go
Normal file
98
internal/modules/hello/hello.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// 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/ArduinoArchSetup/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
|
||||||
|
}
|
||||||
57
internal/modules/hello/hello_test.go
Normal file
57
internal/modules/hello/hello_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package hello
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/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)
|
||||||
|
}
|
||||||
|
|
||||||
|
st2, _ := m.Detect(ctx)
|
||||||
|
if !st2.(state).Present {
|
||||||
|
t.Fatal("marker should be present after apply")
|
||||||
|
}
|
||||||
|
|
||||||
|
plan2, _ := m.Plan(ctx, st2)
|
||||||
|
if !plan2.IsEmpty() {
|
||||||
|
t.Fatal("second plan should be empty (idempotent)")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != markerContent {
|
||||||
|
t.Fatalf("content = %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
internal/modules/neovim/assets/arduino.lua
Normal file
10
internal/modules/neovim/assets/arduino.lua
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
return {
|
||||||
|
{
|
||||||
|
"stevearc/vim-arduino",
|
||||||
|
ft = { "arduino", "cpp", "c" },
|
||||||
|
config = function()
|
||||||
|
vim.g.arduino_use_cli = 1
|
||||||
|
vim.g.arduino_cli_path = "arduino-cli"
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
8
internal/modules/neovim/assets/chadrc.lua.tmpl
Normal file
8
internal/modules/neovim/assets/chadrc.lua.tmpl
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---@type ChadrcConfig
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
M.base46 = {
|
||||||
|
theme = "{{.NvChadTheme}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
return M
|
||||||
10
internal/modules/neovim/assets/clangd.lua
Normal file
10
internal/modules/neovim/assets/clangd.lua
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
return {
|
||||||
|
cmd = {
|
||||||
|
"clangd",
|
||||||
|
"--background-index",
|
||||||
|
"--clang-tidy",
|
||||||
|
"--header-insertion=iwyu",
|
||||||
|
"--completion-style=detailed",
|
||||||
|
},
|
||||||
|
filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" },
|
||||||
|
}
|
||||||
57
internal/modules/neovim/assets/dap.lua
Normal file
57
internal/modules/neovim/assets/dap.lua
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
return {
|
||||||
|
{
|
||||||
|
"mfussenegger/nvim-dap",
|
||||||
|
keys = { "<leader>d" },
|
||||||
|
config = function()
|
||||||
|
local dap = require("dap")
|
||||||
|
|
||||||
|
-- GDB remote adapter (connects to Renode's GDB server)
|
||||||
|
dap.adapters.gdb_remote = {
|
||||||
|
type = "executable",
|
||||||
|
command = vim.fn.executable("arm-none-eabi-gdb") == 1
|
||||||
|
and "arm-none-eabi-gdb"
|
||||||
|
or "gdb-multiarch",
|
||||||
|
args = { "-i", "dap" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Default debug configurations for Arduino/embedded
|
||||||
|
dap.configurations.c = {
|
||||||
|
{
|
||||||
|
name = "Attach to Renode (GDB :3333)",
|
||||||
|
type = "gdb_remote",
|
||||||
|
request = "attach",
|
||||||
|
target = "localhost:3333",
|
||||||
|
cwd = "${workspaceFolder}",
|
||||||
|
program = function()
|
||||||
|
return vim.fn.input("ELF path: ", vim.fn.getcwd() .. "/build/", "file")
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dap.configurations.cpp = dap.configurations.c
|
||||||
|
dap.configurations.arduino = dap.configurations.c
|
||||||
|
|
||||||
|
-- Breakpoint signs
|
||||||
|
vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError" })
|
||||||
|
vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticOk", linehl = "DiffAdd" })
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rcarriga/nvim-dap-ui",
|
||||||
|
dependencies = { "mfussenegger/nvim-dap", "nvim-neotest/nvim-nio" },
|
||||||
|
keys = { "<leader>du" },
|
||||||
|
config = function()
|
||||||
|
local dapui = require("dapui")
|
||||||
|
dapui.setup()
|
||||||
|
|
||||||
|
-- Auto open/close UI on debug sessions
|
||||||
|
local dap = require("dap")
|
||||||
|
dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end
|
||||||
|
dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end
|
||||||
|
dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nvim-neotest/nvim-nio",
|
||||||
|
lazy = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
2
internal/modules/neovim/assets/lspconfig.lua
Normal file
2
internal/modules/neovim/assets/lspconfig.lua
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
vim.lsp.enable("clangd")
|
||||||
|
return {}
|
||||||
20
internal/modules/neovim/assets/mappings.lua
Normal file
20
internal/modules/neovim/assets/mappings.lua
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
require "nvchad.mappings"
|
||||||
|
|
||||||
|
local map = vim.keymap.set
|
||||||
|
|
||||||
|
map("n", "<leader>ac", "<cmd>ArduinoChooseBoard<cr>", { desc = "Arduino Choose Board" })
|
||||||
|
map("n", "<leader>ap", "<cmd>ArduinoChoosePort<cr>", { desc = "Arduino Choose Port" })
|
||||||
|
map("n", "<leader>av", "<cmd>ArduinoVerify<cr>", { desc = "Arduino Verify (compile)" })
|
||||||
|
map("n", "<leader>au", "<cmd>ArduinoUpload<cr>", { desc = "Arduino Upload" })
|
||||||
|
map("n", "<leader>as", "<cmd>ArduinoSerial<cr>", { desc = "Arduino Serial Monitor" })
|
||||||
|
map("n", "<leader>ai", "<cmd>ArduinoInfo<cr>", { desc = "Arduino Board Info" })
|
||||||
|
map("n", "<leader>aw", "<cmd>!wokwi-cli .<cr>", { desc = "Arduino Wokwi Simulate" })
|
||||||
|
|
||||||
|
-- Debug (DAP)
|
||||||
|
map("n", "<leader>ds", function() require("dap").continue() end, { desc = "Debug Start/Continue" })
|
||||||
|
map("n", "<leader>db", function() require("dap").toggle_breakpoint() end, { desc = "Debug Toggle Breakpoint" })
|
||||||
|
map("n", "<leader>dn", function() require("dap").step_over() end, { desc = "Debug Step Over" })
|
||||||
|
map("n", "<leader>di", function() require("dap").step_into() end, { desc = "Debug Step Into" })
|
||||||
|
map("n", "<leader>do", function() require("dap").step_out() end, { desc = "Debug Step Out" })
|
||||||
|
map("n", "<leader>dx", function() require("dap").terminate() end, { desc = "Debug Terminate" })
|
||||||
|
map("n", "<leader>du", function() require("dapui").toggle() end, { desc = "Debug UI Toggle" })
|
||||||
422
internal/modules/neovim/neovim.go
Normal file
422
internal/modules/neovim/neovim.go
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
// Package neovim installs Neovim via pacman, clones the NvChad starter,
|
||||||
|
// backs up any non-NvChad config, strips the starter's nvim-lspconfig
|
||||||
|
// block, and writes Archduino's Lua asset files.
|
||||||
|
package neovim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/*
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
// assetMap lists each asset and its destination under ~/.config/nvim.
|
||||||
|
// Order matters: Detect iterates in this order and Apply writes in this order.
|
||||||
|
var assetMap = []struct {
|
||||||
|
SrcPath string // under assets/
|
||||||
|
DstRel string // under ~/.config/nvim/
|
||||||
|
}{
|
||||||
|
{"assets/arduino.lua", "lua/plugins/arduino.lua"},
|
||||||
|
{"assets/dap.lua", "lua/plugins/dap.lua"},
|
||||||
|
{"assets/lspconfig.lua", "lua/plugins/lspconfig.lua"},
|
||||||
|
{"assets/mappings.lua", "lua/mappings.lua"},
|
||||||
|
{"assets/clangd.lua", "lsp/clangd.lua"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module installs and configures Neovim + NvChad for Archduino.
|
||||||
|
type Module struct {
|
||||||
|
cfg config.NeovimConfig
|
||||||
|
themeID string
|
||||||
|
themes []tui.Theme
|
||||||
|
pm platform.PackageManager
|
||||||
|
git platform.GitClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Neovim Module.
|
||||||
|
func New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module {
|
||||||
|
return Module{cfg: cfg, themeID: themeID, themes: themes, pm: pm, git: git}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
NvimInstalled bool
|
||||||
|
ConfigDirExists bool
|
||||||
|
ChadrcPresent bool
|
||||||
|
BackupNeeded bool
|
||||||
|
BackupSource string
|
||||||
|
StarterCloned bool
|
||||||
|
MissingAssets []string
|
||||||
|
ChadrcDrift bool
|
||||||
|
LspconfigBlock bool
|
||||||
|
StarterLspFile bool
|
||||||
|
ThemeName string
|
||||||
|
RenderedChadrc []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta returns the module metadata.
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "neovim", Name: "Neovim + NvChad", Description: "Install Neovim and configure NvChad with Archduino plugins."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nvimCfg() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".config", "nvim"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) resolvedTheme() string {
|
||||||
|
if t, ok := tui.ThemeByID(m.themes, m.themeID); ok && t.NvChadTheme != "" {
|
||||||
|
return t.NvChadTheme
|
||||||
|
}
|
||||||
|
if len(m.themes) > 0 {
|
||||||
|
return m.themes[0].NvChadTheme
|
||||||
|
}
|
||||||
|
return "gruvbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) renderChadrc() ([]byte, error) {
|
||||||
|
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("chadrc").Parse(string(tmplBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: m.resolvedTheme()}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect inspects the filesystem and returns the current state.
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
cfg, err := nvimCfg()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var s state
|
||||||
|
s.ThemeName = m.resolvedTheme()
|
||||||
|
if m.pm != nil {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, "neovim")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.NvimInstalled = ok
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := m.renderChadrc()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.RenderedChadrc = rendered
|
||||||
|
|
||||||
|
if fi, err := os.Stat(cfg); err == nil && fi.IsDir() {
|
||||||
|
s.ConfigDirExists = true
|
||||||
|
if _, err := os.Stat(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil {
|
||||||
|
s.ChadrcPresent = true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(cfg, "lua")); err == nil && !s.ChadrcPresent {
|
||||||
|
s.BackupNeeded = true
|
||||||
|
s.BackupSource = cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil {
|
||||||
|
s.StarterCloned = true
|
||||||
|
data, _ := os.ReadFile(filepath.Join(cfg, "lua", "plugins", "init.lua"))
|
||||||
|
if bytes.Contains(data, []byte("nvim-lspconfig")) {
|
||||||
|
s.LspconfigBlock = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")); err == nil {
|
||||||
|
s.StarterLspFile = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if cur, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil {
|
||||||
|
if !bytes.Equal(cur, rendered) {
|
||||||
|
s.ChadrcDrift = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range assetMap {
|
||||||
|
want, _ := assets.ReadFile(a.SrcPath)
|
||||||
|
got, err := os.ReadFile(filepath.Join(cfg, a.DstRel))
|
||||||
|
if err != nil || !bytes.Equal(got, want) {
|
||||||
|
s.MissingAssets = append(s.MissingAssets, a.DstRel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripLspconfigBlock removes the plugin-table entry that references
|
||||||
|
// "neovim/nvim-lspconfig" from the NvChad starter's init.lua. It walks the
|
||||||
|
// source line-by-line: find the marker line, scan backward for the opening
|
||||||
|
// `{` of that entry, then scan forward counting braces until the matching
|
||||||
|
// `}` (plus trailing `,` if present). Robust against nested tables inside
|
||||||
|
// the entry (e.g. `opts = {}`) that defeat single-level regexes.
|
||||||
|
func stripLspconfigBlock(src []byte) []byte {
|
||||||
|
lines := strings.Split(string(src), "\n")
|
||||||
|
markerIdx := -1
|
||||||
|
for i, l := range lines {
|
||||||
|
if strings.Contains(l, "neovim/nvim-lspconfig") {
|
||||||
|
markerIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if markerIdx == -1 {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan backward for the opening `{` that begins the plugin entry.
|
||||||
|
// We track braces relative to the marker line so nested tables on
|
||||||
|
// earlier lines don't confuse the search: we want the first `{`
|
||||||
|
// whose pair would enclose the marker.
|
||||||
|
startLine := -1
|
||||||
|
depth := 0
|
||||||
|
for i := markerIdx; i >= 0; i-- {
|
||||||
|
// Count braces on this line from right to left.
|
||||||
|
for j := len(lines[i]) - 1; j >= 0; j-- {
|
||||||
|
c := lines[i][j]
|
||||||
|
if c == '}' {
|
||||||
|
depth++
|
||||||
|
} else if c == '{' {
|
||||||
|
if depth == 0 {
|
||||||
|
startLine = i
|
||||||
|
// break out of both loops
|
||||||
|
i = -1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if startLine == -1 {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan forward from markerIdx for the matching `}`.
|
||||||
|
endLine := -1
|
||||||
|
depth = 1 // we're already inside the `{` at startLine
|
||||||
|
// Start just after the opening `{` on startLine.
|
||||||
|
for i := startLine; i < len(lines); i++ {
|
||||||
|
start := 0
|
||||||
|
if i == startLine {
|
||||||
|
// skip past the first `{` on this line
|
||||||
|
idx := strings.Index(lines[i], "{")
|
||||||
|
if idx >= 0 {
|
||||||
|
start = idx + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j := start; j < len(lines[i]); j++ {
|
||||||
|
c := lines[i][j]
|
||||||
|
if c == '{' {
|
||||||
|
depth++
|
||||||
|
} else if c == '}' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
endLine = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endLine != -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endLine == -1 {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include an optional trailing comma and whitespace-only lines after endLine.
|
||||||
|
cutEnd := endLine
|
||||||
|
// If the `}` line also has a trailing `,`, that's fine; keep the line as-is
|
||||||
|
// but drop it. If the endLine text after the closing `}` is just `,` or
|
||||||
|
// `,` + whitespace, keep cutEnd = endLine. Otherwise also keep cutEnd.
|
||||||
|
// Skip a single blank line after the block to avoid leaving a stray gap.
|
||||||
|
if cutEnd+1 < len(lines) && strings.TrimSpace(lines[cutEnd+1]) == "" {
|
||||||
|
cutEnd++
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, 0, len(lines)-(cutEnd-startLine+1))
|
||||||
|
out = append(out, lines[:startLine]...)
|
||||||
|
out = append(out, lines[cutEnd+1:]...)
|
||||||
|
return []byte(strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan computes the idempotent set of actions to converge to desired state.
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
cfg, err := nvimCfg()
|
||||||
|
if err != nil {
|
||||||
|
return module.Plan{}, err
|
||||||
|
}
|
||||||
|
var actions []module.Action
|
||||||
|
|
||||||
|
if !s.NvimInstalled && m.pm != nil {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "pacman install neovim",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"neovim"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.BackupNeeded {
|
||||||
|
src := s.BackupSource
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "back up existing non-NvChad config",
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
ts := time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
return os.Rename(src, src+".bak."+ts)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.StarterCloned || s.BackupNeeded {
|
||||||
|
repo := m.cfg.NvChadStarterRepo
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "clone NvChad starter",
|
||||||
|
Kind: module.ActionGitClone,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.git.Clone(ctx, repo, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(filepath.Join(cfg, ".git"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.LspconfigBlock || !s.StarterCloned || s.BackupNeeded {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "strip nvim-lspconfig block from starter init.lua",
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
p := filepath.Join(cfg, "lua", "plugins", "init.lua")
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.StarterLspFile || !s.StarterCloned || s.BackupNeeded {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "remove starter lspconfig.lua",
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
err := os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua"))
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ChadrcDrift || !s.ChadrcPresent || s.BackupNeeded {
|
||||||
|
rendered := append([]byte{}, s.RenderedChadrc...)
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "write chadrc.lua with theme " + s.ThemeName,
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), rendered, 0o644)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dst := range s.MissingAssets {
|
||||||
|
dst := dst
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "write asset " + dst,
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
var src string
|
||||||
|
for _, a := range assetMap {
|
||||||
|
if a.DstRel == dst {
|
||||||
|
src = a.SrcPath
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := assets.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.WriteUserFile(filepath.Join(cfg, dst), data, 0o644)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(actions) > 0 {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "clear NvChad base46 theme cache",
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
_ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes"))
|
||||||
|
_ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return module.Plan{Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply executes the planned actions in order.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify re-Detects and returns an error if state is not converged.
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.NvimInstalled {
|
||||||
|
return fmt.Errorf("neovim not installed")
|
||||||
|
}
|
||||||
|
if !s.ChadrcPresent {
|
||||||
|
return fmt.Errorf("chadrc.lua missing")
|
||||||
|
}
|
||||||
|
if s.ChadrcDrift {
|
||||||
|
return fmt.Errorf("chadrc.lua content drifted")
|
||||||
|
}
|
||||||
|
if len(s.MissingAssets) > 0 {
|
||||||
|
return fmt.Errorf("missing assets: %v", s.MissingAssets)
|
||||||
|
}
|
||||||
|
if s.LspconfigBlock {
|
||||||
|
return fmt.Errorf("nvim-lspconfig block not stripped")
|
||||||
|
}
|
||||||
|
if s.StarterLspFile {
|
||||||
|
return fmt.Errorf("starter lspconfig.lua still present")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
180
internal/modules/neovim/neovim_test.go
Normal file
180
internal/modules/neovim/neovim_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package neovim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls, outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
if name == "git" && len(args) >= 1 && args[0] == "clone" {
|
||||||
|
dest := args[len(args)-1]
|
||||||
|
os.MkdirAll(filepath.Join(dest, "lua", "plugins"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(dest, "lua", "plugins", "init.lua"),
|
||||||
|
[]byte("return {\n \"neovim/nvim-lspconfig\",\n opts = {},\n},\n{\n \"other\",\n},\n"),
|
||||||
|
0o644)
|
||||||
|
os.MkdirAll(filepath.Join(dest, "lua", "configs"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(dest, "lua", "configs", "lspconfig.lua"), []byte("stub"), 0o644)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errExit1
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOne struct{}
|
||||||
|
|
||||||
|
func (exitOne) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errExit1 = exitOne{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectFreshSystem(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if s.NvimInstalled {
|
||||||
|
t.Error("nvim should be absent")
|
||||||
|
}
|
||||||
|
if s.ChadrcPresent {
|
||||||
|
t.Error("chadrc should be absent")
|
||||||
|
}
|
||||||
|
if s.BackupNeeded {
|
||||||
|
t.Error("no backup on empty system")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectNonNvChadTriggersBackup(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if !st.(state).BackupNeeded {
|
||||||
|
t.Fatal("expected BackupNeeded=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanApplyInstallsEverything(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if len(p.Actions) == 0 {
|
||||||
|
t.Fatal("empty plan")
|
||||||
|
}
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sawPacman, sawClone := false, false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" {
|
||||||
|
sawPacman = true
|
||||||
|
}
|
||||||
|
if c[0] == "git" && len(c) >= 2 && c[1] == "clone" {
|
||||||
|
sawClone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawPacman || !sawClone {
|
||||||
|
t.Fatalf("missing steps; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
nvimCfg := filepath.Join(home, ".config", "nvim")
|
||||||
|
chadrc, err := os.ReadFile(filepath.Join(nvimCfg, "lua", "chadrc.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chadrc missing: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(chadrc), `theme = "dracula"`) {
|
||||||
|
t.Fatalf("chadrc theme not applied: %s", chadrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
initLua, _ := os.ReadFile(filepath.Join(nvimCfg, "lua", "plugins", "init.lua"))
|
||||||
|
if strings.Contains(string(initLua), "nvim-lspconfig") {
|
||||||
|
t.Fatalf("nvim-lspconfig not stripped: %s", initLua)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "configs", "lspconfig.lua")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("starter lspconfig.lua should be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"arduino.lua", "dap.lua"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "plugins", name)); err != nil {
|
||||||
|
t.Errorf("asset %s missing: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "mappings.lua")); err != nil {
|
||||||
|
t.Errorf("mappings.lua missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(nvimCfg, "lsp", "clangd.lua")); err != nil {
|
||||||
|
t.Errorf("clangd.lua missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAfterApplyIsEmpty(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
||||||
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
_ = m.Apply(context.Background(), p, nopReporter{})
|
||||||
|
st2, _ := m.Detect(context.Background())
|
||||||
|
p2, _ := m.Plan(context.Background(), st2)
|
||||||
|
if !p2.IsEmpty() {
|
||||||
|
t.Fatalf("second plan not empty: %+v", p2.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownThemeFallsBackToFirst(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
||||||
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua"))
|
||||||
|
if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
|
||||||
|
t.Fatalf("fallback theme not applied: %s", chadrc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
74
internal/modules/packages/packages.go
Normal file
74
internal/modules/packages/packages.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package packages installs a declarative list of pacman packages.
|
||||||
|
package packages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
pkgs []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pkgs []string, pm platform.PackageManager) Module {
|
||||||
|
return Module{pkgs: pkgs, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "packages", Name: "Base packages", Description: "Install base pacman package list."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: "pacman install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
|
||||||
|
}}}, 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 missing := st.(state).Missing; len(missing) > 0 {
|
||||||
|
return fmt.Errorf("packages not installed after apply: %v", missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/modules/packages/packages_test.go
Normal file
113
internal/modules/packages/packages_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package packages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool // key: pkg name
|
||||||
|
}
|
||||||
|
|
||||||
|
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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte(args[1] + " 1.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, errExitOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOneErr struct{}
|
||||||
|
|
||||||
|
func (exitOneErr) Error() string { return "exit status 1" }
|
||||||
|
|
||||||
|
var errExitOne = exitOneErr{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if len(s.Missing) != 2 {
|
||||||
|
t.Fatalf("want 2 missing, got %v", s.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"curl": true, "wget": true}}
|
||||||
|
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if len(st.(state).Missing) != 0 {
|
||||||
|
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
|
||||||
|
m := New([]string{"curl", "wget"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: []string{"curl", "wget"}})
|
||||||
|
if len(p.Actions) != 1 {
|
||||||
|
t.Fatalf("want 1 action, got %d", len(p.Actions))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Actions[0].Description, "curl") {
|
||||||
|
t.Fatalf("desc: %s", p.Actions[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPresentEmpty(t *testing.T) {
|
||||||
|
m := New([]string{"curl"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: nil})
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyShellsOut(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"curl"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawInstall := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
|
||||||
|
sawInstall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawInstall {
|
||||||
|
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAfterApply(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"curl": true}}
|
||||||
|
m := New([]string{"curl"}, platform.NewPacman(r))
|
||||||
|
if err := m.Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
54
internal/modules/renode/assets/arduino-sim.tmpl
Normal file
54
internal/modules/renode/assets/arduino-sim.tmpl
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: arduino-sim <firmware.elf> [platform]"
|
||||||
|
echo ""
|
||||||
|
echo "Launch Renode with a GDB server for debugging firmware."
|
||||||
|
echo "Connect nvim-dap or gdb to localhost:3333"
|
||||||
|
echo ""
|
||||||
|
echo "Platforms:"
|
||||||
|
{{range .Platforms}} echo " {{.Name}} {{.Machine}}"
|
||||||
|
{{end}} exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
ELF="$1"
|
||||||
|
PLATFORM="${2:-{{(index .Platforms 0).Name}}}"
|
||||||
|
|
||||||
|
[[ ! -f "$ELF" ]] && echo "Error: $ELF not found" && exit 1
|
||||||
|
|
||||||
|
case "$PLATFORM" in
|
||||||
|
{{range .Platforms}} {{.Name}})
|
||||||
|
RENODE_PLATFORM="{{.ReplPath}}"
|
||||||
|
MACHINE="{{.Machine}}"
|
||||||
|
;;
|
||||||
|
{{end}} *)
|
||||||
|
echo "Unknown platform: $PLATFORM"
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RESC=$(mktemp /tmp/renode-XXXXXX.resc)
|
||||||
|
cat >"$RESC" <<EOF
|
||||||
|
using sysbus
|
||||||
|
|
||||||
|
mach create "$MACHINE"
|
||||||
|
machine LoadPlatformDescription @$RENODE_PLATFORM
|
||||||
|
|
||||||
|
sysbus LoadELF @$(realpath "$ELF")
|
||||||
|
|
||||||
|
machine StartGdbServer 3333
|
||||||
|
|
||||||
|
showAnalyzer sysbus.uart0 Antmicro.Renode.Analyzers.LoggingUartAnalyzer
|
||||||
|
|
||||||
|
start
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Starting Renode ($MACHINE) with GDB server on :3333"
|
||||||
|
echo "Connect debugger: nvim → <leader>ds or gdb → target remote :3333"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
renode --disable-xwt "$RESC"
|
||||||
|
rm -f "$RESC"
|
||||||
187
internal/modules/renode/renode.go
Normal file
187
internal/modules/renode/renode.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// Package renode installs the Renode emulator (AUR), arm-none-eabi-gdb,
|
||||||
|
// the arduino-sim helper script, and wokwi-cli.
|
||||||
|
package renode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/arduino-sim.tmpl
|
||||||
|
var assetsFS embed.FS
|
||||||
|
|
||||||
|
// Module installs Renode and adjacent emulator tooling.
|
||||||
|
type Module struct {
|
||||||
|
cfg config.RenodeConfig
|
||||||
|
pm platform.PackageManager
|
||||||
|
aur platform.AURHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a renode Module.
|
||||||
|
func New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module {
|
||||||
|
return Module{cfg: cfg, pm: pm, aur: aur}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
RenodeInstalled bool
|
||||||
|
GdbInstalled bool
|
||||||
|
SimScriptCurrent bool
|
||||||
|
WokwiInstalled bool
|
||||||
|
RenderedSim []byte
|
||||||
|
SimPath string
|
||||||
|
WokwiPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta returns the module metadata.
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "renode", Name: "Renode emulator", Description: "Install Renode, ARM GDB, arduino-sim helper, and Wokwi CLI."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func binPath() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".local", "bin"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) renderSim() ([]byte, error) {
|
||||||
|
tmplBytes, err := assetsFS.ReadFile("assets/arduino-sim.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("sim").Parse(string(tmplBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, m.cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect inspects the system and returns current state.
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
bin, err := binPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := state{
|
||||||
|
SimPath: filepath.Join(bin, "arduino-sim"),
|
||||||
|
WokwiPath: filepath.Join(bin, "wokwi-cli"),
|
||||||
|
}
|
||||||
|
if ok, err := m.pm.IsInstalled(ctx, "renode-bin"); err == nil && ok {
|
||||||
|
s.RenodeInstalled = true
|
||||||
|
}
|
||||||
|
if ok, err := m.pm.IsInstalled(ctx, "arm-none-eabi-gdb"); err == nil && ok {
|
||||||
|
s.GdbInstalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := m.renderSim()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.RenderedSim = rendered
|
||||||
|
if cur, err := os.ReadFile(s.SimPath); err == nil && bytes.Equal(cur, rendered) {
|
||||||
|
s.SimScriptCurrent = true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(s.WokwiPath); err == nil {
|
||||||
|
s.WokwiInstalled = true
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan computes the set of actions needed to converge to desired state.
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
|
||||||
|
if !s.RenodeInstalled {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "ensure yay installed",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
|
||||||
|
})
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "yay install renode-bin",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.aur.Install(ctx, []string{"renode-bin"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.GdbInstalled {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "pacman install arm-none-eabi-gdb",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"arm-none-eabi-gdb"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.SimScriptCurrent {
|
||||||
|
rendered := append([]byte{}, s.RenderedSim...)
|
||||||
|
path := s.SimPath
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "write arduino-sim helper",
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
return platform.WriteUserFile(path, rendered, 0o755)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.WokwiInstalled {
|
||||||
|
url := m.cfg.WokwiURL
|
||||||
|
dst := s.WokwiPath
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "download wokwi-cli " + m.cfg.WokwiVersion,
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.Download(ctx, url, dst, 0o755)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply executes the planned actions in order.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify re-Detects and errors if state hasn't converged.
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.RenodeInstalled {
|
||||||
|
return fmt.Errorf("renode-bin not installed")
|
||||||
|
}
|
||||||
|
if !s.GdbInstalled {
|
||||||
|
return fmt.Errorf("arm-none-eabi-gdb not installed")
|
||||||
|
}
|
||||||
|
if !s.SimScriptCurrent {
|
||||||
|
return fmt.Errorf("arduino-sim script drift")
|
||||||
|
}
|
||||||
|
if !s.WokwiInstalled {
|
||||||
|
return fmt.Errorf("wokwi-cli missing")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
145
internal/modules/renode/renode_test.go
Normal file
145
internal/modules/renode/renode_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package renode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls, outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errExit1
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOne struct{}
|
||||||
|
|
||||||
|
func (exitOne) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errExit1 = exitOne{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func sampleCfg(url string) config.RenodeConfig {
|
||||||
|
return config.RenodeConfig{
|
||||||
|
WokwiVersion: "0.28.1",
|
||||||
|
WokwiURL: url,
|
||||||
|
Platforms: []config.RenodePlatform{
|
||||||
|
{Name: "stm32f4", ReplPath: "platforms/boards/stm32f4_discovery.repl", Machine: "STM32F4_Discovery"},
|
||||||
|
{Name: "rp2040", ReplPath: "platforms/cpus/rp2040.repl", Machine: "RP2040"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectNothingInstalled(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if s.RenodeInstalled || s.GdbInstalled || s.SimScriptCurrent || s.WokwiInstalled {
|
||||||
|
t.Fatalf("want all absent, got %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectAllInstalled(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("x"), 0o755)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"renode-bin": true, "arm-none-eabi-gdb": true}}
|
||||||
|
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
// Apply once to seed the sim script, then re-detect.
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
_ = m.Apply(context.Background(), p, nopReporter{})
|
||||||
|
st2, _ := m.Detect(context.Background())
|
||||||
|
p2, _ := m.Plan(context.Background(), st2)
|
||||||
|
if !p2.IsEmpty() {
|
||||||
|
t.Fatalf("second plan not empty: %+v", p2.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Write([]byte("wokwi-binary"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yay": true}}
|
||||||
|
m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// arduino-sim rendered with both platforms
|
||||||
|
sim, err := os.ReadFile(filepath.Join(home, ".local", "bin", "arduino-sim"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("arduino-sim not written: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(sim), "stm32f4)") {
|
||||||
|
t.Errorf("stm32f4 branch missing: %s", sim)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(sim), "rp2040)") {
|
||||||
|
t.Errorf("rp2040 branch missing: %s", sim)
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(filepath.Join(home, ".local", "bin", "arduino-sim"))
|
||||||
|
if info.Mode().Perm() != 0o755 {
|
||||||
|
t.Errorf("arduino-sim mode %v", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wokwi-cli downloaded
|
||||||
|
wokwi, err := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wokwi-cli missing: %v", err)
|
||||||
|
}
|
||||||
|
if string(wokwi) != "wokwi-binary" {
|
||||||
|
t.Errorf("wokwi-cli content %q", wokwi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// yay install renode-bin called
|
||||||
|
sawYay := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if c[0] == "yay" && len(c) >= 2 && c[1] == "-S" {
|
||||||
|
sawYay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawYay {
|
||||||
|
t.Fatalf("yay install missing; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
90
internal/modules/shellconfig/shellconfig.go
Normal file
90
internal/modules/shellconfig/shellconfig.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Package shellconfig writes marker-delimited blocks into rc files.
|
||||||
|
package shellconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct{ cfg config.ShellConfigSection }
|
||||||
|
|
||||||
|
func New(cfg config.ShellConfigSection) Module { return Module{cfg: cfg} }
|
||||||
|
|
||||||
|
type state struct{ Missing []config.ShellConfigBlock }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "shellconfig", Name: "Shell config blocks", Description: "Write archduino blocks into rc files."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFile(name string) (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []config.ShellConfigBlock
|
||||||
|
for _, b := range m.cfg.Blocks {
|
||||||
|
p, err := resolveFile(b.File)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
has, err := platform.HasBlock(p, b.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
missing = append(missing, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
for _, b := range s.Missing {
|
||||||
|
b := b
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: fmt.Sprintf("write block %s into %s", b.Name, b.File),
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
p, err := resolveFile(b.File)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.EnsureBlock(p, b.Name, b.Content)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, 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 miss := st.(state).Missing; len(miss) > 0 {
|
||||||
|
return fmt.Errorf("shell blocks still missing: %d", len(miss))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
58
internal/modules/shellconfig/shellconfig_test.go
Normal file
58
internal/modules/shellconfig/shellconfig_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package shellconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestShellConfigWritesBlocks(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
m := New(config.ShellConfigSection{
|
||||||
|
Blocks: []config.ShellConfigBlock{
|
||||||
|
{Name: "path", File: ".zshrc", Content: `export PATH="$HOME/.local/bin:$PATH"`},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(home, ".zshrc"))
|
||||||
|
if !strings.Contains(string(data), ">>> path >>>") {
|
||||||
|
t.Fatalf("block not present: %q", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) {
|
||||||
|
t.Fatalf("content missing: %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellConfigIdempotent(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
cfg := config.ShellConfigSection{Blocks: []config.ShellConfigBlock{
|
||||||
|
{Name: "x", File: ".zshrc", Content: "echo x"},
|
||||||
|
}}
|
||||||
|
m := New(cfg)
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
_ = m.Apply(context.Background(), p, nop{})
|
||||||
|
st2, _ := m.Detect(context.Background())
|
||||||
|
p2, _ := m.Plan(context.Background(), st2)
|
||||||
|
if !p2.IsEmpty() {
|
||||||
|
t.Fatalf("second plan not empty: %+v", p2.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
74
internal/modules/starship/starship.go
Normal file
74
internal/modules/starship/starship.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package starship installs a declarative list of pacman packages.
|
||||||
|
package starship
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
pkgs []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pkgs []string, pm platform.PackageManager) Module {
|
||||||
|
return Module{pkgs: pkgs, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "starship", Name: "Starship prompt", Description: "Install starship prompt."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: "pacman install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
|
||||||
|
}}}, 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 missing := st.(state).Missing; len(missing) > 0 {
|
||||||
|
return fmt.Errorf("packages not installed after apply: %v", missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/modules/starship/starship_test.go
Normal file
113
internal/modules/starship/starship_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package starship
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool // key: pkg name
|
||||||
|
}
|
||||||
|
|
||||||
|
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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte(args[1] + " 1.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, errExitOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOneErr struct{}
|
||||||
|
|
||||||
|
func (exitOneErr) Error() string { return "exit status 1" }
|
||||||
|
|
||||||
|
var errExitOne = exitOneErr{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"starship"}, platform.NewPacman(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if len(s.Missing) != 1 {
|
||||||
|
t.Fatalf("want 1 missing, got %v", s.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"starship": true}}
|
||||||
|
m := New([]string{"starship"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if len(st.(state).Missing) != 0 {
|
||||||
|
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
|
||||||
|
m := New([]string{"starship"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: []string{"starship"}})
|
||||||
|
if len(p.Actions) != 1 {
|
||||||
|
t.Fatalf("want 1 action, got %d", len(p.Actions))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Actions[0].Description, "starship") {
|
||||||
|
t.Fatalf("desc: %s", p.Actions[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPresentEmpty(t *testing.T) {
|
||||||
|
m := New([]string{"starship"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: nil})
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyShellsOut(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"starship"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawInstall := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
|
||||||
|
sawInstall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawInstall {
|
||||||
|
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAfterApply(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"starship": true}}
|
||||||
|
m := New([]string{"starship"}, platform.NewPacman(r))
|
||||||
|
if err := m.Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
115
internal/modules/themes/themes.go
Normal file
115
internal/modules/themes/themes.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Package themes installs archduino theme files into ~/.config/archduino/themes.
|
||||||
|
package themes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct{ cfg config.ThemesConfig }
|
||||||
|
|
||||||
|
func New(cfg config.ThemesConfig) Module { return Module{cfg: cfg} }
|
||||||
|
|
||||||
|
type filePair struct{ src, dst string }
|
||||||
|
type state struct{ Missing []filePair }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "themes", Name: "Colorscheme themes", Description: "Install archduino themes."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func destRoot() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".config", "archduino", "themes"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
dst, err := destRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var missing []filePair
|
||||||
|
err = filepath.WalkDir(m.cfg.Source, func(p string, d fs.DirEntry, werr error) error {
|
||||||
|
if werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(m.cfg.Source, p)
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
a, errA := os.ReadFile(p)
|
||||||
|
if errA != nil {
|
||||||
|
return errA
|
||||||
|
}
|
||||||
|
b, errB := os.ReadFile(target)
|
||||||
|
if errB != nil || !bytes.Equal(a, b) {
|
||||||
|
missing = append(missing, filePair{src: p, dst: target})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return state{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
pairs := append([]filePair{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: fmt.Sprintf("copy %d theme files", len(pairs)),
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
for _, fp := range pairs {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fp.dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(fp.src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fp.dst, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}}, 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 miss := st.(state).Missing; len(miss) > 0 {
|
||||||
|
return fmt.Errorf("themes still missing: %d files", len(miss))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
66
internal/modules/themes/themes_test.go
Normal file
66
internal/modules/themes/themes_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package themes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestThemesCopiesFiles(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "one.toml"), []byte("A"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Join(src, "sub"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(src, "sub", "two.toml"), []byte("B"), 0o644)
|
||||||
|
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
m := New(config.ThemesConfig{Source: src})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "one.toml"))
|
||||||
|
if string(got) != "A" {
|
||||||
|
t.Fatalf("one.toml: %q", got)
|
||||||
|
}
|
||||||
|
got, _ = os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "sub", "two.toml"))
|
||||||
|
if string(got) != "B" {
|
||||||
|
t.Fatalf("two.toml: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThemesIdempotent(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(src, "x.toml"), []byte("X"), 0o644)
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
m := New(config.ThemesConfig{Source: src})
|
||||||
|
_ = applyOnce(t, m)
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatalf("second plan not empty: %+v", p.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOnce(t *testing.T, m Module) error {
|
||||||
|
t.Helper()
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
return m.Apply(context.Background(), p, nop{})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
74
internal/modules/yazi/yazi.go
Normal file
74
internal/modules/yazi/yazi.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package yazi installs a declarative list of pacman packages.
|
||||||
|
package yazi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
pkgs []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pkgs []string, pm platform.PackageManager) Module {
|
||||||
|
return Module{pkgs: pkgs, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "yazi", Name: "Yazi file manager", Description: "Install yazi."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: "pacman install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
|
||||||
|
}}}, 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 missing := st.(state).Missing; len(missing) > 0 {
|
||||||
|
return fmt.Errorf("packages not installed after apply: %v", missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/modules/yazi/yazi_test.go
Normal file
113
internal/modules/yazi/yazi_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package yazi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool // key: pkg name
|
||||||
|
}
|
||||||
|
|
||||||
|
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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte(args[1] + " 1.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, errExitOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOneErr struct{}
|
||||||
|
|
||||||
|
func (exitOneErr) Error() string { return "exit status 1" }
|
||||||
|
|
||||||
|
var errExitOne = exitOneErr{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"yazi"}, platform.NewPacman(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if len(s.Missing) != 1 {
|
||||||
|
t.Fatalf("want 1 missing, got %v", s.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
|
||||||
|
m := New([]string{"yazi"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if len(st.(state).Missing) != 0 {
|
||||||
|
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
|
||||||
|
m := New([]string{"yazi"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: []string{"yazi"}})
|
||||||
|
if len(p.Actions) != 1 {
|
||||||
|
t.Fatalf("want 1 action, got %d", len(p.Actions))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Actions[0].Description, "yazi") {
|
||||||
|
t.Fatalf("desc: %s", p.Actions[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPresentEmpty(t *testing.T) {
|
||||||
|
m := New([]string{"yazi"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: nil})
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyShellsOut(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"yazi"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawInstall := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
|
||||||
|
sawInstall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawInstall {
|
||||||
|
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAfterApply(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
|
||||||
|
m := New([]string{"yazi"}, platform.NewPacman(r))
|
||||||
|
if err := m.Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
110
internal/modules/zellij/zellij.go
Normal file
110
internal/modules/zellij/zellij.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Package zellij installs zellij and optionally writes a remote config.
|
||||||
|
package zellij
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg config.ZellijConfig
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ZellijConfig, pm platform.PackageManager) Module {
|
||||||
|
return Module{cfg: cfg, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
Installed bool
|
||||||
|
ConfigOK bool
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "zellij", Name: "Zellij terminal multiplexer", Description: "Install zellij and config."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPath() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".config", "zellij", "config.kdl"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var s state
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, "zellij")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.Installed = ok
|
||||||
|
p, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ConfigPath = p
|
||||||
|
if m.cfg.ConfigURL == "" {
|
||||||
|
s.ConfigOK = true
|
||||||
|
} else if _, err := os.Stat(p); err == nil {
|
||||||
|
s.ConfigOK = true
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
if !s.Installed {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "pacman install zellij",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zellij"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.ConfigOK {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "download zellij config",
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.ConfigPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.Download(ctx, m.cfg.ConfigURL, s.ConfigPath, 0o644)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, 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
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.Installed {
|
||||||
|
return fmt.Errorf("zellij not installed")
|
||||||
|
}
|
||||||
|
if !s.ConfigOK {
|
||||||
|
return fmt.Errorf("zellij config missing")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
84
internal/modules/zellij/zellij_test.go
Normal file
84
internal/modules/zellij/zellij_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package zellij
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type eo struct{}
|
||||||
|
|
||||||
|
func (eo) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errOne = eo{}
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestZellijPlanAppliesInstallAndConfig(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Write([]byte("keybinds {}\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.ZellijConfig{ConfigURL: srv.URL}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(home, ".config", "zellij", "config.kdl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config not written: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "keybinds {}\n" {
|
||||||
|
t.Fatalf("unexpected content %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZellijInstalledPresentEmptyPlan(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"zellij": true}}
|
||||||
|
m := New(config.ZellijConfig{ConfigURL: ""}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatalf("want empty plan, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
132
internal/modules/zsh/zsh.go
Normal file
132
internal/modules/zsh/zsh.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Package zsh installs zsh, oh-my-zsh, and configured plugins.
|
||||||
|
package zsh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg config.ZshConfig
|
||||||
|
pm platform.PackageManager
|
||||||
|
git platform.GitClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ZshConfig, pm platform.PackageManager, git platform.GitClient) Module {
|
||||||
|
return Module{cfg: cfg, pm: pm, git: git}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
ZshInstalled bool
|
||||||
|
OMZInstalled bool
|
||||||
|
MissingPlugins []config.ZshPlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
func omzHome() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".oh-my-zsh"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "zsh", Name: "Zsh + oh-my-zsh", Description: "Install zsh, oh-my-zsh, and plugins."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var s state
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, "zsh")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ZshInstalled = ok
|
||||||
|
omz, err := omzHome()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(omz); err == nil {
|
||||||
|
s.OMZInstalled = true
|
||||||
|
}
|
||||||
|
for _, p := range m.cfg.Plugins {
|
||||||
|
path := filepath.Join(omz, "custom", "plugins", p.Name, ".git")
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
s.MissingPlugins = append(s.MissingPlugins, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
if !s.ZshInstalled {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "pacman install zsh",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zsh"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.OMZInstalled {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "clone oh-my-zsh from " + m.cfg.OhMyZshRepo,
|
||||||
|
Kind: module.ActionGitClone,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
omz, err := omzHome()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.git.EnsureRepo(ctx, m.cfg.OhMyZshRepo, omz)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, p := range s.MissingPlugins {
|
||||||
|
p := p // capture
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "clone plugin " + p.Name,
|
||||||
|
Kind: module.ActionGitClone,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
omz, err := omzHome()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.git.EnsureRepo(ctx, p.Repo, filepath.Join(omz, "custom", "plugins", p.Name))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, 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
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.ZshInstalled {
|
||||||
|
return fmt.Errorf("zsh not installed")
|
||||||
|
}
|
||||||
|
if !s.OMZInstalled {
|
||||||
|
return fmt.Errorf("oh-my-zsh not installed")
|
||||||
|
}
|
||||||
|
if len(s.MissingPlugins) > 0 {
|
||||||
|
return fmt.Errorf("plugins missing: %v", s.MissingPlugins)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
117
internal/modules/zsh/zsh_test.go
Normal file
117
internal/modules/zsh/zsh_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package zsh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type eo struct{}
|
||||||
|
|
||||||
|
func (eo) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errOne = eo{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestZshDetectAbsent(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.ZshConfig{
|
||||||
|
OhMyZshRepo: "https://x/omz.git",
|
||||||
|
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
|
||||||
|
}, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
s := st.(state)
|
||||||
|
if s.ZshInstalled {
|
||||||
|
t.Error("want zsh absent")
|
||||||
|
}
|
||||||
|
if s.OMZInstalled {
|
||||||
|
t.Error("want omz absent")
|
||||||
|
}
|
||||||
|
if len(s.MissingPlugins) != 1 {
|
||||||
|
t.Errorf("want 1 plugin missing, got %v", s.MissingPlugins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZshDetectAllPresent(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0o755)
|
||||||
|
os.MkdirAll(filepath.Join(home, ".oh-my-zsh", "custom", "plugins", "zsh-autosuggestions", ".git"), 0o755)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"zsh": true}}
|
||||||
|
m := New(config.ZshConfig{
|
||||||
|
OhMyZshRepo: "https://x/omz.git",
|
||||||
|
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
|
||||||
|
}, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
s := st.(state)
|
||||||
|
if !s.ZshInstalled || !s.OMZInstalled || len(s.MissingPlugins) != 0 {
|
||||||
|
t.Fatalf("want all present, got %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZshPlanApplyInstallsEverything(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.ZshConfig{
|
||||||
|
OhMyZshRepo: "https://x/omz.git",
|
||||||
|
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
|
||||||
|
}, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if len(p.Actions) == 0 {
|
||||||
|
t.Fatal("want non-empty plan")
|
||||||
|
}
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawPacman, sawOMZ, sawPlugin := false, false, false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" {
|
||||||
|
sawPacman = true
|
||||||
|
}
|
||||||
|
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/omz.git" {
|
||||||
|
sawOMZ = true
|
||||||
|
}
|
||||||
|
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/a.git" {
|
||||||
|
sawPlugin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawPacman || !sawOMZ || !sawPlugin {
|
||||||
|
t.Fatalf("missing step; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
57
internal/platform/aur.go
Normal file
57
internal/platform/aur.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AURHelper abstracts an AUR front-end (currently only yay).
|
||||||
|
type AURHelper interface {
|
||||||
|
EnsureInstalled(ctx context.Context) error
|
||||||
|
Install(ctx context.Context, pkgs []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Yay struct {
|
||||||
|
Runner Runner
|
||||||
|
pm *Pacman
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYay(r Runner) *Yay { return &Yay{Runner: r, pm: NewPacman(r)} }
|
||||||
|
|
||||||
|
func (y *Yay) EnsureInstalled(ctx context.Context) error {
|
||||||
|
ok, _ := y.pm.IsInstalled(ctx, "yay")
|
||||||
|
if ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Bootstrap: install build deps, clone AUR yay, makepkg -si.
|
||||||
|
if err := y.pm.Install(ctx, []string{"base-devel", "git"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := os.MkdirTemp("", "archduino-yay-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
clone := filepath.Join(tmp, "yay")
|
||||||
|
if err := y.Runner.Run(ctx, "git", "clone", "https://aur.archlinux.org/yay.git", clone); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// `makepkg -si --noconfirm` needs to cd into the cloned dir. os/exec does
|
||||||
|
// not have a per-command cwd without exec.Cmd; our Runner interface does
|
||||||
|
// not expose that, so we invoke via sh -c with a pushd-style prefix.
|
||||||
|
return y.Runner.Run(ctx, "sh", "-c", "cd "+shellQuote(clone)+" && makepkg -si --noconfirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Yay) Install(ctx context.Context, pkgs []string) error {
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...)
|
||||||
|
return y.Runner.Run(ctx, "yay", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
89
internal/platform/aur_test.go
Normal file
89
internal/platform/aur_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type aurFakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
// yayInstalled toggles what `pacman -Q yay` returns.
|
||||||
|
yayInstalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *aurFakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *aurFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" && args[1] == "yay" {
|
||||||
|
if f.yayInstalled {
|
||||||
|
return []byte("yay 12.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, &fakeExitErr{}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeExitErr struct{}
|
||||||
|
|
||||||
|
func (fakeExitErr) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
func TestAUREnsureInstalledSkipsWhenYayPresent(t *testing.T) {
|
||||||
|
r := &aurFakeRunner{yayInstalled: true}
|
||||||
|
y := NewYay(r)
|
||||||
|
if err := y.EnsureInstalled(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.runCalls) != 0 {
|
||||||
|
t.Fatalf("no installs expected, got %v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAUREnsureInstalledBootstrapsYay(t *testing.T) {
|
||||||
|
r := &aurFakeRunner{yayInstalled: false}
|
||||||
|
y := NewYay(r)
|
||||||
|
if err := y.EnsureInstalled(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Expect: pacman install base-devel+git, git clone yay, makepkg -si in that dir.
|
||||||
|
sawPacman := false
|
||||||
|
sawClone := false
|
||||||
|
sawMakepkg := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 2 && c[0] == "sudo" && c[1] == "pacman" {
|
||||||
|
sawPacman = true
|
||||||
|
}
|
||||||
|
if len(c) >= 2 && c[0] == "git" && c[1] == "clone" {
|
||||||
|
sawClone = true
|
||||||
|
}
|
||||||
|
if len(c) >= 3 && c[0] == "sh" && c[1] == "-c" && strings.Contains(c[2], "makepkg") {
|
||||||
|
sawMakepkg = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawPacman || !sawClone || !sawMakepkg {
|
||||||
|
t.Fatalf("missing bootstrap step; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAURInstall(t *testing.T) {
|
||||||
|
r := &aurFakeRunner{yayInstalled: true}
|
||||||
|
y := NewYay(r)
|
||||||
|
if err := y.Install(context.Background(), []string{"ttf-jetbrains-mono-nerd"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.runCalls) == 0 {
|
||||||
|
t.Fatal("expected yay install call")
|
||||||
|
}
|
||||||
|
c := r.runCalls[len(r.runCalls)-1]
|
||||||
|
want := []string{"yay", "-S", "--needed", "--noconfirm", "ttf-jetbrains-mono-nerd"}
|
||||||
|
for i, w := range want {
|
||||||
|
if i >= len(c) || c[i] != w {
|
||||||
|
t.Fatalf("call = %v, want %v", c, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/platform/download.go
Normal file
42
internal/platform/download.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Download fetches url into destPath atomically (temp file + rename) with
|
||||||
|
// the given file mode. Any non-2xx response returns an error and leaves
|
||||||
|
// no file behind.
|
||||||
|
func Download(ctx context.Context, url, destPath string, mode os.FileMode) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("download %s: http %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
tmp := destPath + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, destPath)
|
||||||
|
}
|
||||||
45
internal/platform/download_test.go
Normal file
45
internal/platform/download_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadHappyPath(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("payload"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "out.bin")
|
||||||
|
if err := Download(context.Background(), srv.URL, dest, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
if string(got) != "payload" {
|
||||||
|
t.Fatalf("got %q want payload", got)
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(dest)
|
||||||
|
if info.Mode().Perm() != 0o755 {
|
||||||
|
t.Fatalf("mode=%v want 0755", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownload404Errors(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "nope", http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "out.bin")
|
||||||
|
if err := Download(context.Background(), srv.URL, dest, 0o644); err == nil {
|
||||||
|
t.Fatal("want error on 404")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("dest should not exist after failed download")
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/platform/extract.go
Normal file
114
internal/platform/extract.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func safeJoin(dest, name string) (string, error) {
|
||||||
|
p := filepath.Join(dest, name)
|
||||||
|
cleanDest := filepath.Clean(dest) + string(os.PathSeparator)
|
||||||
|
if !strings.HasPrefix(filepath.Clean(p)+string(os.PathSeparator), cleanDest) {
|
||||||
|
return "", fmt.Errorf("unsafe path %q escapes %q", name, dest)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractTarGz(ctx context.Context, archivePath, destDir string) error {
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
gz, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := safeJoin(destDir, h.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch h.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, os.FileMode(h.Mode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(w, tr); err != nil {
|
||||||
|
w.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractZip(ctx context.Context, archivePath, destDir string) error {
|
||||||
|
zr, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := safeJoin(destDir, zf.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if zf.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(target, zf.Mode()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode())
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, rc)
|
||||||
|
rc.Close()
|
||||||
|
w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
80
internal/platform/extract_test.go
Normal file
80
internal/platform/extract_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeTarGz(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "a.tar.gz")
|
||||||
|
f, err := os.Create(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gz := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for name, body := range entries {
|
||||||
|
tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))})
|
||||||
|
tw.Write([]byte(body))
|
||||||
|
}
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
f.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeZip(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "a.zip")
|
||||||
|
f, _ := os.Create(p)
|
||||||
|
zw := zip.NewWriter(f)
|
||||||
|
for name, body := range entries {
|
||||||
|
w, _ := zw.Create(name)
|
||||||
|
w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
f.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTarGz(t *testing.T) {
|
||||||
|
src := makeTarGz(t, map[string]string{"hello.txt": "world", "sub/x.txt": "y"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractTarGz(context.Background(), src, dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dest, "hello.txt"))
|
||||||
|
if string(got) != "world" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
got, _ = os.ReadFile(filepath.Join(dest, "sub", "x.txt"))
|
||||||
|
if string(got) != "y" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractZip(t *testing.T) {
|
||||||
|
src := makeZip(t, map[string]string{"a.txt": "A"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractZip(context.Background(), src, dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dest, "a.txt"))
|
||||||
|
if string(got) != "A" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsPathTraversal(t *testing.T) {
|
||||||
|
src := makeTarGz(t, map[string]string{"../evil.txt": "x"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractTarGz(context.Background(), src, dest); err == nil {
|
||||||
|
t.Fatal("want error on path traversal")
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/platform/git.go
Normal file
23
internal/platform/git.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitClient wraps `git` via a Runner.
|
||||||
|
type GitClient struct{ Runner Runner }
|
||||||
|
|
||||||
|
// Clone always runs `git clone --depth=1 url dest`.
|
||||||
|
func (g GitClient) Clone(ctx context.Context, url, dest string) error {
|
||||||
|
return g.Runner.Run(ctx, "git", "clone", "--depth=1", url, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRepo clones url into dest if dest/.git is missing; otherwise noop.
|
||||||
|
func (g GitClient) EnsureRepo(ctx context.Context, url, dest string) error {
|
||||||
|
if _, err := os.Stat(filepath.Join(dest, ".git")); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return g.Clone(ctx, url, dest)
|
||||||
|
}
|
||||||
55
internal/platform/git_test.go
Normal file
55
internal/platform/git_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gitFakeRunner struct {
|
||||||
|
calls [][]string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gitFakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.calls = append(f.calls, append([]string{name}, args...))
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
func (f *gitFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitEnsureRepoClonesWhenMissing(t *testing.T) {
|
||||||
|
r := &gitFakeRunner{}
|
||||||
|
g := GitClient{Runner: r}
|
||||||
|
dest := filepath.Join(t.TempDir(), "repo")
|
||||||
|
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.calls) != 1 {
|
||||||
|
t.Fatalf("want 1 call, got %d: %v", len(r.calls), r.calls)
|
||||||
|
}
|
||||||
|
got := r.calls[0]
|
||||||
|
want := []string{"git", "clone", "--depth=1", "https://x/y.git", dest}
|
||||||
|
for i := range want {
|
||||||
|
if i >= len(got) || got[i] != want[i] {
|
||||||
|
t.Fatalf("call = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitEnsureRepoSkipsWhenPresent(t *testing.T) {
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dest, ".git"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := &gitFakeRunner{}
|
||||||
|
g := GitClient{Runner: r}
|
||||||
|
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.calls) != 0 {
|
||||||
|
t.Fatalf("expected no calls, got %v", r.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
internal/platform/pacman.go
Normal file
85
internal/platform/pacman.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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...)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if msg == "" {
|
||||||
|
return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 -Syu --noconfirm` (full sync + system upgrade).
|
||||||
|
// On Arch, a bare `pacman -Sy` followed by an install is the partial-upgrade
|
||||||
|
// anti-pattern that can break the system when a dep's ABI changes, so we
|
||||||
|
// always pair the sync with the upgrade.
|
||||||
|
func (p Pacman) Update(ctx context.Context) error {
|
||||||
|
return p.Runner.Run(ctx, "sudo", "pacman", "-Syu", "--noconfirm")
|
||||||
|
}
|
||||||
87
internal/platform/pacman_test.go
Normal file
87
internal/platform/pacman_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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", "-Syu", "--noconfirm"}
|
||||||
|
if !reflect.DeepEqual(r.runCalls[0], want) {
|
||||||
|
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/platform/pkgmgr.go
Normal file
19
internal/platform/pkgmgr.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
63
internal/platform/platform.go
Normal file
63
internal/platform/platform.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
45
internal/platform/platform_test.go
Normal file
45
internal/platform/platform_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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.
|
||||||
|
}
|
||||||
78
internal/platform/textblock.go
Normal file
78
internal/platform/textblock.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func blockMarkers(marker string) (string, string) {
|
||||||
|
return fmt.Sprintf("# >>> %s >>>", marker), fmt.Sprintf("# <<< %s <<<", marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBlock writes a marker-delimited block into path. If the file does
|
||||||
|
// not exist it is created. If a block with the same marker already exists
|
||||||
|
// it is replaced in place. Surrounding content is preserved.
|
||||||
|
func EnsureBlock(path, marker, content string) error {
|
||||||
|
start, end := blockMarkers(marker)
|
||||||
|
existing, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
block := start + "\n" + strings.TrimRight(content, "\n") + "\n" + end + "\n"
|
||||||
|
|
||||||
|
text := string(existing)
|
||||||
|
si := strings.Index(text, start)
|
||||||
|
ei := strings.Index(text, end)
|
||||||
|
if si >= 0 && ei > si {
|
||||||
|
endPos := ei + len(end)
|
||||||
|
if endPos < len(text) && text[endPos] == '\n' {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
text = text[:si] + block + text[endPos:]
|
||||||
|
} else {
|
||||||
|
if text != "" && !strings.HasSuffix(text, "\n") {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += block
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(text), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasBlock reports whether path contains a block with the given marker.
|
||||||
|
// A missing file yields (false, nil).
|
||||||
|
func HasBlock(path, marker string) (bool, error) {
|
||||||
|
start, _ := blockMarkers(marker)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.Contains(string(data), start), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveBlock deletes a marker-delimited block if present.
|
||||||
|
func RemoveBlock(path, marker string) error {
|
||||||
|
start, end := blockMarkers(marker)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
si := strings.Index(text, start)
|
||||||
|
ei := strings.Index(text, end)
|
||||||
|
if si < 0 || ei <= si {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
endPos := ei + len(end)
|
||||||
|
if endPos < len(text) && text[endPos] == '\n' {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
text = text[:si] + text[endPos:]
|
||||||
|
return os.WriteFile(path, []byte(text), 0o644)
|
||||||
|
}
|
||||||
88
internal/platform/textblock_test.go
Normal file
88
internal/platform/textblock_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureBlockInsertsIntoMissingFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
if err := EnsureBlock(p, "archduino", "export FOO=1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
want := "# >>> archduino >>>\nexport FOO=1\n# <<< archduino <<<\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBlockReplacesExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
initial := "alias ll='ls -l'\n# >>> archduino >>>\nold content\n# <<< archduino <<<\necho tail\n"
|
||||||
|
if err := os.WriteFile(p, []byte(initial), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := EnsureBlock(p, "archduino", "new content"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
want := "alias ll='ls -l'\n# >>> archduino >>>\nnew content\n# <<< archduino <<<\necho tail\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBlockIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
before, _ := os.ReadFile(p)
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
after, _ := os.ReadFile(p)
|
||||||
|
if string(before) != string(after) {
|
||||||
|
t.Fatalf("not idempotent: %q vs %q", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasBlockTrueFalse(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
has, err := HasBlock(p, "a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
t.Fatal("missing file should report has=false")
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(p, []byte("no blocks here\n"), 0o644)
|
||||||
|
has, _ = HasBlock(p, "a")
|
||||||
|
if has {
|
||||||
|
t.Fatal("unblocked file should report has=false")
|
||||||
|
}
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
has, _ = HasBlock(p, "a")
|
||||||
|
if !has {
|
||||||
|
t.Fatal("blocked file should report has=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveBlock(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
_ = os.WriteFile(p, []byte("head\n# >>> a >>>\nmid\n# <<< a <<<\ntail\n"), 0o644)
|
||||||
|
if err := RemoveBlock(p, "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
if strings.Contains(string(got), ">>> a >>>") {
|
||||||
|
t.Fatalf("block not removed: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), "head") || !strings.Contains(string(got), "tail") {
|
||||||
|
t.Fatalf("surrounding content lost: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/platform/writefile.go
Normal file
25
internal/platform/writefile.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteUserFile writes data to path atomically (tmp + rename) with the
|
||||||
|
// given mode, creating any missing parent directories (mode 0755).
|
||||||
|
// Intended for user-owned destinations under $HOME — no sudo.
|
||||||
|
func WriteUserFile(path string, data []byte, mode os.FileMode) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
54
internal/platform/writefile_test.go
Normal file
54
internal/platform/writefile_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteUserFileCreatesParent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "a", "b", "c.txt")
|
||||||
|
if err := WriteUserFile(dest, []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
if string(got) != "hi" {
|
||||||
|
t.Fatalf("content %q", got)
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(dest)
|
||||||
|
if info.Mode().Perm() != 0o644 {
|
||||||
|
t.Fatalf("mode %v", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteUserFileOverwritesAtomically(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "x")
|
||||||
|
if err := WriteUserFile(dest, []byte("one"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := WriteUserFile(dest, []byte("two"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
if string(got) != "two" {
|
||||||
|
t.Fatalf("got %q want two", got)
|
||||||
|
}
|
||||||
|
// No .tmp sibling should be left behind.
|
||||||
|
if _, err := os.Stat(dest + ".tmp"); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf(".tmp left behind: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteUserFileExecMode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "bin")
|
||||||
|
if err := WriteUserFile(dest, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(dest)
|
||||||
|
if info.Mode().Perm() != 0o755 {
|
||||||
|
t.Fatalf("mode %v want 0755", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/privilege/sudo.go
Normal file
82
internal/privilege/sudo.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Package privilege manages sudo timestamp acquisition and keepalive so
|
||||||
|
// a long-running installer only prompts once.
|
||||||
|
package privilege
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"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")
|
||||||
|
// Connect to the real terminal so sudo can prompt for the password.
|
||||||
|
// exec.Cmd.Stdin defaults to /dev/null, not the parent process's stdin.
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
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
|
||||||
|
}
|
||||||
65
internal/privilege/sudo_test.go
Normal file
65
internal/privilege/sudo_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
109
internal/state/state.go
Normal file
109
internal/state/state.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
63
internal/state/state_test.go
Normal file
63
internal/state/state_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/theming/assets/gruvbox/lazygit.yml
Normal file
13
internal/theming/assets/gruvbox/lazygit.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gui:
|
||||||
|
theme:
|
||||||
|
activeBorderColor:
|
||||||
|
- "#fabd2f"
|
||||||
|
- bold
|
||||||
|
inactiveBorderColor:
|
||||||
|
- "#665c54"
|
||||||
|
selectedLineBgColor:
|
||||||
|
- "#3c3836"
|
||||||
|
cherryPickedCommitFgColor:
|
||||||
|
- "#b8bb26"
|
||||||
|
defaultFgColor:
|
||||||
|
- "#ebdbb2"
|
||||||
26
internal/theming/assets/gruvbox/starship.toml
Normal file
26
internal/theming/assets/gruvbox/starship.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
palette = "gruvbox_dark"
|
||||||
|
format = """$directory$git_branch$git_status$character"""
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
style = "bold fg:bright-yellow"
|
||||||
|
truncation_length = 3
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
style = "bold fg:bright-green"
|
||||||
|
format = " [$branch]($style) "
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
style = "bold fg:bright-red"
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[❯](bold fg:bright-green)"
|
||||||
|
error_symbol = "[❯](bold fg:bright-red)"
|
||||||
|
|
||||||
|
[palettes.gruvbox_dark]
|
||||||
|
bright-yellow = "#fabd2f"
|
||||||
|
bright-green = "#b8bb26"
|
||||||
|
bright-red = "#fb4934"
|
||||||
|
bright-blue = "#83a598"
|
||||||
|
bright-purple = "#d3869b"
|
||||||
|
bright-aqua = "#8ec07c"
|
||||||
|
bright-orange = "#fe8019"
|
||||||
2
internal/theming/assets/gruvbox/theme.yaml
Normal file
2
internal/theming/assets/gruvbox/theme.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
display_name: "Gruvbox Dark"
|
||||||
|
nvchad: "gruvbox"
|
||||||
30
internal/theming/assets/gruvbox/yazi.toml
Normal file
30
internal/theming/assets/gruvbox/yazi.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[manager]
|
||||||
|
cwd = { fg = "#fabd2f" }
|
||||||
|
tab_active = { fg = "#282828", bg = "#fabd2f" }
|
||||||
|
tab_inactive = { fg = "#a89984", bg = "#3c3836" }
|
||||||
|
border_symbol = "│"
|
||||||
|
border_style = { fg = "#665c54" }
|
||||||
|
count_copied = { fg = "#282828", bg = "#b8bb26" }
|
||||||
|
count_cut = { fg = "#282828", bg = "#fb4934" }
|
||||||
|
count_selected = { fg = "#282828", bg = "#fabd2f" }
|
||||||
|
|
||||||
|
[status]
|
||||||
|
separator_open = ""
|
||||||
|
separator_close = ""
|
||||||
|
mode_normal = { fg = "#282828", bg = "#b8bb26", bold = true }
|
||||||
|
mode_select = { fg = "#282828", bg = "#fabd2f", bold = true }
|
||||||
|
mode_unset = { fg = "#282828", bg = "#fb4934", bold = true }
|
||||||
|
progress_label = { fg = "#ebdbb2", bold = true }
|
||||||
|
progress_normal = { fg = "#458588", bg = "#3c3836" }
|
||||||
|
progress_error = { fg = "#fb4934", bg = "#3c3836" }
|
||||||
|
|
||||||
|
[input]
|
||||||
|
border = { fg = "#fabd2f" }
|
||||||
|
title = {}
|
||||||
|
value = {}
|
||||||
|
selected = { reversed = true }
|
||||||
|
|
||||||
|
[select]
|
||||||
|
border = { fg = "#fabd2f" }
|
||||||
|
active = { fg = "#b8bb26", bold = true }
|
||||||
|
inactive = {}
|
||||||
35
internal/theming/assets/gruvbox/zellij.kdl
Normal file
35
internal/theming/assets/gruvbox/zellij.kdl
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
theme "gruvbox-dark"
|
||||||
|
default_layout "default"
|
||||||
|
default_mode "normal"
|
||||||
|
pane_frames false
|
||||||
|
simplified_ui true
|
||||||
|
mouse_mode false
|
||||||
|
|
||||||
|
keybinds {
|
||||||
|
shared {
|
||||||
|
bind "Alt h" { MoveFocusOrTab "Left"; }
|
||||||
|
bind "Alt l" { MoveFocusOrTab "Right"; }
|
||||||
|
bind "Alt j" { MoveFocus "Down"; }
|
||||||
|
bind "Alt k" { MoveFocus "Up"; }
|
||||||
|
bind "Alt n" { NewPane "Down"; }
|
||||||
|
bind "Alt v" { NewPane "Right"; }
|
||||||
|
bind "Alt f" { ToggleFloatingPanes; }
|
||||||
|
bind "Alt t" { NewTab; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
themes {
|
||||||
|
gruvbox-dark {
|
||||||
|
fg "#ebdbb2"
|
||||||
|
bg "#282828"
|
||||||
|
black "#1d2021"
|
||||||
|
red "#cc241d"
|
||||||
|
green "#98971a"
|
||||||
|
yellow "#d79921"
|
||||||
|
blue "#458588"
|
||||||
|
magenta "#b16286"
|
||||||
|
cyan "#689d6a"
|
||||||
|
white "#a89984"
|
||||||
|
orange "#d65d0e"
|
||||||
|
}
|
||||||
|
}
|
||||||
191
internal/theming/theming.go
Normal file
191
internal/theming/theming.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// Package theming applies a selected colorscheme across starship, zellij,
|
||||||
|
// yazi, lazygit, and NvChad. Themes are discovered from embedded manifest
|
||||||
|
// files under assets/<id>/theme.yaml at package init time.
|
||||||
|
package theming
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets
|
||||||
|
var assetsFS embed.FS
|
||||||
|
|
||||||
|
// Theme describes a colorscheme discovered under assets/<id>/theme.yaml.
|
||||||
|
type Theme struct {
|
||||||
|
ID string
|
||||||
|
DisplayName string
|
||||||
|
NvChadTheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
DisplayName string `yaml:"display_name"`
|
||||||
|
NvChad string `yaml:"nvchad"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var discovered []Theme
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
themes, err := discover()
|
||||||
|
if err != nil {
|
||||||
|
// Package is embed-only; discovery failure is a programmer error,
|
||||||
|
// not runtime input. Panic so tests surface the mistake early.
|
||||||
|
panic(fmt.Errorf("theming: discover themes: %w", err))
|
||||||
|
}
|
||||||
|
discovered = themes
|
||||||
|
}
|
||||||
|
|
||||||
|
func discover() ([]Theme, error) {
|
||||||
|
entries, err := fs.ReadDir(assetsFS, "assets")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Theme
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := e.Name()
|
||||||
|
m, err := readManifest(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("theme %q: %w", id, err)
|
||||||
|
}
|
||||||
|
out = append(out, Theme{ID: id, DisplayName: m.DisplayName, NvChadTheme: m.NvChad})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(id string) (manifest, error) {
|
||||||
|
data, err := assetsFS.ReadFile(filepath.Join("assets", id, "theme.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return manifest{}, err
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||||
|
return manifest{}, err
|
||||||
|
}
|
||||||
|
if m.DisplayName == "" {
|
||||||
|
return manifest{}, fmt.Errorf("empty display_name")
|
||||||
|
}
|
||||||
|
if m.NvChad == "" {
|
||||||
|
return manifest{}, fmt.Errorf("empty nvchad")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the themes sorted by ID.
|
||||||
|
func List() []Theme {
|
||||||
|
out := make([]Theme, len(discovered))
|
||||||
|
copy(out, discovered)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the theme with the given ID, if present.
|
||||||
|
func Get(id string) (Theme, bool) {
|
||||||
|
for _, t := range discovered {
|
||||||
|
if t.ID == id {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Theme{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolConfig describes one tool's config file: its asset name under
|
||||||
|
// assets/<theme>/ and its user-config destination relative to $HOME.
|
||||||
|
type toolConfig struct {
|
||||||
|
Asset string // e.g. "starship.toml"
|
||||||
|
DestRel string // e.g. ".config/starship.toml"
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolConfigs = []toolConfig{
|
||||||
|
{"starship.toml", ".config/starship.toml"},
|
||||||
|
{"zellij.kdl", ".config/zellij/config.kdl"},
|
||||||
|
{"yazi.toml", ".config/yazi/theme.toml"},
|
||||||
|
{"lazygit.yml", ".config/lazygit/config.yml"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply writes each tool's theme config to the user's home directory.
|
||||||
|
// Task 3 adds overlay-file update; Task 4 adds NvChad chadrc rewrite.
|
||||||
|
func Apply(cfg config.Config, themeID string) error {
|
||||||
|
th, ok := Get(themeID)
|
||||||
|
if !ok {
|
||||||
|
ids := make([]string, 0, len(discovered))
|
||||||
|
for _, t := range discovered {
|
||||||
|
ids = append(ids, t.ID)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown theme %q; valid: %s", themeID, strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range toolConfigs {
|
||||||
|
data, err := assetsFS.ReadFile(filepath.Join("assets", th.ID, tc.Asset))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read asset %s: %w", tc.Asset, err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(home, tc.DestRel)
|
||||||
|
if err := platform.WriteUserFile(dst, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", tc.DestRel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cfg // overlay carries config state; overlay file update below
|
||||||
|
if err := updateOverlayActive(th.ID); err != nil {
|
||||||
|
return fmt.Errorf("update overlay: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateOverlayActive rewrites ~/.config/archduino/overlay.yaml so that
|
||||||
|
// theme_overrides.__active reflects the selected theme, preserving all
|
||||||
|
// other keys via a generic map round-trip.
|
||||||
|
func updateOverlayActive(themeID string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(home, ".config", "archduino", "overlay.yaml")
|
||||||
|
|
||||||
|
var root map[string]any
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||||
|
return fmt.Errorf("parse overlay: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root == nil {
|
||||||
|
root = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := root["version"]; !ok {
|
||||||
|
root["version"] = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var overrides map[string]any
|
||||||
|
if raw, ok := root["theme_overrides"]; ok {
|
||||||
|
if m, ok := raw.(map[string]any); ok {
|
||||||
|
overrides = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if overrides == nil {
|
||||||
|
overrides = map[string]any{}
|
||||||
|
}
|
||||||
|
overrides["__active"] = themeID
|
||||||
|
root["theme_overrides"] = overrides
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.WriteUserFile(path, out, 0o644)
|
||||||
|
}
|
||||||
119
internal/theming/theming_test.go
Normal file
119
internal/theming/theming_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package theming
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListDiscoversThemes(t *testing.T) {
|
||||||
|
themes := List()
|
||||||
|
if len(themes) == 0 {
|
||||||
|
t.Fatal("no themes discovered")
|
||||||
|
}
|
||||||
|
for _, th := range themes {
|
||||||
|
if th.ID == "" || th.DisplayName == "" || th.NvChadTheme == "" {
|
||||||
|
t.Errorf("incomplete theme: %+v", th)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetKnownThemeReturnsMatch(t *testing.T) {
|
||||||
|
got, ok := Get("gruvbox")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("gruvbox should exist")
|
||||||
|
}
|
||||||
|
if got.ID != "gruvbox" || got.DisplayName == "" || got.NvChadTheme != "gruvbox" {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUnknownThemeReturnsFalse(t *testing.T) {
|
||||||
|
if _, ok := Get("does-not-exist"); ok {
|
||||||
|
t.Fatal("bogus id should return ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIsSortedByID(t *testing.T) {
|
||||||
|
themes := List()
|
||||||
|
for i := 1; i < len(themes); i++ {
|
||||||
|
if themes[i].ID < themes[i-1].ID {
|
||||||
|
t.Fatalf("not sorted: %s before %s", themes[i-1].ID, themes[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyWritesFourToolConfigs(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := Apply(config.Config{}, "gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := []struct{ rel, wantPrefix string }{
|
||||||
|
{".config/starship.toml", "palette"},
|
||||||
|
{".config/zellij/config.kdl", "theme"},
|
||||||
|
{".config/yazi/theme.toml", ""},
|
||||||
|
{".config/lazygit/config.yml", "gui"},
|
||||||
|
}
|
||||||
|
for _, c := range checks {
|
||||||
|
data, err := os.ReadFile(filepath.Join(home, c.rel))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s missing: %v", c.rel, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Errorf("%s empty", c.rel)
|
||||||
|
}
|
||||||
|
if c.wantPrefix != "" && !strings.HasPrefix(strings.TrimSpace(string(data)), c.wantPrefix) {
|
||||||
|
t.Errorf("%s: want prefix %q, got %q", c.rel, c.wantPrefix, string(data[:min(len(data), 40)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyUnknownThemeErrors(t *testing.T) {
|
||||||
|
if err := Apply(config.Config{}, "does-not-exist"); err == nil {
|
||||||
|
t.Fatal("want error on unknown theme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyUpdatesOverlayActive(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := Apply(config.Config{}, "gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(home, ".config", "archduino", "overlay.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overlay missing: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "__active: gruvbox") {
|
||||||
|
t.Fatalf("overlay missing active theme: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPreservesOtherOverlayKeys(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
op := filepath.Join(home, ".config", "archduino", "overlay.yaml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(op), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(op, []byte("version: 1\nextra_packages:\n - foo\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Apply(config.Config{}, "gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(op)
|
||||||
|
if !strings.Contains(string(data), "- foo") {
|
||||||
|
t.Fatalf("extra_packages lost: %s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "__active: gruvbox") {
|
||||||
|
t.Fatalf("active theme missing after preserve: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
internal/tui/dashboard/doc.go
Normal file
3
internal/tui/dashboard/doc.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Package dashboard is the live-updating Bubble Tea view that shows
|
||||||
|
// per-module status while engine.Run executes.
|
||||||
|
package dashboard
|
||||||
79
internal/tui/dashboard/integration_test.go
Normal file
79
internal/tui/dashboard/integration_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/hello"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDashboardRunsHelloModule exercises the whole reporter → model loop
|
||||||
|
// without a real TTY: we run engine.Run synchronously while routing events
|
||||||
|
// through a TeaReporter bound to a recorder, then replay every recorded
|
||||||
|
// message into the Model. The marker file written by the hello module
|
||||||
|
// confirms the pipeline is end-to-end.
|
||||||
|
func TestDashboardRunsHelloModule(t *testing.T) {
|
||||||
|
markerPath := filepath.Join(t.TempDir(), "hello.ok")
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
if err := reg.Register(hello.Module{Path: markerPath}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := tui.WizardState{
|
||||||
|
Modules: []tui.ModuleChoice{{ID: "hello", Name: "Hello", Selected: true}},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewModel(state.SelectedIDs(), map[string]string{"hello": "Hello"})
|
||||||
|
rec := &recorderSender{}
|
||||||
|
reporter := tui.NewTeaReporter(rec)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sum, err := engine.Run(ctx, reg, engine.RunOptions{
|
||||||
|
Selected: state.SelectedIDs(),
|
||||||
|
Reporter: reporter,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("engine.Run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range rec.drained() {
|
||||||
|
m.Update(msg)
|
||||||
|
}
|
||||||
|
m.Update(DoneMsg{Summary: sum})
|
||||||
|
|
||||||
|
if _, err := os.Stat(markerPath); err != nil {
|
||||||
|
t.Fatalf("marker not written: %v", err)
|
||||||
|
}
|
||||||
|
if m.Summary() == nil {
|
||||||
|
t.Fatal("dashboard summary is nil")
|
||||||
|
}
|
||||||
|
ok, fail, skip := m.Summary().Counts()
|
||||||
|
if ok != 1 || fail != 0 || skip != 0 {
|
||||||
|
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
|
||||||
|
}
|
||||||
|
if out := m.View(); out == "" {
|
||||||
|
t.Fatal("empty view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recorderSender buffers tea.Msg values for replay. Matches the unexported
|
||||||
|
// `sender` interface in the tui package via its Send method signature.
|
||||||
|
type recorderSender struct{ msgs []tea.Msg }
|
||||||
|
|
||||||
|
func (r *recorderSender) Send(msg tea.Msg) { r.msgs = append(r.msgs, msg) }
|
||||||
|
|
||||||
|
func (r *recorderSender) drained() []tea.Msg {
|
||||||
|
out := make([]tea.Msg, len(r.msgs))
|
||||||
|
copy(out, r.msgs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
178
internal/tui/dashboard/model.go
Normal file
178
internal/tui/dashboard/model.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DoneMsg is sent from the engine goroutine once engine.Run returns.
|
||||||
|
type DoneMsg struct {
|
||||||
|
Summary engine.Summary
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowState tracks per-module progress in the dashboard.
|
||||||
|
// Name, Started, and Ended are populated for future use by elapsed-time
|
||||||
|
// rendering (planned for a later iteration); they are currently not read by View.
|
||||||
|
type rowState struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Current int
|
||||||
|
Total int
|
||||||
|
LastMsg string
|
||||||
|
LogTail []string
|
||||||
|
StatusIcon string // ·, ⠋ (running), ✓, ✗, ⊘
|
||||||
|
Done bool
|
||||||
|
Started time.Time
|
||||||
|
Ended time.Time
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is the Bubble Tea model for the Apply-phase dashboard.
|
||||||
|
type Model struct {
|
||||||
|
rows []*rowState
|
||||||
|
rowIndex map[string]*rowState
|
||||||
|
cursor int
|
||||||
|
showLogs bool
|
||||||
|
summary *engine.Summary
|
||||||
|
runErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModel seeds the dashboard from the list of module IDs selected to run.
|
||||||
|
// Rows start in the "pending" state and flip to running on first ProgressMsg.
|
||||||
|
func NewModel(selected []string, nameByID map[string]string) *Model {
|
||||||
|
m := &Model{rowIndex: make(map[string]*rowState, len(selected))}
|
||||||
|
for _, id := range selected {
|
||||||
|
name := id
|
||||||
|
if n, ok := nameByID[id]; ok {
|
||||||
|
name = n
|
||||||
|
}
|
||||||
|
r := &rowState{ID: id, Name: name, StatusIcon: "·"}
|
||||||
|
m.rows = append(m.rows, r)
|
||||||
|
m.rowIndex[id] = r
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tui.ProgressMsg:
|
||||||
|
// Guard: ignore late Progress events that arrive after DoneMsg has
|
||||||
|
// finalised this row. tea.Program.Send is asynchronous and concurrent
|
||||||
|
// goroutines have no arrival-order guarantee.
|
||||||
|
if r, ok := m.rowIndex[msg.ModuleID]; ok && !r.Done {
|
||||||
|
if r.Started.IsZero() {
|
||||||
|
r.Started = time.Now()
|
||||||
|
}
|
||||||
|
r.Current = msg.Current
|
||||||
|
r.Total = msg.Total
|
||||||
|
r.LastMsg = msg.Message
|
||||||
|
r.StatusIcon = "⠋"
|
||||||
|
}
|
||||||
|
case tui.LogMsg:
|
||||||
|
if r, ok := m.rowIndex[msg.ModuleID]; ok && !r.Done {
|
||||||
|
r.LogTail = appendCapped(r.LogTail, msg.Line, 200)
|
||||||
|
}
|
||||||
|
case DoneMsg:
|
||||||
|
m.summary = &msg.Summary
|
||||||
|
m.runErr = msg.Err
|
||||||
|
for _, res := range msg.Summary.Results {
|
||||||
|
r, ok := m.rowIndex[res.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Done = true
|
||||||
|
r.Ended = time.Now()
|
||||||
|
switch res.Status {
|
||||||
|
case engine.StatusSucceeded, engine.StatusPlanned:
|
||||||
|
r.StatusIcon = "✓"
|
||||||
|
case engine.StatusFailed:
|
||||||
|
r.StatusIcon = "✗"
|
||||||
|
r.Err = res.Err
|
||||||
|
case engine.StatusSkipped:
|
||||||
|
r.StatusIcon = "⊘"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "up", "k":
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.cursor < len(m.rows)-1 {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
m.showLogs = !m.showLogs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
header := lipgloss.NewStyle().Bold(true).Render("archduino install")
|
||||||
|
b.WriteString(header + "\n")
|
||||||
|
|
||||||
|
for i, r := range m.rows {
|
||||||
|
cursor := " "
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = "▸ "
|
||||||
|
}
|
||||||
|
progress := ""
|
||||||
|
if r.Total > 0 {
|
||||||
|
progress = fmt.Sprintf("%d/%d", r.Current, r.Total)
|
||||||
|
}
|
||||||
|
detail := r.LastMsg
|
||||||
|
if r.Err != nil {
|
||||||
|
detail = "failed — " + r.Err.Error()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s%s %-16s %-6s %s\n", cursor, r.StatusIcon, r.ID, progress, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.showLogs && len(m.rows) > 0 {
|
||||||
|
active := m.rows[m.cursor]
|
||||||
|
b.WriteString("\n─ log tail ─\n")
|
||||||
|
for _, line := range active.LogTail {
|
||||||
|
b.WriteString(" " + line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.summary != nil {
|
||||||
|
ok, fail, skip := m.summary.Counts()
|
||||||
|
fmt.Fprintf(&b, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skip)
|
||||||
|
if m.runErr != nil {
|
||||||
|
fmt.Fprintf(&b, "error: %v\n", m.runErr)
|
||||||
|
}
|
||||||
|
b.WriteString("\n[q] quit\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("\n[↑/↓] navigate [enter] toggle log [q] abort\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary returns the engine summary once the run has completed, or nil.
|
||||||
|
func (m *Model) Summary() *engine.Summary { return m.summary }
|
||||||
|
|
||||||
|
// RunErr returns the engine's top-level error, if any.
|
||||||
|
func (m *Model) RunErr() error { return m.runErr }
|
||||||
|
|
||||||
|
func appendCapped(s []string, line string, max int) []string {
|
||||||
|
s = append(s, line)
|
||||||
|
if len(s) > max {
|
||||||
|
s = s[len(s)-max:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
78
internal/tui/dashboard/model_test.go
Normal file
78
internal/tui/dashboard/model_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProgressUpdatesRow(t *testing.T) {
|
||||||
|
m := NewModel([]string{"a"}, map[string]string{"a": "Alpha"})
|
||||||
|
m.Update(tui.ProgressMsg{ModuleID: "a", Current: 3, Total: 10, Message: "doing"})
|
||||||
|
if r := m.rowIndex["a"]; r.Current != 3 || r.Total != 10 || r.LastMsg != "doing" {
|
||||||
|
t.Fatalf("row state wrong: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogAppendedBounded(t *testing.T) {
|
||||||
|
m := NewModel([]string{"a"}, nil)
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
m.Update(tui.LogMsg{ModuleID: "a", Line: "x"})
|
||||||
|
}
|
||||||
|
if got := len(m.rowIndex["a"].LogTail); got != 200 {
|
||||||
|
t.Fatalf("log tail = %d, want 200", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoneMsgFlipsIcons(t *testing.T) {
|
||||||
|
m := NewModel([]string{"ok", "bad", "skip"}, nil)
|
||||||
|
m.Update(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{
|
||||||
|
{ID: "ok", Status: engine.StatusSucceeded},
|
||||||
|
{ID: "bad", Status: engine.StatusFailed, Err: errors.New("boom")},
|
||||||
|
{ID: "skip", Status: engine.StatusSkipped},
|
||||||
|
}}})
|
||||||
|
if m.rowIndex["ok"].StatusIcon != "✓" {
|
||||||
|
t.Errorf("ok icon = %q", m.rowIndex["ok"].StatusIcon)
|
||||||
|
}
|
||||||
|
if m.rowIndex["bad"].StatusIcon != "✗" {
|
||||||
|
t.Errorf("bad icon = %q", m.rowIndex["bad"].StatusIcon)
|
||||||
|
}
|
||||||
|
if m.rowIndex["skip"].StatusIcon != "⊘" {
|
||||||
|
t.Errorf("skip icon = %q", m.rowIndex["skip"].StatusIcon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressAfterDoneDoesNotRevertIcon(t *testing.T) {
|
||||||
|
m := NewModel([]string{"a"}, nil)
|
||||||
|
m.Update(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{
|
||||||
|
{ID: "a", Status: engine.StatusSucceeded},
|
||||||
|
}}})
|
||||||
|
if got := m.rowIndex["a"].StatusIcon; got != "✓" {
|
||||||
|
t.Fatalf("after Done icon = %q, want ✓", got)
|
||||||
|
}
|
||||||
|
// Late Progress event — tea.Program.Send has no ordering guarantee across goroutines.
|
||||||
|
m.Update(tui.ProgressMsg{ModuleID: "a", Current: 9, Total: 10, Message: "late"})
|
||||||
|
if got := m.rowIndex["a"].StatusIcon; got != "✓" {
|
||||||
|
t.Fatalf("late Progress reverted icon to %q", got)
|
||||||
|
}
|
||||||
|
// Late Log event must also be dropped — it would otherwise pollute the
|
||||||
|
// log tail of a completed row.
|
||||||
|
before := len(m.rowIndex["a"].LogTail)
|
||||||
|
m.Update(tui.LogMsg{ModuleID: "a", Line: "late log"})
|
||||||
|
if after := len(m.rowIndex["a"].LogTail); after != before {
|
||||||
|
t.Fatalf("late Log appended to tail: before=%d after=%d", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewMentionsModules(t *testing.T) {
|
||||||
|
m := NewModel([]string{"alpha", "bravo"}, nil)
|
||||||
|
out := m.View()
|
||||||
|
for _, want := range []string{"alpha", "bravo", "archduino install"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("view missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/tui/dashboard/run.go
Normal file
54
internal/tui/dashboard/run.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run displays the dashboard while engine.Run executes the selected modules.
|
||||||
|
// Returns the engine summary (for writeState) and the top-level error, if any.
|
||||||
|
func Run(ctx context.Context, reg *module.Registry, state tui.WizardState, halt bool) (engine.Summary, error) {
|
||||||
|
selected := state.SelectedIDs()
|
||||||
|
nameByID := make(map[string]string, len(state.Modules))
|
||||||
|
for _, m := range state.Modules {
|
||||||
|
nameByID[m.ID] = m.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewModel(selected, nameByID)
|
||||||
|
p := tea.NewProgram(m, tea.WithContext(ctx))
|
||||||
|
|
||||||
|
// Derived context so that when p.Run() returns (user pressed q, tea
|
||||||
|
// errored, parent ctx cancelled) the engine goroutine is cancelled
|
||||||
|
// too. Deferred cancel ensures no orphan goroutine outlives Run.
|
||||||
|
engineCtx, cancelEngine := context.WithCancel(ctx)
|
||||||
|
defer cancelEngine()
|
||||||
|
|
||||||
|
reporter := tui.NewTeaReporter(p)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// TODO(plan-3): forward state.Parallelism once engine.RunOptions
|
||||||
|
// gains a Parallelism field and the engine supports concurrent Apply.
|
||||||
|
summary, err := engine.Run(engineCtx, reg, engine.RunOptions{
|
||||||
|
Selected: selected,
|
||||||
|
HaltOnError: halt,
|
||||||
|
Reporter: reporter,
|
||||||
|
})
|
||||||
|
p.Send(DoneMsg{Summary: summary, Err: err})
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
return engine.Summary{}, fmt.Errorf("tui run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum engine.Summary
|
||||||
|
if m.Summary() != nil {
|
||||||
|
sum = *m.Summary()
|
||||||
|
}
|
||||||
|
return sum, m.RunErr()
|
||||||
|
}
|
||||||
43
internal/tui/dashboard/run_test.go
Normal file
43
internal/tui/dashboard/run_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/x/exp/teatest"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestModelEndToEnd drives the Model through teatest, feeding in the same
|
||||||
|
// message sequence that the TeaReporter + engine goroutine would produce.
|
||||||
|
func TestModelEndToEnd(t *testing.T) {
|
||||||
|
m := NewModel([]string{"alpha"}, map[string]string{"alpha": "Alpha"})
|
||||||
|
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
|
||||||
|
|
||||||
|
tm.Send(tui.ProgressMsg{ModuleID: "alpha", Current: 1, Total: 1, Message: "installing"})
|
||||||
|
tm.Send(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{
|
||||||
|
{ID: "alpha", Status: engine.StatusSucceeded},
|
||||||
|
}}})
|
||||||
|
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||||
|
|
||||||
|
tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second))
|
||||||
|
|
||||||
|
out := readAll(t, tm.FinalOutput(t))
|
||||||
|
if !bytes.Contains(out, []byte("alpha")) || !bytes.Contains(out, []byte("summary:")) {
|
||||||
|
t.Fatalf("unexpected final output:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAll(t *testing.T, r io.Reader) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
4
internal/tui/doc.go
Normal file
4
internal/tui/doc.go
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Package tui hosts the interactive installer UI — the Huh-driven wizard
|
||||||
|
// that collects user choices and the Bubble Tea dashboard that renders
|
||||||
|
// live module status during engine.Run.
|
||||||
|
package tui
|
||||||
60
internal/tui/reporter.go
Normal file
60
internal/tui/reporter.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressMsg is emitted when a module reports progress during Apply.
|
||||||
|
type ProgressMsg struct {
|
||||||
|
ModuleID string
|
||||||
|
Current int
|
||||||
|
Total int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogMsg is emitted when a module emits a log line during Apply.
|
||||||
|
type LogMsg struct {
|
||||||
|
ModuleID string
|
||||||
|
Line string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sender abstracts tea.Program.Send so tests can inject a recorder.
|
||||||
|
type sender interface {
|
||||||
|
Send(msg tea.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeaReporter implements module.Reporter and forwards every event to a
|
||||||
|
// sender (typically a *tea.Program). Concurrency-safe.
|
||||||
|
type TeaReporter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
s sender
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTeaReporter wraps a sender. Passing nil is allowed — events are dropped.
|
||||||
|
func NewTeaReporter(s sender) *TeaReporter {
|
||||||
|
return &TeaReporter{s: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TeaReporter) Progress(id string, current, total int, msg string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.s.Send(ProgressMsg{ModuleID: id, Current: current, Total: total, Message: msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TeaReporter) Log(id, line string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.s.Send(LogMsg{ModuleID: id, Line: line})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Reporter = (*TeaReporter)(nil)
|
||||||
51
internal/tui/reporter_test.go
Normal file
51
internal/tui/reporter_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
msgs []tea.Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recorder) Send(m tea.Msg) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.msgs = append(r.msgs, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recorder) get() []tea.Msg {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]tea.Msg, len(r.msgs))
|
||||||
|
copy(out, r.msgs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeaReporterForwardsProgressAndLog(t *testing.T) {
|
||||||
|
rec := &recorder{}
|
||||||
|
r := NewTeaReporter(rec)
|
||||||
|
r.Progress("mod", 1, 4, "step one")
|
||||||
|
r.Log("mod", "hello world")
|
||||||
|
|
||||||
|
msgs := rec.get()
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("got %d msgs", len(msgs))
|
||||||
|
}
|
||||||
|
if p, ok := msgs[0].(ProgressMsg); !ok || p.ModuleID != "mod" || p.Current != 1 || p.Total != 4 {
|
||||||
|
t.Errorf("msg[0] = %+v", msgs[0])
|
||||||
|
}
|
||||||
|
if l, ok := msgs[1].(LogMsg); !ok || l.Line != "hello world" {
|
||||||
|
t.Errorf("msg[1] = %+v", msgs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeaReporterNilSenderIsSafe(t *testing.T) {
|
||||||
|
r := NewTeaReporter(nil)
|
||||||
|
r.Progress("x", 1, 1, "y") // must not panic
|
||||||
|
r.Log("x", "y")
|
||||||
|
}
|
||||||
98
internal/tui/state.go
Normal file
98
internal/tui/state.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Theme is a named colorscheme presented in the wizard. Each entry carries
|
||||||
|
// per-tool theme names; Plan 3b uses NvChadTheme, later plans add more.
|
||||||
|
type Theme struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
NvChadTheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuiltinThemes is the fixed list the wizard shows. Keep in sync with the
|
||||||
|
// themes shipped by Plan 3's theme module.
|
||||||
|
var BuiltinThemes = []Theme{
|
||||||
|
{ID: "gruvbox", Name: "Gruvbox Dark", NvChadTheme: "gruvbox"},
|
||||||
|
{ID: "catppuccin", Name: "Catppuccin Mocha", NvChadTheme: "catppuccin"},
|
||||||
|
{ID: "tokyonight", Name: "Tokyo Night", NvChadTheme: "tokyonight"},
|
||||||
|
{ID: "nord", Name: "Nord", NvChadTheme: "nord"},
|
||||||
|
{ID: "dracula", Name: "Dracula", NvChadTheme: "dracula"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeByID returns the Theme with the given ID. The bool is false when no
|
||||||
|
// match is found; callers decide whether to fall back or error.
|
||||||
|
func ThemeByID(themes []Theme, id string) (Theme, bool) {
|
||||||
|
for _, t := range themes {
|
||||||
|
if t.ID == id {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Theme{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleChoice is one row in the module selector.
|
||||||
|
type ModuleChoice struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WizardState is the payload the wizard fills in and the dashboard reads.
|
||||||
|
type WizardState struct {
|
||||||
|
ThemeID string
|
||||||
|
Modules []ModuleChoice
|
||||||
|
Parallelism int // advanced; reserved for a future concurrent engine
|
||||||
|
HaltOnError bool // advanced
|
||||||
|
Confirmed bool // true once the user hits Confirm on the last screen
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWizardState builds an initial state from the running config and the
|
||||||
|
// registered modules. Pre-ticks all modules except those disabled in the
|
||||||
|
// overlay, defaults the theme to the first built-in theme.
|
||||||
|
func NewWizardState(cfg config.Config, reg *module.Registry) WizardState {
|
||||||
|
disabled := map[string]bool{}
|
||||||
|
for _, id := range cfg.DisabledModules {
|
||||||
|
disabled[id] = true
|
||||||
|
}
|
||||||
|
mods := reg.All()
|
||||||
|
choices := make([]ModuleChoice, 0, len(mods))
|
||||||
|
for _, m := range mods {
|
||||||
|
meta := m.Meta()
|
||||||
|
choices = append(choices, ModuleChoice{
|
||||||
|
ID: meta.ID,
|
||||||
|
Name: meta.Name,
|
||||||
|
Description: meta.Description,
|
||||||
|
Selected: !disabled[meta.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(choices, func(i, j int) bool { return choices[i].ID < choices[j].ID })
|
||||||
|
|
||||||
|
theme := BuiltinThemes[0].ID
|
||||||
|
if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" {
|
||||||
|
theme = override
|
||||||
|
}
|
||||||
|
|
||||||
|
return WizardState{
|
||||||
|
ThemeID: theme,
|
||||||
|
Modules: choices,
|
||||||
|
Parallelism: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedIDs returns only the checked module IDs.
|
||||||
|
func (s WizardState) SelectedIDs() []string {
|
||||||
|
out := make([]string, 0, len(s.Modules))
|
||||||
|
for _, m := range s.Modules {
|
||||||
|
if m.Selected {
|
||||||
|
out = append(out, m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
100
internal/tui/state_test.go
Normal file
100
internal/tui/state_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeModule struct{ id, name, desc string }
|
||||||
|
|
||||||
|
func (f fakeModule) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: f.id, Name: f.name, Description: f.desc}
|
||||||
|
}
|
||||||
|
func (f fakeModule) Detect(context.Context) (module.State, error) { return nil, nil }
|
||||||
|
func (f fakeModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
func (f fakeModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||||
|
func (f fakeModule) Verify(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func TestNewWizardStatePreTicksAllByDefault(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(fakeModule{id: "a", name: "Alpha"})
|
||||||
|
_ = reg.Register(fakeModule{id: "b", name: "Bravo"})
|
||||||
|
|
||||||
|
s := NewWizardState(config.Config{}, reg)
|
||||||
|
if len(s.Modules) != 2 {
|
||||||
|
t.Fatalf("modules = %d", len(s.Modules))
|
||||||
|
}
|
||||||
|
for _, m := range s.Modules {
|
||||||
|
if !m.Selected {
|
||||||
|
t.Errorf("%s not selected", m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.ThemeID != BuiltinThemes[0].ID {
|
||||||
|
t.Errorf("theme = %q", s.ThemeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWizardStateRespectsDisabled(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(fakeModule{id: "a"})
|
||||||
|
_ = reg.Register(fakeModule{id: "b"})
|
||||||
|
|
||||||
|
s := NewWizardState(config.Config{DisabledModules: []string{"a"}}, reg)
|
||||||
|
for _, m := range s.Modules {
|
||||||
|
switch m.ID {
|
||||||
|
case "a":
|
||||||
|
if m.Selected {
|
||||||
|
t.Error("a should be unticked")
|
||||||
|
}
|
||||||
|
case "b":
|
||||||
|
if !m.Selected {
|
||||||
|
t.Error("b should be ticked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectedIDsOnlyReturnsChecked(t *testing.T) {
|
||||||
|
s := WizardState{Modules: []ModuleChoice{
|
||||||
|
{ID: "a", Selected: true},
|
||||||
|
{ID: "b", Selected: false},
|
||||||
|
{ID: "c", Selected: true},
|
||||||
|
}}
|
||||||
|
got := s.SelectedIDs()
|
||||||
|
if len(got) != 2 || got[0] != "a" || got[1] != "c" {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinThemesCarryNvChadName(t *testing.T) {
|
||||||
|
for _, th := range BuiltinThemes {
|
||||||
|
if th.NvChadTheme == "" {
|
||||||
|
t.Errorf("theme %q has empty NvChadTheme", th.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThemeByIDReturnsMatch(t *testing.T) {
|
||||||
|
got, ok := ThemeByID(BuiltinThemes, "dracula")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("dracula should exist")
|
||||||
|
}
|
||||||
|
if got.ID != "dracula" {
|
||||||
|
t.Fatalf("got id=%q", got.ID)
|
||||||
|
}
|
||||||
|
if got.NvChadTheme == "" {
|
||||||
|
t.Fatal("NvChadTheme empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThemeByIDFallsBackWhenUnknown(t *testing.T) {
|
||||||
|
_, ok := ThemeByID(BuiltinThemes, "does-not-exist")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("unknown id should report ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
3
internal/tui/wizard/doc.go
Normal file
3
internal/tui/wizard/doc.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Package wizard is the pre-install form flow: welcome, theme picker,
|
||||||
|
// module selector, advanced options, plan review, and confirmation.
|
||||||
|
package wizard
|
||||||
206
internal/tui/wizard/wizard.go
Normal file
206
internal/tui/wizard/wizard.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package wizard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run presents the six-screen wizard. Returns the final state and whether
|
||||||
|
// the user confirmed. A context cancellation returns (state, false, nil).
|
||||||
|
func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tui.WizardState, bool, error) {
|
||||||
|
s := initial
|
||||||
|
|
||||||
|
// Honour an already-cancelled context before Huh tries to open /dev/tty.
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return s, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
themeOptions := make([]huh.Option[string], 0, len(tui.BuiltinThemes))
|
||||||
|
for _, t := range tui.BuiltinThemes {
|
||||||
|
themeOptions = append(themeOptions, huh.NewOption(t.Name, t.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleOptions := make([]huh.Option[string], 0, len(s.Modules))
|
||||||
|
defaults := make([]string, 0, len(s.Modules))
|
||||||
|
for _, m := range s.Modules {
|
||||||
|
label := m.Name
|
||||||
|
if m.Description != "" {
|
||||||
|
label = fmt.Sprintf("%s — %s", m.Name, m.Description)
|
||||||
|
}
|
||||||
|
moduleOptions = append(moduleOptions, huh.NewOption(label, m.ID))
|
||||||
|
if m.Selected {
|
||||||
|
defaults = append(defaults, m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedModules := defaults
|
||||||
|
parallelismStr := fmt.Sprintf("%d", s.Parallelism)
|
||||||
|
var proceed bool
|
||||||
|
cache := newPlanCache()
|
||||||
|
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(huh.NewNote().
|
||||||
|
Title("Archduino Installer").
|
||||||
|
Description("A TUI Arduino development environment.\n\nPress Enter to continue.")),
|
||||||
|
|
||||||
|
huh.NewGroup(huh.NewSelect[string]().
|
||||||
|
Title("Pick a colorscheme").
|
||||||
|
Options(themeOptions...).
|
||||||
|
Value(&s.ThemeID)),
|
||||||
|
|
||||||
|
huh.NewGroup(huh.NewMultiSelect[string]().
|
||||||
|
Title("Select modules to install").
|
||||||
|
Options(moduleOptions...).
|
||||||
|
Value(&selectedModules)),
|
||||||
|
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Parallelism (advanced)").
|
||||||
|
Description("How many modules to run concurrently. 1 is safe.").
|
||||||
|
Value(¶llelismStr),
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title("Halt on first error?").
|
||||||
|
Value(&s.HaltOnError),
|
||||||
|
),
|
||||||
|
|
||||||
|
huh.NewGroup(huh.NewNote().
|
||||||
|
Title("Review plan").
|
||||||
|
DescriptionFunc(func() string {
|
||||||
|
s.Modules = syncSelected(s.Modules, selectedModules)
|
||||||
|
return renderPlanCached(ctx, reg, s.SelectedIDs(), cache)
|
||||||
|
}, &selectedModules)),
|
||||||
|
|
||||||
|
huh.NewGroup(huh.NewConfirm().
|
||||||
|
Title("Proceed with installation?").
|
||||||
|
Value(&proceed)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := form.RunWithContext(ctx); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
return s, false, nil
|
||||||
|
}
|
||||||
|
return s, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redundant-but-safe: the DescriptionFunc closure on the plan-review
|
||||||
|
// screen already kept s.Modules in sync. The MultiSelect group is
|
||||||
|
// frozen by the time we reach here, so this is idempotent. Kept for
|
||||||
|
// the case where the form is exited via a path that bypasses the
|
||||||
|
// plan-review screen in future revisions.
|
||||||
|
s.Modules = syncSelected(s.Modules, selectedModules)
|
||||||
|
s.Parallelism = parseIntClamp(parallelismStr, 1)
|
||||||
|
s.Confirmed = proceed
|
||||||
|
return s, proceed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncSelected returns a copy of mods with Selected set to whether the ID
|
||||||
|
// appears in selected.
|
||||||
|
func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice {
|
||||||
|
set := map[string]bool{}
|
||||||
|
for _, id := range selected {
|
||||||
|
set[id] = true
|
||||||
|
}
|
||||||
|
out := make([]tui.ModuleChoice, len(mods))
|
||||||
|
copy(out, mods)
|
||||||
|
for i := range out {
|
||||||
|
out[i].Selected = set[out[i].ID]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// planCache memoises rendered Detect+Plan output per module ID so
|
||||||
|
// DescriptionFunc (which fires on every redraw) does not re-run
|
||||||
|
// potentially expensive Detect/Plan calls. Entries are evicted when a
|
||||||
|
// module leaves the current selection.
|
||||||
|
type planCacheEntry struct{ out string }
|
||||||
|
type planCache struct{ entries map[string]planCacheEntry }
|
||||||
|
|
||||||
|
func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} }
|
||||||
|
|
||||||
|
func (c *planCache) get(id string) (string, bool) {
|
||||||
|
e, ok := c.entries[id]
|
||||||
|
return e.out, ok
|
||||||
|
}
|
||||||
|
func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} }
|
||||||
|
func (c *planCache) prune(keepIDs []string) {
|
||||||
|
keep := map[string]bool{}
|
||||||
|
for _, id := range keepIDs {
|
||||||
|
keep[id] = true
|
||||||
|
}
|
||||||
|
for k := range c.entries {
|
||||||
|
if !keep[k] {
|
||||||
|
delete(c.entries, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPlanCached is the cached equivalent of running Detect+Plan for
|
||||||
|
// every selected module and joining the rendered lines. IDs no longer in
|
||||||
|
// the selection are evicted so a deselect+reselect re-runs Detect.
|
||||||
|
func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string {
|
||||||
|
cache.prune(selectedIDs)
|
||||||
|
if len(selectedIDs) == 0 {
|
||||||
|
return "No modules selected."
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, id := range selectedIDs {
|
||||||
|
if cached, ok := cache.get(id); ok {
|
||||||
|
b.WriteString(cached)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := renderOne(ctx, reg, id)
|
||||||
|
cache.put(id, line)
|
||||||
|
b.WriteString(line)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOne runs Detect+Plan for a single module and returns a rendered
|
||||||
|
// line. A failing Detect/Plan is shown inline but does not abort.
|
||||||
|
func renderOne(ctx context.Context, reg *module.Registry, id string) string {
|
||||||
|
m, ok := reg.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Sprintf("• %s — (unknown)\n", id)
|
||||||
|
}
|
||||||
|
state, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("• %s — detect failed: %v\n", id, err)
|
||||||
|
}
|
||||||
|
plan, err := m.Plan(ctx, state)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
|
||||||
|
}
|
||||||
|
if plan.IsEmpty() {
|
||||||
|
return fmt.Sprintf("• %s — already up to date\n", id)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "• %s\n", id)
|
||||||
|
for _, a := range plan.Actions {
|
||||||
|
fmt.Fprintf(&b, " - %s\n", a.Description)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIntClamp parses s as a positive int. Empty, non-numeric (including
|
||||||
|
// partial matches like "3abc"), negative, zero, or overflowing input all
|
||||||
|
// return fallback. strconv.Atoi (not fmt.Sscanf) is used so partial matches
|
||||||
|
// are rejected and overflow surfaces as an error.
|
||||||
|
func parseIntClamp(s string, fallback int) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
135
internal/tui/wizard/wizard_test.go
Normal file
135
internal/tui/wizard/wizard_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package wizard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/hello"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncSelected(t *testing.T) {
|
||||||
|
orig := []tui.ModuleChoice{
|
||||||
|
{ID: "a", Selected: false},
|
||||||
|
{ID: "b", Selected: true},
|
||||||
|
{ID: "c", Selected: true},
|
||||||
|
}
|
||||||
|
got := syncSelected(orig, []string{"a", "c"})
|
||||||
|
want := map[string]bool{"a": true, "b": false, "c": true}
|
||||||
|
for _, m := range got {
|
||||||
|
if m.Selected != want[m.ID] {
|
||||||
|
t.Errorf("%s selected=%v, want %v", m.ID, m.Selected, want[m.ID])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if orig[0].Selected {
|
||||||
|
t.Error("orig mutated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPlanWithHello(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"})
|
||||||
|
out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache())
|
||||||
|
if !strings.Contains(out, "hello") {
|
||||||
|
t.Fatalf("output missing hello: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPlanEmpty(t *testing.T) {
|
||||||
|
if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache()); got != "No modules selected." {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIntClamp(t *testing.T) {
|
||||||
|
cases := map[string]int{
|
||||||
|
"": 1,
|
||||||
|
" ": 1,
|
||||||
|
"abc": 1,
|
||||||
|
"3abc": 1, // partial match is rejected (strconv.Atoi, not Sscanf)
|
||||||
|
"0": 1,
|
||||||
|
"-5": 1,
|
||||||
|
"3": 3,
|
||||||
|
"12": 12,
|
||||||
|
"99999999999999999999": 1, // overflow → fallback
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got := parseIntClamp(in, 1)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("parseIntClamp(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPlanCachesDetectAndPlan(t *testing.T) {
|
||||||
|
var detects, plans int
|
||||||
|
m := countingModule{
|
||||||
|
meta: module.Meta{ID: "count", Name: "Count"},
|
||||||
|
onDetect: func() { detects++ },
|
||||||
|
onPlan: func() { plans++ },
|
||||||
|
}
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
if err := reg.Register(m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cache := newPlanCache()
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
if detects != 1 || plans != 1 {
|
||||||
|
t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) {
|
||||||
|
var detects int
|
||||||
|
m := countingModule{
|
||||||
|
meta: module.Meta{ID: "count", Name: "Count"},
|
||||||
|
onDetect: func() { detects++ },
|
||||||
|
}
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(m)
|
||||||
|
cache := newPlanCache()
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{}, cache)
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
if detects != 2 {
|
||||||
|
t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingModule struct {
|
||||||
|
meta module.Meta
|
||||||
|
onDetect func()
|
||||||
|
onPlan func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingModule) Meta() module.Meta { return c.meta }
|
||||||
|
func (c countingModule) Detect(context.Context) (module.State, error) {
|
||||||
|
if c.onDetect != nil {
|
||||||
|
c.onDetect()
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (c countingModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||||
|
if c.onPlan != nil {
|
||||||
|
c.onPlan()
|
||||||
|
}
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||||
|
func (c countingModule) Verify(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, confirmed, err := Run(ctx, module.NewRegistry(), tui.WizardState{})
|
||||||
|
if err != nil && err != context.Canceled {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if confirmed {
|
||||||
|
t.Fatal("confirmed=true on cancelled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user