Compare commits
83 Commits
rewrite/UI
...
929cee1643
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929cee1643 | ||
|
|
fd87135f62 | ||
|
|
a203cdfe89 | ||
|
|
c710ed81df | ||
|
|
2464e70cd2 | ||
|
|
bbaa21a6bf | ||
|
|
698271a235 | ||
|
|
4f7411ca95 | ||
|
|
12c502d48a | ||
|
|
770bdcf3a2 | ||
|
|
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
|
||||
WokwiKey.txt
|
||||
|
||||
# Go
|
||||
/archduino
|
||||
/dist/
|
||||
*.test
|
||||
*.out
|
||||
coverage.*
|
||||
.worktrees/
|
||||
|
||||
424
cmd/archduino/main.go
Normal file
424
cmd/archduino/main.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/arduinocli"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/fonts"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/gittools"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/neovim"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/packages"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/renode"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/shellconfig"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/starship"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/themes"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/yazi"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zellij"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zsh"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/privilege"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/state"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/doctor"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/newproject"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/dashboard"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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, 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())
|
||||
root.AddCommand(newThemeCmd())
|
||||
root.AddCommand(newNewCmd())
|
||||
root.AddCommand(newDoctorCmd())
|
||||
|
||||
// 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
|
||||
selectedTheme = cfg.ThemeOverrides["__active"]
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
if finalState.ThemeID != "" {
|
||||
selectedTheme = finalState.ThemeID
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// Apply the wizard-selected theme (no-op if user chose a theme
|
||||
// whose configs are already on disk). Skipped on dry-run.
|
||||
if !dryRun && selectedTheme != "" {
|
||||
if err := theming.Apply(cfg, selectedTheme); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
ok, fail, skipped := sum.Counts()
|
||||
fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped)
|
||||
for _, r := range sum.Results {
|
||||
line := fmt.Sprintf(" %-16s %s", r.ID, r.Status)
|
||||
if r.Err != nil {
|
||||
line += " — " + r.Err.Error()
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, line)
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
if err := writeState(started, ended, sum); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: state write failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
if fail > 0 {
|
||||
return fmt.Errorf("%d module(s) failed", fail)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&noTUI, "no-tui", false, "plain-text output, no interactive UI")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned actions without applying")
|
||||
cmd.Flags().BoolVar(&halt, "halt-on-error", false, "stop at first failure instead of continuing siblings")
|
||||
cmd.Flags().BoolVar(&noSudo, "no-sudo", false, "skip upfront sudo -v (for tests / containers with passwordless sudo)")
|
||||
cmd.Flags().StringSliceVar(&only, "only", nil, "comma-separated module IDs to run (exclusive with --skip)")
|
||||
cmd.Flags().StringSliceVar(&skip, "skip", nil, "comma-separated module IDs to skip")
|
||||
cmd.Flags().StringVar(&overlayPath, "config", "", "path to overlay.yaml (default: ~/.config/archduino/overlay.yaml)")
|
||||
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)
|
||||
}
|
||||
|
||||
func newThemeCmd() *cobra.Command {
|
||||
var overlayPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "theme [id]",
|
||||
Short: "Switch the active colorscheme across tools",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(overlayPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// "list" short-circuit.
|
||||
if len(args) == 1 && args[0] == "list" {
|
||||
for _, t := range theming.List() {
|
||||
fmt.Printf("%s — %s\n", t.ID, t.DisplayName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var id string
|
||||
if len(args) == 1 {
|
||||
id = args[0]
|
||||
} else {
|
||||
themes := theming.List()
|
||||
opts := make([]huh.Option[string], 0, len(themes))
|
||||
for _, t := range themes {
|
||||
opts = append(opts, huh.NewOption(t.DisplayName, t.ID))
|
||||
}
|
||||
id = cfg.ThemeOverrides["__active"]
|
||||
if id == "" && len(themes) > 0 {
|
||||
id = themes[0].ID
|
||||
}
|
||||
form := huh.NewForm(huh.NewGroup(
|
||||
huh.NewSelect[string]().Title("Choose a theme").Options(opts...).Value(&id),
|
||||
))
|
||||
if err := form.RunWithContext(cmd.Context()); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := theming.Apply(cfg, id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Applied theme: %s\n", id)
|
||||
fmt.Println("Restart zellij and neovim to see the new colors.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml (defaults to ~/.config/archduino/overlay.yaml)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newNewCmd() *cobra.Command {
|
||||
var overlayPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "new <project> <board>",
|
||||
Short: "Scaffold a new Arduino sketch directory",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(overlayPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, board := args[0], args[1]
|
||||
if err := newproject.Run(cfg, project, board); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Created %s/ (board: %s)\n", project, board)
|
||||
fmt.Printf("Next: cd %s && nvim %s.ino\n", project, project)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDoctorCmd() *cobra.Command {
|
||||
var overlayPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Report module convergence and pinned-tool version drift",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(overlayPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg, err := buildRegistry(cfg, cfg.ThemeOverrides["__active"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("build registry: %w", err)
|
||||
}
|
||||
code := doctor.Run(cmd.Context(), cfg, reg, platform.ExecRunner{}, os.Stdout)
|
||||
if code != 0 {
|
||||
os.Exit(code)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
|
||||
return cmd
|
||||
}
|
||||
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 gitlab.hive-brain.de/MDietermann/Archduino
|
||||
|
||||
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=
|
||||
179
internal/config/config.go
Normal file
179
internal/config/config.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type Board struct {
|
||||
Name string `yaml:"name"`
|
||||
FQBN string `yaml:"fqbn"`
|
||||
Template string `yaml:"template"`
|
||||
}
|
||||
|
||||
type Scaffolding struct {
|
||||
Boards []Board `yaml:"boards"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Scaffolding Scaffolding `yaml:"scaffolding"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if len(over.Scaffolding.Boards) > 0 {
|
||||
out.Scaffolding = over.Scaffolding
|
||||
}
|
||||
return out
|
||||
}
|
||||
133
internal/config/config_test.go
Normal file
133
internal/config/config_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultsCarryScaffoldingBoards(t *testing.T) {
|
||||
cfg, err := Load("/nonexistent/overlay.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Scaffolding.Boards) < 6 {
|
||||
t.Fatalf("want >= 6 boards, got %d", len(cfg.Scaffolding.Boards))
|
||||
}
|
||||
want := map[string]string{
|
||||
"uno": "arduino:avr:uno", "mega": "arduino:avr:mega", "nano": "arduino:avr:nano",
|
||||
"esp32": "esp32:esp32:esp32", "esp32s3": "esp32:esp32:esp32s3", "esp8266": "esp8266:esp8266:nodemcuv2",
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, b := range cfg.Scaffolding.Boards {
|
||||
got[b.Name] = b.FQBN
|
||||
}
|
||||
for name, fqbn := range want {
|
||||
if got[name] != fqbn {
|
||||
t.Errorf("board %q: got fqbn %q, want %q", name, got[name], fqbn)
|
||||
}
|
||||
}
|
||||
}
|
||||
108
internal/config/defaults.yaml
Normal file
108
internal/config/defaults.yaml
Normal file
@@ -0,0 +1,108 @@
|
||||
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"
|
||||
|
||||
scaffolding:
|
||||
boards:
|
||||
- {name: uno, fqbn: "arduino:avr:uno", template: uno}
|
||||
- {name: mega, fqbn: "arduino:avr:mega", template: mega}
|
||||
- {name: nano, fqbn: "arduino:avr:nano", template: nano}
|
||||
- {name: esp32, fqbn: "esp32:esp32:esp32", template: esp32}
|
||||
- {name: esp32s3, fqbn: "esp32:esp32:esp32s3", template: esp32s3}
|
||||
- {name: esp8266, fqbn: "esp8266:esp8266:nodemcuv2", template: esp8266}
|
||||
91
internal/engine/dag.go
Normal file
91
internal/engine/dag.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
)
|
||||
|
||||
// TopoSort returns module IDs in a valid dependency order.
|
||||
// Ties are broken alphabetically for deterministic output.
|
||||
// Returns an error on unknown deps or cycles.
|
||||
func TopoSort(metas []module.Meta) ([]string, error) {
|
||||
known := make(map[string]bool, len(metas))
|
||||
for _, m := range metas {
|
||||
known[m.ID] = true
|
||||
}
|
||||
for _, m := range metas {
|
||||
for _, d := range m.Deps {
|
||||
if !known[d] {
|
||||
return nil, fmt.Errorf("module %q depends on unknown %q", m.ID, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm with alphabetical tie-breaking.
|
||||
indeg := make(map[string]int, len(metas))
|
||||
children := make(map[string][]string, len(metas))
|
||||
for _, m := range metas {
|
||||
if _, ok := indeg[m.ID]; !ok {
|
||||
indeg[m.ID] = 0
|
||||
}
|
||||
for _, d := range m.Deps {
|
||||
indeg[m.ID]++
|
||||
children[d] = append(children[d], m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
var ready []string
|
||||
for id, d := range indeg {
|
||||
if d == 0 {
|
||||
ready = append(ready, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(ready)
|
||||
|
||||
var order []string
|
||||
for len(ready) > 0 {
|
||||
next := ready[0]
|
||||
ready = ready[1:]
|
||||
order = append(order, next)
|
||||
kids := append([]string(nil), children[next]...)
|
||||
sort.Strings(kids)
|
||||
for _, c := range kids {
|
||||
indeg[c]--
|
||||
if indeg[c] == 0 {
|
||||
ready = append(ready, c)
|
||||
sort.Strings(ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) != len(metas) {
|
||||
return nil, fmt.Errorf("dependency cycle detected among modules")
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// Descendants returns the set of module IDs transitively downstream of root.
|
||||
// Root itself is NOT included.
|
||||
func Descendants(metas []module.Meta, root string) map[string]bool {
|
||||
children := make(map[string][]string)
|
||||
for _, m := range metas {
|
||||
for _, d := range m.Deps {
|
||||
children[d] = append(children[d], m.ID)
|
||||
}
|
||||
}
|
||||
out := make(map[string]bool)
|
||||
var walk func(string)
|
||||
walk = func(id string) {
|
||||
for _, c := range children[id] {
|
||||
if out[c] {
|
||||
continue
|
||||
}
|
||||
out[c] = true
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
)
|
||||
|
||||
func TestTopoSort(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metas []module.Meta
|
||||
want []string
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
name: "linear chain",
|
||||
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"b"}}},
|
||||
want: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "independent modules sorted alphabetically",
|
||||
metas: []module.Meta{{ID: "c"}, {ID: "a"}, {ID: "b"}},
|
||||
want: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "diamond",
|
||||
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"a"}}, {ID: "d", Deps: []string{"b", "c"}}},
|
||||
want: []string{"a", "b", "c", "d"},
|
||||
},
|
||||
{
|
||||
name: "unknown dep",
|
||||
metas: []module.Meta{{ID: "a", Deps: []string{"missing"}}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "cycle",
|
||||
metas: []module.Meta{{ID: "a", Deps: []string{"b"}}, {ID: "b", Deps: []string{"a"}}},
|
||||
err: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := TopoSort(tc.metas)
|
||||
if tc.err {
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got nil (order=%v)", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("order = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescendants(t *testing.T) {
|
||||
metas := []module.Meta{
|
||||
{ID: "a"},
|
||||
{ID: "b", Deps: []string{"a"}},
|
||||
{ID: "c", Deps: []string{"a"}},
|
||||
{ID: "d", Deps: []string{"b"}},
|
||||
{ID: "e"}, // independent
|
||||
}
|
||||
got := Descendants(metas, "a")
|
||||
want := map[string]bool{"b": true, "c": true, "d": true}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("descendants(a) = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if len(Descendants(metas, "e")) != 0 {
|
||||
t.Fatalf("descendants(e) should be empty")
|
||||
}
|
||||
}
|
||||
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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
)
|
||||
|
||||
// RunOptions controls a single engine run.
|
||||
type RunOptions struct {
|
||||
Selected []string // module IDs to run; empty = all
|
||||
DryRun bool // Detect + Plan only, no Apply/Verify
|
||||
HaltOnError bool // stop at first failure instead of continuing siblings
|
||||
Reporter module.Reporter
|
||||
}
|
||||
|
||||
// ModuleStatus is the terminal state of a module within a run.
|
||||
type ModuleStatus string
|
||||
|
||||
const (
|
||||
StatusSucceeded ModuleStatus = "succeeded"
|
||||
StatusFailed ModuleStatus = "failed"
|
||||
StatusSkipped ModuleStatus = "skipped"
|
||||
StatusPlanned ModuleStatus = "planned" // dry-run only
|
||||
)
|
||||
|
||||
// ModuleResult captures what happened for one module.
|
||||
type ModuleResult struct {
|
||||
ID string
|
||||
Status ModuleStatus
|
||||
Plan module.Plan
|
||||
Err error
|
||||
}
|
||||
|
||||
// Summary aggregates results.
|
||||
type Summary struct {
|
||||
Results []ModuleResult
|
||||
}
|
||||
|
||||
// Counts returns (succeeded, failed, skipped).
|
||||
func (s Summary) Counts() (int, int, int) {
|
||||
var ok, fail, skip int
|
||||
for _, r := range s.Results {
|
||||
switch r.Status {
|
||||
case StatusSucceeded, StatusPlanned:
|
||||
ok++
|
||||
case StatusFailed:
|
||||
fail++
|
||||
case StatusSkipped:
|
||||
skip++
|
||||
}
|
||||
}
|
||||
return ok, fail, skip
|
||||
}
|
||||
|
||||
// Run drives the selected modules through the four phases in DAG order.
|
||||
// Parallelism is intentionally omitted in this foundation; a later plan
|
||||
// will introduce it. Sequential execution is a correct baseline.
|
||||
func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, error) {
|
||||
mods, err := resolveSelection(reg, opts.Selected)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
|
||||
metas := make([]module.Meta, 0, len(mods))
|
||||
for _, m := range mods {
|
||||
metas = append(metas, m.Meta())
|
||||
}
|
||||
order, err := TopoSort(metas)
|
||||
if err != nil {
|
||||
return Summary{}, fmt.Errorf("module graph: %w", err)
|
||||
}
|
||||
|
||||
byID := make(map[string]module.Module, len(mods))
|
||||
for _, m := range mods {
|
||||
byID[m.Meta().ID] = m
|
||||
}
|
||||
|
||||
results := make(map[string]*ModuleResult, len(mods))
|
||||
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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
)
|
||||
|
||||
// scriptedModule lets tests control what each phase returns.
|
||||
type scriptedModule struct {
|
||||
id string
|
||||
deps []string
|
||||
detect func() (module.State, error)
|
||||
plan func() (module.Plan, error)
|
||||
apply func() error
|
||||
verify func() error
|
||||
}
|
||||
|
||||
func (s scriptedModule) Meta() module.Meta { return module.Meta{ID: s.id, Name: s.id, Deps: s.deps} }
|
||||
func (s scriptedModule) Detect(ctx context.Context) (module.State, error) {
|
||||
if s.detect != nil {
|
||||
return s.detect()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) {
|
||||
if s.plan != nil {
|
||||
return s.plan()
|
||||
}
|
||||
return module.Plan{}, nil
|
||||
}
|
||||
func (s scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error {
|
||||
if s.apply != nil {
|
||||
return s.apply()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s scriptedModule) Verify(ctx context.Context) error {
|
||||
if s.verify != nil {
|
||||
return s.verify()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newReg(t *testing.T, mods ...module.Module) *module.Registry {
|
||||
t.Helper()
|
||||
r := module.NewRegistry()
|
||||
for _, m := range mods {
|
||||
if err := r.Register(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRunAllSucceed(t *testing.T) {
|
||||
reg := newReg(t,
|
||||
scriptedModule{id: "a"},
|
||||
scriptedModule{id: "b", deps: []string{"a"}},
|
||||
)
|
||||
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, fail, skip := sum.Counts()
|
||||
if ok != 2 || fail != 0 || skip != 0 {
|
||||
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
|
||||
}
|
||||
if sum.Results[0].ID != "a" || sum.Results[1].ID != "b" {
|
||||
t.Fatalf("order = %v", sum.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunDoesNotApply(t *testing.T) {
|
||||
applied := false
|
||||
reg := newReg(t, scriptedModule{
|
||||
id: "a",
|
||||
apply: func() error { applied = true; return nil },
|
||||
})
|
||||
sum, err := Run(context.Background(), reg, RunOptions{DryRun: true, Reporter: NewBufferedReporter()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied {
|
||||
t.Fatal("Apply was called during dry-run")
|
||||
}
|
||||
if sum.Results[0].Status != StatusPlanned {
|
||||
t.Fatalf("status = %v", sum.Results[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
|
||||
reg := newReg(t,
|
||||
scriptedModule{id: "root"},
|
||||
scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }},
|
||||
scriptedModule{id: "child", deps: []string{"fails"}},
|
||||
scriptedModule{id: "sibling", deps: []string{"root"}},
|
||||
)
|
||||
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := map[string]ModuleStatus{}
|
||||
for _, r := range sum.Results {
|
||||
status[r.ID] = r.Status
|
||||
}
|
||||
if status["root"] != StatusSucceeded {
|
||||
t.Errorf("root = %v", status["root"])
|
||||
}
|
||||
if status["fails"] != StatusFailed {
|
||||
t.Errorf("fails = %v", status["fails"])
|
||||
}
|
||||
if status["child"] != StatusSkipped {
|
||||
t.Errorf("child = %v", status["child"])
|
||||
}
|
||||
if status["sibling"] != StatusSucceeded {
|
||||
t.Errorf("sibling = %v (should have run)", status["sibling"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHaltOnError(t *testing.T) {
|
||||
reg := newReg(t,
|
||||
scriptedModule{id: "a", apply: func() error { return errors.New("nope") }},
|
||||
scriptedModule{id: "b"}, // independent
|
||||
)
|
||||
sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// "b" should not appear in results because we halted before reaching it.
|
||||
for _, r := range sum.Results {
|
||||
if r.ID == "b" {
|
||||
t.Fatalf("b was run despite HaltOnError")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownModule(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_, err := Run(context.Background(), reg, RunOptions{Selected: []string{"ghost"}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown module")
|
||||
}
|
||||
}
|
||||
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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
)
|
||||
|
||||
// Event is a single item streamed from a module during Apply.
|
||||
type Event struct {
|
||||
ModuleID string
|
||||
Kind EventKind
|
||||
Current int
|
||||
Total int
|
||||
Message string
|
||||
}
|
||||
|
||||
type EventKind int
|
||||
|
||||
const (
|
||||
EventProgress EventKind = iota
|
||||
EventLog
|
||||
)
|
||||
|
||||
// BufferedReporter stores every event in memory. Used in tests.
|
||||
type BufferedReporter struct {
|
||||
mu sync.Mutex
|
||||
events []Event
|
||||
}
|
||||
|
||||
func NewBufferedReporter() *BufferedReporter { return &BufferedReporter{} }
|
||||
|
||||
func (b *BufferedReporter) Progress(id string, current, total int, msg string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.events = append(b.events, Event{ModuleID: id, Kind: EventProgress, Current: current, Total: total, Message: msg})
|
||||
}
|
||||
|
||||
func (b *BufferedReporter) Log(id, line string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.events = append(b.events, Event{ModuleID: id, Kind: EventLog, Message: line})
|
||||
}
|
||||
|
||||
func (b *BufferedReporter) Events() []Event {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
out := make([]Event, len(b.events))
|
||||
copy(out, b.events)
|
||||
return out
|
||||
}
|
||||
|
||||
// PlainReporter writes human-readable lines to w. Concurrency-safe.
|
||||
type PlainReporter struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func NewPlainReporter(w io.Writer) *PlainReporter { return &PlainReporter{w: w} }
|
||||
|
||||
func (p *PlainReporter) Progress(id string, current, total int, msg string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintf(p.w, "[%s] (%d/%d) %s\n", id, current, total, msg)
|
||||
}
|
||||
|
||||
func (p *PlainReporter) Log(id, line string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintf(p.w, "[%s] %s\n", id, line)
|
||||
}
|
||||
|
||||
// Compile-time guarantee both types satisfy module.Reporter.
|
||||
var (
|
||||
_ module.Reporter = (*BufferedReporter)(nil)
|
||||
_ module.Reporter = (*PlainReporter)(nil)
|
||||
)
|
||||
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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
)
|
||||
|
||||
const defaultRelPath = ".cache/archduino/hello.ok"
|
||||
const markerContent = "archduino-hello\n"
|
||||
|
||||
// Module is the exported module value. Path is overridable for tests.
|
||||
type Module struct {
|
||||
Path string // absolute path; if empty, ~/.cache/archduino/hello.ok is used
|
||||
}
|
||||
|
||||
func (m Module) resolve() (string, error) {
|
||||
if m.Path != "" {
|
||||
return m.Path, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, defaultRelPath), nil
|
||||
}
|
||||
|
||||
func (Module) Meta() module.Meta {
|
||||
return module.Meta{
|
||||
ID: "hello",
|
||||
Name: "Hello",
|
||||
Description: "Toy module that writes a marker file (engine smoke test).",
|
||||
}
|
||||
}
|
||||
|
||||
type state struct{ Present bool }
|
||||
|
||||
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||
path, err := m.resolve()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return state{Present: false}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return state{Present: string(data) == markerContent}, nil
|
||||
}
|
||||
|
||||
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||
s, _ := cur.(state)
|
||||
if s.Present {
|
||||
return module.Plan{}, nil
|
||||
}
|
||||
path, err := m.resolve()
|
||||
if err != nil {
|
||||
return module.Plan{}, err
|
||||
}
|
||||
return module.Plan{Actions: []module.Action{{
|
||||
Description: "write marker " + path,
|
||||
Kind: module.ActionFileWrite,
|
||||
Exec: func(ctx context.Context) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(markerContent), 0o644)
|
||||
},
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||
for i, a := range p.Actions {
|
||||
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||
if err := a.Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Module) Verify(ctx context.Context) error {
|
||||
st, err := m.Detect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !st.(state).Present {
|
||||
return fmt.Errorf("hello: marker not present after apply")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
|
||||
)
|
||||
|
||||
func TestHelloFullLifecycle(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hello.ok")
|
||||
m := Module{Path: path}
|
||||
ctx := context.Background()
|
||||
|
||||
st, err := m.Detect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.(state).Present {
|
||||
t.Fatal("marker should be absent on fresh tmpdir")
|
||||
}
|
||||
|
||||
plan, err := m.Plan(ctx, st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.IsEmpty() {
|
||||
t.Fatal("plan should have one action")
|
||||
}
|
||||
|
||||
if err := m.Apply(ctx, plan, engine.NewBufferedReporter()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Verify(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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" })
|
||||
463
internal/modules/neovim/neovim.go
Normal file
463
internal/modules/neovim/neovim.go
Normal file
@@ -0,0 +1,463 @@
|
||||
// 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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
var assets embed.FS
|
||||
|
||||
// Register WriteChadrc with the theming package so theming.Apply can
|
||||
// rewrite chadrc.lua on theme changes without importing this package
|
||||
// (which would be a cycle: neovim imports theming).
|
||||
func init() {
|
||||
theming.ChadrcWriter = WriteChadrc
|
||||
}
|
||||
|
||||
// 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
|
||||
pm platform.PackageManager
|
||||
git platform.GitClient
|
||||
}
|
||||
|
||||
// New constructs a Neovim Module.
|
||||
func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module {
|
||||
return Module{cfg: cfg, themeID: themeID, 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 := theming.Get(m.themeID); ok {
|
||||
return t.NvChadTheme
|
||||
}
|
||||
return theming.Default().NvChadTheme
|
||||
}
|
||||
|
||||
// WriteChadrc renders chadrc.lua for the given NvChad theme name and writes
|
||||
// it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist
|
||||
// and the surrounding nvim config dir does not exist, the function returns
|
||||
// nil without writing anything (the user hasn't installed neovim yet).
|
||||
//
|
||||
// Exported so the theming package can re-render chadrc on theme switches
|
||||
// without re-running the full Module pipeline.
|
||||
func WriteChadrc(nvChadThemeName string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := filepath.Join(home, ".config", "nvim")
|
||||
chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua")
|
||||
|
||||
// If chadrc doesn't exist, only write when the parent dir exists. A
|
||||
// fresh system where nvim was never installed should be a no-op.
|
||||
if _, err := os.Stat(chadrcPath); err != nil {
|
||||
if _, err := os.Stat(cfg); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpl, err := template.New("chadrc").Parse(string(tmplBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil {
|
||||
return err
|
||||
}
|
||||
return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
210
internal/modules/neovim/neovim_test.go
Normal file
210
internal/modules/neovim/neovim_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package neovim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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...))
|
||||
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", 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", 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", 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", 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 TestUnknownThemeFallsBackToDefault(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", 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"))
|
||||
// Unknown theme IDs fall back to theming.Default(), which is gruvbox.
|
||||
if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
|
||||
t.Fatalf("fallback theme not applied: %s", chadrc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteChadrcWritesFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
cfg := filepath.Join(home, ".config", "nvim")
|
||||
if err := os.MkdirAll(cfg, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteChadrc("dracula"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `theme = "dracula"`) {
|
||||
t.Fatalf("theme not applied: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
if err := WriteChadrc("gruvbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")); !os.IsNotExist(err) {
|
||||
t.Fatal("chadrc should not be created on a bare system")
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/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))
|
||||
}
|
||||
}
|
||||
104
internal/subcommands/doctor/doctor.go
Normal file
104
internal/subcommands/doctor/doctor.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Package doctor reports module convergence and pinned-tool version drift.
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
||||
)
|
||||
|
||||
// Run inspects every module in the registry plus pinned tool versions and
|
||||
// writes a human-readable report to w. Returns 0 when everything is
|
||||
// converged with no drift; 1 otherwise. Never panics on module errors.
|
||||
func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int {
|
||||
var converged, pending, errs, drift int
|
||||
|
||||
for _, m := range reg.All() {
|
||||
id := m.Meta().ID
|
||||
st, err := m.Detect(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err)
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
pl, err := m.Plan(ctx, st)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err)
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
if pl.IsEmpty() {
|
||||
fmt.Fprintf(w, "[✓] %s\n", id)
|
||||
converged++
|
||||
} else {
|
||||
fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions))
|
||||
pending++
|
||||
}
|
||||
}
|
||||
|
||||
drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w)
|
||||
drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w)
|
||||
|
||||
fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs)
|
||||
|
||||
if pending > 0 || drift > 0 || errs > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type arduinoCLIVersion struct {
|
||||
VersionString string `json:"VersionString"`
|
||||
}
|
||||
|
||||
func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
|
||||
if want == "" {
|
||||
return 0
|
||||
}
|
||||
out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json")
|
||||
if err != nil {
|
||||
return 0 // not installed → silent skip
|
||||
}
|
||||
var v arduinoCLIVersion
|
||||
if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" {
|
||||
fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n")
|
||||
return 1
|
||||
}
|
||||
if v.VersionString != want {
|
||||
fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString)
|
||||
return 0
|
||||
}
|
||||
|
||||
var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
||||
|
||||
func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
|
||||
if want == "" {
|
||||
return 0
|
||||
}
|
||||
out, err := runner.Output(ctx, "wokwi-cli", "--version")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out)))
|
||||
if len(m) < 2 {
|
||||
fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n")
|
||||
return 1
|
||||
}
|
||||
got := m[1]
|
||||
if got != want {
|
||||
fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got)
|
||||
return 0
|
||||
}
|
||||
135
internal/subcommands/doctor/doctor_test.go
Normal file
135
internal/subcommands/doctor/doctor_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
||||
)
|
||||
|
||||
// convergedModule returns an empty Plan — always converged.
|
||||
type convergedModule struct{ id string }
|
||||
|
||||
func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} }
|
||||
func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil }
|
||||
func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||
return module.Plan{}, nil
|
||||
}
|
||||
func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||
func (c convergedModule) Verify(context.Context) error { return nil }
|
||||
|
||||
type pendingModule struct{ id string }
|
||||
|
||||
func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} }
|
||||
func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil }
|
||||
func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
|
||||
}
|
||||
func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||
func (p pendingModule) Verify(context.Context) error { return nil }
|
||||
|
||||
type detectFailModule struct{ id string }
|
||||
|
||||
func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} }
|
||||
func (d detectFailModule) Detect(context.Context) (module.State, error) { return nil, errors.New("boom") }
|
||||
func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||
return module.Plan{}, nil
|
||||
}
|
||||
func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||
func (d detectFailModule) Verify(context.Context) error { return nil }
|
||||
|
||||
// notFoundRunner simulates "command not found" for version checks.
|
||||
type notFoundRunner struct{}
|
||||
|
||||
func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") }
|
||||
func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
|
||||
// fixedRunner returns a preset version string for a matching command.
|
||||
type fixedRunner struct {
|
||||
arduinoOut []byte
|
||||
arduinoErr error
|
||||
wokwiOut []byte
|
||||
wokwiErr error
|
||||
}
|
||||
|
||||
func (f fixedRunner) Run(context.Context, string, ...string) error { return nil }
|
||||
func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
switch name {
|
||||
case "arduino-cli":
|
||||
return f.arduinoOut, f.arduinoErr
|
||||
case "wokwi-cli":
|
||||
return f.wokwiOut, f.wokwiErr
|
||||
}
|
||||
return nil, errors.New("no match")
|
||||
}
|
||||
|
||||
func TestDoctorAllConvergedExitsZero(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(convergedModule{id: "foo"})
|
||||
var buf bytes.Buffer
|
||||
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "[✓] foo") {
|
||||
t.Errorf("missing converged mark: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorDivergedExitsOne(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(pendingModule{id: "bar"})
|
||||
var buf bytes.Buffer
|
||||
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
||||
if code != 1 {
|
||||
t.Fatalf("exit %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "[✗] bar") {
|
||||
t.Errorf("missing diverged mark: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorDetectErrorIsReported(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(detectFailModule{id: "baz"})
|
||||
var buf bytes.Buffer
|
||||
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
||||
if code != 1 {
|
||||
t.Fatalf("exit %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "[!] baz") {
|
||||
t.Errorf("missing error mark: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorArduinoCLIDriftIsReported(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(convergedModule{id: "foo"})
|
||||
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
|
||||
runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)}
|
||||
var buf bytes.Buffer
|
||||
code := Run(context.Background(), cfg, reg, runner, &buf)
|
||||
if code != 1 {
|
||||
t.Fatalf("exit %d, want 1 (drift)", code)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drift") {
|
||||
t.Errorf("missing drift report: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(convergedModule{id: "foo"})
|
||||
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
|
||||
var buf bytes.Buffer
|
||||
code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
|
||||
}
|
||||
}
|
||||
9
internal/subcommands/newproject/assets/default.ino
Normal file
9
internal/subcommands/newproject/assets/default.ino
Normal file
@@ -0,0 +1,9 @@
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial) { ; }
|
||||
Serial.println("Hello from Arduino!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Your code here
|
||||
}
|
||||
22
internal/subcommands/newproject/assets/esp32.ino
Normal file
22
internal/subcommands/newproject/assets/esp32.ino
Normal file
@@ -0,0 +1,22 @@
|
||||
#include <WiFi.h>
|
||||
|
||||
const char* ssid = "YOUR_SSID";
|
||||
const char* password = "YOUR_PASS";
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
WiFi.begin(ssid, password);
|
||||
Serial.print("Connecting to WiFi");
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
Serial.println();
|
||||
Serial.print("Connected! IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
}
|
||||
|
||||
void loop() {
|
||||
delay(1000);
|
||||
}
|
||||
9
internal/subcommands/newproject/assets/esp32s3.ino
Normal file
9
internal/subcommands/newproject/assets/esp32s3.ino
Normal file
@@ -0,0 +1,9 @@
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial) { ; }
|
||||
Serial.println("Hello from Arduino!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Your code here
|
||||
}
|
||||
22
internal/subcommands/newproject/assets/esp8266.ino
Normal file
22
internal/subcommands/newproject/assets/esp8266.ino
Normal file
@@ -0,0 +1,22 @@
|
||||
#include <ESP8266WiFi.h>
|
||||
|
||||
const char* ssid = "YOUR_SSID";
|
||||
const char* password = "YOUR_PASS";
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
WiFi.begin(ssid, password);
|
||||
Serial.print("Connecting to WiFi");
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
Serial.println();
|
||||
Serial.print("Connected! IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
}
|
||||
|
||||
void loop() {
|
||||
delay(1000);
|
||||
}
|
||||
9
internal/subcommands/newproject/assets/mega.ino
Normal file
9
internal/subcommands/newproject/assets/mega.ino
Normal file
@@ -0,0 +1,9 @@
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial) { ; }
|
||||
Serial.println("Hello from Arduino!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Your code here
|
||||
}
|
||||
9
internal/subcommands/newproject/assets/nano.ino
Normal file
9
internal/subcommands/newproject/assets/nano.ino
Normal file
@@ -0,0 +1,9 @@
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial) { ; }
|
||||
Serial.println("Hello from Arduino!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Your code here
|
||||
}
|
||||
9
internal/subcommands/newproject/assets/uno.ino
Normal file
9
internal/subcommands/newproject/assets/uno.ino
Normal file
@@ -0,0 +1,9 @@
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial) { ; }
|
||||
Serial.println("Hello from Arduino!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Your code here
|
||||
}
|
||||
75
internal/subcommands/newproject/newproject.go
Normal file
75
internal/subcommands/newproject/newproject.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Package newproject scaffolds a new Arduino sketch directory.
|
||||
package newproject
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
const clangdContent = `CompileFlags:
|
||||
Add:
|
||||
- -xc++
|
||||
- -std=c++17
|
||||
`
|
||||
|
||||
// Run creates a new Arduino project directory. It errors if the project
|
||||
// directory already exists, if the board is unknown, or if the project name
|
||||
// contains a path separator.
|
||||
func Run(cfg config.Config, project, board string) error {
|
||||
if project == "" {
|
||||
return fmt.Errorf("project name required")
|
||||
}
|
||||
if strings.ContainsAny(project, "/\\") || strings.HasPrefix(project, ".") {
|
||||
return fmt.Errorf("invalid project name %q", project)
|
||||
}
|
||||
|
||||
var b *config.Board
|
||||
var valid []string
|
||||
for i := range cfg.Scaffolding.Boards {
|
||||
bp := &cfg.Scaffolding.Boards[i]
|
||||
valid = append(valid, bp.Name)
|
||||
if bp.Name == board {
|
||||
b = bp
|
||||
}
|
||||
}
|
||||
if b == nil {
|
||||
return fmt.Errorf("unknown board %q; valid: %s", board, strings.Join(valid, ", "))
|
||||
}
|
||||
|
||||
if _, err := os.Stat(project); err == nil {
|
||||
return fmt.Errorf("project directory %q already exists", project)
|
||||
}
|
||||
if err := os.MkdirAll(project, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pick .ino template: try <template>.ino, else default.ino.
|
||||
inoData, err := assetsFS.ReadFile(filepath.Join("assets", b.Template+".ino"))
|
||||
if err != nil {
|
||||
inoData, err = assetsFS.ReadFile("assets/default.ino")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := platform.WriteUserFile(filepath.Join(project, project+".ino"), inoData, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sketch := fmt.Sprintf("default_fqbn: %s\ndefault_port: auto\n", b.FQBN)
|
||||
if err := platform.WriteUserFile(filepath.Join(project, "sketch.yaml"), []byte(sketch), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := platform.WriteUserFile(filepath.Join(project, ".clangd"), []byte(clangdContent), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
84
internal/subcommands/newproject/newproject_test.go
Normal file
84
internal/subcommands/newproject/newproject_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package newproject
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
||||
)
|
||||
|
||||
func sampleCfg() config.Config {
|
||||
return config.Config{
|
||||
Scaffolding: config.Scaffolding{Boards: []config.Board{
|
||||
{Name: "uno", FQBN: "arduino:avr:uno", Template: "uno"},
|
||||
{Name: "xyz", FQBN: "fake:fqbn:xyz", Template: "does-not-exist"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCreatesProject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := Run(sampleCfg(), "blinky", "uno"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ino, err := os.ReadFile(filepath.Join("blinky", "blinky.ino"))
|
||||
if err != nil {
|
||||
t.Fatalf("blinky.ino missing: %v", err)
|
||||
}
|
||||
if len(ino) == 0 {
|
||||
t.Error("blinky.ino empty")
|
||||
}
|
||||
sketch, err := os.ReadFile(filepath.Join("blinky", "sketch.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("sketch.yaml missing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(sketch), "arduino:avr:uno") {
|
||||
t.Errorf("sketch.yaml missing fqbn: %s", sketch)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("blinky", ".clangd")); err != nil {
|
||||
t.Errorf(".clangd missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRefusesExistingDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
os.Mkdir("blinky", 0o755)
|
||||
if err := Run(sampleCfg(), "blinky", "uno"); err == nil {
|
||||
t.Fatal("want error on existing dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFallsBackToDefaultTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := Run(sampleCfg(), "thing", "xyz"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("thing", "thing.ino")); err != nil {
|
||||
t.Fatalf("thing.ino missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownBoardErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
err := Run(sampleCfg(), "thing", "bogus")
|
||||
if err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "uno") {
|
||||
t.Errorf("error should list valid boards: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsSlashInProjectName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := Run(sampleCfg(), "bad/name", "uno"); err == nil {
|
||||
t.Fatal("want error on slashes")
|
||||
}
|
||||
}
|
||||
13
internal/theming/assets/catppuccin/lazygit.yml
Normal file
13
internal/theming/assets/catppuccin/lazygit.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
gui:
|
||||
theme:
|
||||
activeBorderColor:
|
||||
- "#f9e2af"
|
||||
- bold
|
||||
inactiveBorderColor:
|
||||
- "#585b70"
|
||||
selectedLineBgColor:
|
||||
- "#313244"
|
||||
cherryPickedCommitFgColor:
|
||||
- "#a6e3a1"
|
||||
defaultFgColor:
|
||||
- "#cdd6f4"
|
||||
26
internal/theming/assets/catppuccin/starship.toml
Normal file
26
internal/theming/assets/catppuccin/starship.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
palette = "catppuccin_mocha"
|
||||
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.catppuccin_mocha]
|
||||
bright-yellow = "#f9e2af"
|
||||
bright-green = "#a6e3a1"
|
||||
bright-red = "#f38ba8"
|
||||
bright-blue = "#89b4fa"
|
||||
bright-purple = "#cba6f7"
|
||||
bright-aqua = "#94e2d5"
|
||||
bright-orange = "#fab387"
|
||||
2
internal/theming/assets/catppuccin/theme.yaml
Normal file
2
internal/theming/assets/catppuccin/theme.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
display_name: "Catppuccin Mocha"
|
||||
nvchad: "catppuccin"
|
||||
30
internal/theming/assets/catppuccin/yazi.toml
Normal file
30
internal/theming/assets/catppuccin/yazi.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[manager]
|
||||
cwd = { fg = "#f9e2af" }
|
||||
tab_active = { fg = "#1e1e2e", bg = "#f9e2af" }
|
||||
tab_inactive = { fg = "#bac2de", bg = "#313244" }
|
||||
border_symbol = "│"
|
||||
border_style = { fg = "#585b70" }
|
||||
count_copied = { fg = "#1e1e2e", bg = "#a6e3a1" }
|
||||
count_cut = { fg = "#1e1e2e", bg = "#f38ba8" }
|
||||
count_selected = { fg = "#1e1e2e", bg = "#f9e2af" }
|
||||
|
||||
[status]
|
||||
separator_open = ""
|
||||
separator_close = ""
|
||||
mode_normal = { fg = "#1e1e2e", bg = "#a6e3a1", bold = true }
|
||||
mode_select = { fg = "#1e1e2e", bg = "#f9e2af", bold = true }
|
||||
mode_unset = { fg = "#1e1e2e", bg = "#f38ba8", bold = true }
|
||||
progress_label = { fg = "#cdd6f4", bold = true }
|
||||
progress_normal = { fg = "#89b4fa", bg = "#313244" }
|
||||
progress_error = { fg = "#f38ba8", bg = "#313244" }
|
||||
|
||||
[input]
|
||||
border = { fg = "#f9e2af" }
|
||||
title = {}
|
||||
value = {}
|
||||
selected = { reversed = true }
|
||||
|
||||
[select]
|
||||
border = { fg = "#f9e2af" }
|
||||
active = { fg = "#a6e3a1", bold = true }
|
||||
inactive = {}
|
||||
35
internal/theming/assets/catppuccin/zellij.kdl
Normal file
35
internal/theming/assets/catppuccin/zellij.kdl
Normal file
@@ -0,0 +1,35 @@
|
||||
theme "catppuccin-mocha"
|
||||
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 {
|
||||
catppuccin-mocha {
|
||||
fg "#cdd6f4"
|
||||
bg "#1e1e2e"
|
||||
black "#181825"
|
||||
red "#f38ba8"
|
||||
green "#a6e3a1"
|
||||
yellow "#f9e2af"
|
||||
blue "#89b4fa"
|
||||
magenta "#cba6f7"
|
||||
cyan "#94e2d5"
|
||||
white "#bac2de"
|
||||
orange "#fab387"
|
||||
}
|
||||
}
|
||||
13
internal/theming/assets/dracula/lazygit.yml
Normal file
13
internal/theming/assets/dracula/lazygit.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
gui:
|
||||
theme:
|
||||
activeBorderColor:
|
||||
- "#f1fa8c"
|
||||
- bold
|
||||
inactiveBorderColor:
|
||||
- "#6272a4"
|
||||
selectedLineBgColor:
|
||||
- "#44475a"
|
||||
cherryPickedCommitFgColor:
|
||||
- "#50fa7b"
|
||||
defaultFgColor:
|
||||
- "#f8f8f2"
|
||||
26
internal/theming/assets/dracula/starship.toml
Normal file
26
internal/theming/assets/dracula/starship.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
palette = "dracula"
|
||||
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.dracula]
|
||||
bright-yellow = "#f1fa8c"
|
||||
bright-green = "#50fa7b"
|
||||
bright-red = "#ff5555"
|
||||
bright-blue = "#bd93f9"
|
||||
bright-purple = "#ff79c6"
|
||||
bright-aqua = "#8be9fd"
|
||||
bright-orange = "#ffb86c"
|
||||
2
internal/theming/assets/dracula/theme.yaml
Normal file
2
internal/theming/assets/dracula/theme.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
display_name: "Dracula"
|
||||
nvchad: "dracula"
|
||||
30
internal/theming/assets/dracula/yazi.toml
Normal file
30
internal/theming/assets/dracula/yazi.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[manager]
|
||||
cwd = { fg = "#f1fa8c" }
|
||||
tab_active = { fg = "#282a36", bg = "#f1fa8c" }
|
||||
tab_inactive = { fg = "#bfbfbf", bg = "#44475a" }
|
||||
border_symbol = "│"
|
||||
border_style = { fg = "#6272a4" }
|
||||
count_copied = { fg = "#282a36", bg = "#50fa7b" }
|
||||
count_cut = { fg = "#282a36", bg = "#ff5555" }
|
||||
count_selected = { fg = "#282a36", bg = "#f1fa8c" }
|
||||
|
||||
[status]
|
||||
separator_open = ""
|
||||
separator_close = ""
|
||||
mode_normal = { fg = "#282a36", bg = "#50fa7b", bold = true }
|
||||
mode_select = { fg = "#282a36", bg = "#f1fa8c", bold = true }
|
||||
mode_unset = { fg = "#282a36", bg = "#ff5555", bold = true }
|
||||
progress_label = { fg = "#f8f8f2", bold = true }
|
||||
progress_normal = { fg = "#bd93f9", bg = "#44475a" }
|
||||
progress_error = { fg = "#ff5555", bg = "#44475a" }
|
||||
|
||||
[input]
|
||||
border = { fg = "#f1fa8c" }
|
||||
title = {}
|
||||
value = {}
|
||||
selected = { reversed = true }
|
||||
|
||||
[select]
|
||||
border = { fg = "#f1fa8c" }
|
||||
active = { fg = "#50fa7b", bold = true }
|
||||
inactive = {}
|
||||
35
internal/theming/assets/dracula/zellij.kdl
Normal file
35
internal/theming/assets/dracula/zellij.kdl
Normal file
@@ -0,0 +1,35 @@
|
||||
theme "dracula"
|
||||
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 {
|
||||
dracula {
|
||||
fg "#f8f8f2"
|
||||
bg "#282a36"
|
||||
black "#21222c"
|
||||
red "#ff5555"
|
||||
green "#50fa7b"
|
||||
yellow "#f1fa8c"
|
||||
blue "#bd93f9"
|
||||
magenta "#ff79c6"
|
||||
cyan "#8be9fd"
|
||||
white "#bfbfbf"
|
||||
orange "#ffb86c"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user