Compare commits
2 Commits
929cee1643
...
rewrite/UI
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ab9e03b2 | ||
|
|
edbbf47e9f |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,10 +1,2 @@
|
|||||||
.idea
|
.idea
|
||||||
WokwiKey.txt
|
WokwiKey.txt
|
||||||
|
|
||||||
# Go
|
|
||||||
/archduino
|
|
||||||
/dist/
|
|
||||||
*.test
|
|
||||||
*.out
|
|
||||||
coverage.*
|
|
||||||
.worktrees/
|
|
||||||
|
|||||||
@@ -1,424 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## Conventions used throughout this plan
|
## Conventions used throughout this plan
|
||||||
|
|
||||||
- **Module path:** `github.com/MDietermann/ArduinoArchSetup` (matches the existing GitHub repo name). All `import` statements in subsequent tasks must use this prefix. If a code snippet later in this document shows `github.com/MDietermann/archduino`, treat it as an editing artifact and substitute `github.com/MDietermann/ArduinoArchSetup`.
|
- **Module path:** `github.com/MDietermann/archduino`. If your GitHub owner/repo is different, adjust `go.mod` and all import paths in Task 1 and keep the rest of the plan consistent.
|
||||||
- **Go version:** 1.22 or newer. All code targets 1.22 features (e.g. `slices`, `range` over integers is not required but allowed).
|
- **Go version:** 1.22 or newer. All code targets 1.22 features (e.g. `slices`, `range` over integers is not required but allowed).
|
||||||
- **Test style:** table-driven with `t.Run(tc.name, ...)` subtests; no external assertion libraries.
|
- **Test style:** table-driven with `t.Run(tc.name, ...)` subtests; no external assertion libraries.
|
||||||
- **Commits:** one commit per completed task (after tests pass). Message format: `feat(scope): short summary` or `test(scope): ...`, `chore(scope): ...` as appropriate.
|
- **Commits:** one commit per completed task (after tests pass). Message format: `feat(scope): short summary` or `test(scope): ...`, `chore(scope): ...` as appropriate.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# 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
42
go.mod
@@ -1,42 +0,0 @@
|
|||||||
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
88
go.sum
@@ -1,88 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// 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)
|
|
||||||
)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
return {
|
|
||||||
{
|
|
||||||
"stevearc/vim-arduino",
|
|
||||||
ft = { "arduino", "cpp", "c" },
|
|
||||||
config = function()
|
|
||||||
vim.g.arduino_use_cli = 1
|
|
||||||
vim.g.arduino_cli_path = "arduino-cli"
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---@type ChadrcConfig
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
M.base46 = {
|
|
||||||
theme = "{{.NvChadTheme}}",
|
|
||||||
}
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
return {
|
|
||||||
cmd = {
|
|
||||||
"clangd",
|
|
||||||
"--background-index",
|
|
||||||
"--clang-tidy",
|
|
||||||
"--header-insertion=iwyu",
|
|
||||||
"--completion-style=detailed",
|
|
||||||
},
|
|
||||||
filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" },
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
vim.lsp.enable("clangd")
|
|
||||||
return {}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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" })
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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{}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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, "'", `'\''`) + "'"
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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.
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#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);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#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);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
gui:
|
|
||||||
theme:
|
|
||||||
activeBorderColor:
|
|
||||||
- "#f9e2af"
|
|
||||||
- bold
|
|
||||||
inactiveBorderColor:
|
|
||||||
- "#585b70"
|
|
||||||
selectedLineBgColor:
|
|
||||||
- "#313244"
|
|
||||||
cherryPickedCommitFgColor:
|
|
||||||
- "#a6e3a1"
|
|
||||||
defaultFgColor:
|
|
||||||
- "#cdd6f4"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
display_name: "Catppuccin Mocha"
|
|
||||||
nvchad: "catppuccin"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[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 = {}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
gui:
|
|
||||||
theme:
|
|
||||||
activeBorderColor:
|
|
||||||
- "#f1fa8c"
|
|
||||||
- bold
|
|
||||||
inactiveBorderColor:
|
|
||||||
- "#6272a4"
|
|
||||||
selectedLineBgColor:
|
|
||||||
- "#44475a"
|
|
||||||
cherryPickedCommitFgColor:
|
|
||||||
- "#50fa7b"
|
|
||||||
defaultFgColor:
|
|
||||||
- "#f8f8f2"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
display_name: "Dracula"
|
|
||||||
nvchad: "dracula"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[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 = {}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
gui:
|
|
||||||
theme:
|
|
||||||
activeBorderColor:
|
|
||||||
- "#fabd2f"
|
|
||||||
- bold
|
|
||||||
inactiveBorderColor:
|
|
||||||
- "#665c54"
|
|
||||||
selectedLineBgColor:
|
|
||||||
- "#3c3836"
|
|
||||||
cherryPickedCommitFgColor:
|
|
||||||
- "#b8bb26"
|
|
||||||
defaultFgColor:
|
|
||||||
- "#ebdbb2"
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user