83 Commits

Author SHA1 Message Date
MDietermann
929cee1643 chore: rename module to gitlab.hive-brain.de/MDietermann/Archduino 2026-04-14 14:30:14 +02:00
MDietermann
fd87135f62 Merge Plan 4a: theme/new/doctor subcommands + theming package 2026-04-14 14:25:48 +02:00
MDietermann
a203cdfe89 feat(cmd): add theme/new/doctor subcommands; install applies theme 2026-04-14 14:22:33 +02:00
MDietermann
c710ed81df feat(doctor): add archduino doctor subcommand 2026-04-14 14:19:12 +02:00
MDietermann
2464e70cd2 feat(newproject): add archduino new project scaffolder 2026-04-14 14:16:07 +02:00
MDietermann
bbaa21a6bf feat(config): add Scaffolding.Boards typed input with 6 defaults 2026-04-14 14:08:54 +02:00
MDietermann
698271a235 fix(theming): restore gruvbox as default via Default() helper 2026-04-14 13:42:58 +02:00
MDietermann
4f7411ca95 refactor: retire tui.BuiltinThemes in favor of theming.List
Make internal/theming.List the single source of truth for themes:

- Drop tui.BuiltinThemes, tui.ThemeByID, and the NvChadTheme field on
  tui.Theme. tui.Theme is now a thin {ID, Name} display value.
- Wizard populates its dropdown from theming.List() and uses
  DisplayName for the label.
- neovim.resolvedTheme uses theming.Get (with theming.List()[0] as a
  fallback) instead of a caller-supplied []tui.Theme slice.
- neovim.New signature loses the themes []tui.Theme parameter; update
  cmd/archduino/main.go and all neovim tests to match.

Break the resulting neovim→theming import cycle (theming previously
imported neovim for WriteChadrc) by inverting the dependency:
theming exposes a ChadrcWriter function hook, and neovim registers
its WriteChadrc into it via package init. The theming test stubs
ChadrcWriter directly to exercise the chadrc-rewrite branch without
pulling neovim in and re-creating the cycle.

Delete the now-obsolete TestBuiltinThemesCarryNvChadName / TestThemeByID*
tests in internal/tui/state_test.go — their coverage lives in the
theming package's own tests.
2026-04-14 13:35:10 +02:00
MDietermann
12c502d48a feat(theming): add catppuccin, dracula, nord, tokyonight themes 2026-04-14 13:25:58 +02:00
MDietermann
770bdcf3a2 feat(theming): integrate NvChad chadrc rewrite into Apply 2026-04-14 13:14:47 +02:00
MDietermann
4fa580f226 feat(theming): apply updates overlay theme_overrides.__active 2026-04-14 13:11:34 +02:00
MDietermann
b422402ffc feat(theming): apply writes four tool configs to $HOME 2026-04-14 13:07:08 +02:00
MDietermann
b628898c6d feat(theming): add theme discovery with gruvbox assets 2026-04-14 12:56:09 +02:00
MDietermann
465d10a5ef docs(plans): add Plan 4a user-facing subcommands implementation plan 2026-04-14 12:50:09 +02:00
MDietermann
e000e8d22c docs(specs): add Plan 4a user-facing subcommands design 2026-04-14 12:42:53 +02:00
MDietermann
fcbd2201b3 fix(renode): pin wokwi-cli to v0.26.1 (v0.28.1 does not exist) 2026-04-14 12:19:32 +02:00
MDietermann
f177d86255 Merge Plan 3b: neovim + renode modules + theme plumbing 2026-04-14 12:17:02 +02:00
MDietermann
bf2d8de82c feat(cmd): register neovim and renode modules in install command 2026-04-14 12:16:05 +02:00
MDietermann
66f02edfb2 feat(modules): port renode module (AUR + arduino-sim + wokwi-cli) to Go 2026-04-14 12:13:42 +02:00
MDietermann
daa20b1dca fix(neovim): write mappings.lua to lua/mappings.lua per NvChad convention 2026-04-14 10:09:26 +02:00
MDietermann
8677a1b950 feat(modules): port neovim module (NvChad + Archduino Lua assets) to Go 2026-04-14 10:08:06 +02:00
MDietermann
e0c1fc3c5d feat(cmd): thread themeID through buildRegistry, rebuild post-wizard
Adds a themeID parameter to buildRegistry (unused in the body until
Task 7 wires neovim.New) and rebuilds the registry after the wizard
returns with finalState.ThemeID, so the dashboard runs against a
registry configured with the user's theme selection. The pre-wizard
call passes "" since only the dashboard path consumes theme-aware
modules.
2026-04-14 10:03:19 +02:00
MDietermann
e2637bc093 feat(config): add Neovim and Renode typed inputs 2026-04-14 10:01:40 +02:00
MDietermann
e830e4dfda feat(tui): add NvChadTheme field and ThemeByID helper 2026-04-14 09:58:04 +02:00
MDietermann
adfc9232ac feat(platform): add WriteUserFile atomic user-space writer 2026-04-14 09:53:03 +02:00
MDietermann
b9f9f0306d docs(plans): add Plan 3b install-modules implementation plan 2026-04-14 09:48:35 +02:00
MDietermann
027b7a88bc docs(specs): add Plan 3b install-modules (neovim, renode) design 2026-04-14 09:41:15 +02:00
MDietermann
7914b7ef51 fix(installer): replace orphaned neofetch with fastfetch, surface stderr on Run failure
- neofetch was removed from Arch's official repos; fastfetch is the maintained replacement.
- ExecRunner.Run previously discarded stderr, so any 'sudo pacman' failure surfaced only as 'exit status 1'. Now the returned error includes the captured stderr (matching what Output already did).
2026-04-14 09:27:09 +02:00
MDietermann
5587d793df Merge Plan 3a: install modules + platform helpers + wizard cache fix 2026-04-14 09:20:19 +02:00
MDietermann
fa946e9403 feat(cmd): register all Plan 3a modules in install command
Replace the init()-time hello toy-module registration with a
buildRegistry(cfg) helper called from the install RunE that wires the
ten Plan 3a modules (packages, gittools, starship, yazi, zsh, fonts,
zellij, themes, arduinocli, shellconfig) against a shared ExecRunner,
Pacman, Yay, and GitClient.
2026-04-14 09:15:54 +02:00
MDietermann
7de9d6c72c feat(modules): port shellconfig module to Go 2026-04-14 09:10:16 +02:00
MDietermann
99db34d70f feat(modules): port arduino-cli module to Go 2026-04-14 09:07:44 +02:00
MDietermann
9650cd0c82 feat(modules): port themes module to Go 2026-04-14 09:03:49 +02:00
MDietermann
78ad784c7e feat(modules): port zellij module to Go 2026-04-14 08:59:28 +02:00
MDietermann
4b3ce22491 feat(modules): port fonts module (AUR) to Go 2026-04-14 08:56:22 +02:00
MDietermann
1735f66bf0 feat(modules): port zsh module (zsh + oh-my-zsh + plugins) to Go 2026-04-14 08:52:27 +02:00
MDietermann
6dbfc41ce2 feat(modules): port yazi module to Go 2026-04-14 08:47:00 +02:00
MDietermann
f5a45c4be6 feat(modules): port starship module to Go 2026-04-14 08:47:00 +02:00
MDietermann
2b30b371e4 feat(modules): port gittools (lazygit) module to Go 2026-04-14 08:46:49 +02:00
MDietermann
cba883376a feat(modules): port packages module to Go 2026-04-14 08:43:22 +02:00
MDietermann
ffd18c5382 feat(config): add typed module-input fields with defaults 2026-04-14 08:40:29 +02:00
MDietermann
fcc24b99b2 feat(platform): add AURHelper with yay bootstrap and install 2026-04-14 08:35:46 +02:00
MDietermann
1903217b5f feat(platform): add GitClient helper with idempotent EnsureRepo 2026-04-14 08:32:04 +02:00
MDietermann
3f4223879b feat(platform): add tar.gz and zip extract helpers with traversal guard 2026-04-14 08:29:42 +02:00
MDietermann
2d73114b5f feat(platform): add atomic HTTP download helper 2026-04-14 08:25:22 +02:00
MDietermann
afed8ab600 feat(platform): add textblock helper for idempotent config-file blocks 2026-04-14 08:21:38 +02:00
MDietermann
83bb622232 perf(tui): cache Detect+Plan results in wizard renderPlan 2026-04-14 08:18:38 +02:00
MDietermann
05dc8aa7f5 chore: ignore .worktrees 2026-04-14 08:13:47 +02:00
MDietermann
42dabf85a5 docs(plans): add Plan 3a install-modules implementation plan 2026-04-14 08:11:03 +02:00
MDietermann
6318362e99 docs(specs): add Plan 3a install-modules design 2026-04-14 08:03:17 +02:00
MDietermann
2ebaa6b5c6 fix: address Plan 2 final-review findings
- tui/doc.go: remove teatest blank import (test harness in prod binary)
- cmd: honor wizard HaltOnError toggle in addition to --halt-on-error flag
- cmd: wire SIGINT/SIGTERM to root context via signal.NotifyContext
- dashboard/run: derive engine context and cancel on tea.Program exit
  so the engine goroutine does not outlive Run
2026-04-13 22:26:54 +02:00
MDietermann
ea5667e040 test(tui): integration smoke — dashboard runs hello module end-to-end 2026-04-13 22:20:08 +02:00
MDietermann
4f3d736bd7 refactor(cmd): drop err=runErr reassignment, return runErr directly 2026-04-13 22:19:09 +02:00
MDietermann
341250d8f4 feat(cmd): wire TUI wizard and dashboard into install subcommand 2026-04-13 22:13:52 +02:00
MDietermann
7baf0fceaa feat(tui): add dashboard.Run bridging engine to Bubble Tea 2026-04-13 22:11:54 +02:00
MDietermann
5c5076f1f1 fix(tui): guard against late Progress/Log after DoneMsg; drop dead quitting field 2026-04-13 22:09:08 +02:00
MDietermann
feb01242f2 feat(tui): add Bubble Tea dashboard model for Apply phase 2026-04-13 22:02:38 +02:00
MDietermann
e7aa75735d refactor(tui): use strconv.Atoi in parseIntClamp, drop spurious error return 2026-04-13 22:00:49 +02:00
MDietermann
394fb33d0c fix(tui): short-circuit wizard.Run on cancelled context before opening TTY 2026-04-13 21:52:50 +02:00
MDietermann
d21ab3fd30 feat(tui): add six-screen Huh wizard for installer input 2026-04-13 21:52:17 +02:00
MDietermann
c0983284b3 feat(tui): add TeaReporter forwarding engine events to bubbletea 2026-04-13 21:49:14 +02:00
MDietermann
258b53b6a0 feat(tui): add shared WizardState seeded from config and registry 2026-04-13 21:48:12 +02:00
MDietermann
777a3fe49b chore(tui): add Charm dependencies and package skeleton 2026-04-13 21:47:02 +02:00
MDietermann
07c13551ce docs: add Plan 2 (TUI) implementation plan 2026-04-13 21:40:44 +02:00
MDietermann
8b43a0f273 fix: address final-review findings before closing Plan 1
- privilege: wire sudo -v to real stdin/stdout/stderr so it can prompt
- cmd: validate --only/--skip IDs against registry (prevents silent
  run-all on typo); gate install to Arch-only (spec requirement);
  mark --only and --skip mutually exclusive
- platform: Pacman.Update now runs -Syu --noconfirm, not -Sy (avoids
  Arch partial-upgrade anti-pattern)
- test/integration: trap-clean the named docker volume on any exit
- go.mod: tidy — yaml.v3 is a direct dep
2026-04-13 21:35:14 +02:00
MDietermann
40ac730719 test(integration): run archduino install end-to-end on archlinux:base 2026-04-13 21:22:29 +02:00
MDietermann
3a4855b92a feat(modules): add hello toy module as engine smoke test 2026-04-13 20:31:43 +02:00
MDietermann
7a3ebbb146 docs(cmd): clarify why !noTUI branch prints the fallback notice 2026-04-13 20:18:07 +02:00
MDietermann
7a382c9bdd feat(cmd): wire install subcommand to engine with plain reporter 2026-04-13 20:13:50 +02:00
MDietermann
e4f945146b feat(state): persist per-run logs with rotation and last-run pointer 2026-04-13 20:02:48 +02:00
MDietermann
8f9e34174c feat(privilege): acquire sudo and keep timestamp fresh via goroutine 2026-04-13 20:01:34 +02:00
MDietermann
83cf7f0e72 feat(platform): add PackageManager interface and Pacman implementation 2026-04-13 20:00:11 +02:00
MDietermann
3b666615d7 feat(platform): detect distro, WSL, and architecture 2026-04-13 19:59:04 +02:00
MDietermann
ea0d793461 feat(config): load embedded defaults and merge optional overlay 2026-04-13 19:56:53 +02:00
MDietermann
0f0ea7e925 refactor(engine): remove dead failed map from markFailed 2026-04-13 19:55:41 +02:00
MDietermann
94c7084ec3 feat(engine): run modules through Detect/Plan/Apply/Verify with DAG + skip-on-failure 2026-04-13 19:49:52 +02:00
MDietermann
37761e017e feat(engine): add topological sort and descendants for module DAG 2026-04-13 19:39:35 +02:00
MDietermann
30643e385d feat(engine): add Reporter with buffered and plain implementations 2026-04-13 19:37:51 +02:00
MDietermann
eb54225ad3 feat(module): add four-phase Module interface and registry 2026-04-13 19:29:18 +02:00
MDietermann
5b5e677749 chore: go mod tidy, pin go 1.22, clarify module path in plan 2026-04-13 19:21:13 +02:00
MDietermann
94dbea001c docs: add Plan 1 (Foundation) implementation plan 2026-04-13 18:44:47 +02:00
MDietermann
b952704be5 docs: add installer architecture redesign spec
Design for Go rewrite of the Bash installer: single static binary with
Bubble Tea + Huh TUI, four-phase module contract, DAG execution engine,
embedded config + optional overlay, and a minimal Bash pre-install shim.
Scoped to installer architecture only; toolset overhaul, CLI browser,
and TTY Nerd Font support are queued as follow-up specs.
2026-04-13 18:44:47 +02:00
MDietermann
5b8de40b4c chore: bootstrap Go project with cobra skeleton 2026-04-13 18:24:37 +02:00
133 changed files with 19150 additions and 0 deletions

8
.gitignore vendored
View File

@@ -1,2 +1,10 @@
.idea
WokwiKey.txt
# Go
/archduino
/dist/
*.test
*.out
coverage.*
.worktrees/

424
cmd/archduino/main.go Normal file
View File

@@ -0,0 +1,424 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/arduinocli"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/fonts"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/gittools"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/neovim"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/packages"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/renode"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/shellconfig"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/starship"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/themes"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/yazi"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zellij"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zsh"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/privilege"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/state"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/doctor"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/newproject"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/dashboard"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/wizard"
)
var version = "dev"
// buildRegistry constructs a module registry populated with all Plan 3a
// modules, wired against the provided config and platform dependencies.
// It replaces the earlier init()-time hello-module registration now that
// the real modules need runtime config to be instantiated.
func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) {
reg := module.NewRegistry()
runner := platform.ExecRunner{}
pm := platform.NewPacman(runner)
yay := platform.NewYay(runner)
git := platform.GitClient{Runner: runner}
mods := []module.Module{
packages.New(cfg.Packages.Base, pm),
gittools.New([]string{"lazygit"}, pm),
starship.New([]string{"starship"}, pm),
yazi.New([]string{"yazi"}, pm),
zsh.New(cfg.Zsh, pm, git),
fonts.New(cfg.Fonts, pm, yay),
zellij.New(cfg.Zellij, pm),
themes.New(cfg.Themes),
arduinocli.New(cfg.ArduinoCLI, runner),
shellconfig.New(cfg.ShellConfig),
neovim.New(cfg.Neovim, themeID, pm, git),
renode.New(cfg.Renode, pm, yay),
}
for _, m := range mods {
if err := reg.Register(m); err != nil {
return nil, err
}
}
return reg, nil
}
func main() {
root := &cobra.Command{
Use: "archduino",
Short: "Archduino installer and toolbox",
SilenceUsage: true,
SilenceErrors: true,
Version: version,
}
root.AddCommand(newInstallCmd())
root.AddCommand(newThemeCmd())
root.AddCommand(newNewCmd())
root.AddCommand(newDoctorCmd())
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases
// (engine.Run, sudo keepalive) observe the cancellation promptly.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func newInstallCmd() *cobra.Command {
var (
noTUI bool
dryRun bool
halt bool
noSudo bool
only []string
skip []string
overlayPath string
)
cmd := &cobra.Command{
Use: "install",
Short: "Install or reconcile the Archduino toolset",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
// Gate: v1 supports Arch only. Non-Arch hosts would silently run
// the hello module today but fail on Plan 3's pacman-dependent
// modules — fail fast with a clear message instead.
if !noSudo { // --no-sudo implies a test/container context; skip the gate
p, derr := platform.Detect()
if derr != nil {
return fmt.Errorf("platform detect: %w", derr)
}
if p.Distro != platform.DistroArch {
return fmt.Errorf("archduino requires Arch Linux (detected distro: %s)", p.Distro)
}
}
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
reg, err := buildRegistry(cfg, "")
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
selected, err := resolveSelection(reg, cfg, only, skip)
if err != nil {
return err
}
// Acquire sudo unless dry-run or explicitly skipped.
if !dryRun && !noSudo {
k, err := privilege.Acquire(ctx, privilege.ExecSudo{}, 50*time.Second)
if err != nil {
return err
}
defer k.Stop()
}
started := time.Now().UTC()
var (
sum engine.Summary
runErr error
selectedTheme = cfg.ThemeOverrides["__active"]
)
if noTUI || dryRun {
// Plain-output path: preserve the Plan 1 behaviour. Dry-run
// always uses plain output because the dashboard has nothing
// to animate when no Apply happens.
reporter := engine.NewPlainReporter(os.Stdout)
sum, runErr = engine.Run(ctx, reg, engine.RunOptions{
Selected: selected,
DryRun: dryRun,
HaltOnError: halt,
Reporter: reporter,
})
} else {
// TUI path: wizard → dashboard. If the user does not confirm,
// we exit cleanly with a zero-summary and no error.
initial := tui.NewWizardState(cfg, reg)
// Pre-filter the wizard's module list by --only/--skip so the
// user cannot accidentally widen selection in the TUI beyond
// what they asked for on the CLI.
if len(selected) < len(initial.Modules) {
wanted := map[string]bool{}
for _, id := range selected {
wanted[id] = true
}
for i := range initial.Modules {
initial.Modules[i].Selected = wanted[initial.Modules[i].ID]
}
}
finalState, confirmed, werr := wizard.Run(ctx, reg, initial)
if werr != nil {
return werr
}
if !confirmed {
fmt.Fprintln(os.Stdout, "installation cancelled")
return nil
}
// Rebuild the registry with the theme the user chose in the
// wizard so theme-aware modules (e.g. neovim in Task 7) pick
// up the selection instead of the empty pre-wizard default.
reg, err = buildRegistry(cfg, finalState.ThemeID)
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
if finalState.ThemeID != "" {
selectedTheme = finalState.ThemeID
}
// Either the CLI flag or the wizard toggle can force halt-on-error.
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
}
ended := time.Now().UTC()
if runErr != nil {
return runErr
}
// Apply the wizard-selected theme (no-op if user chose a theme
// whose configs are already on disk). Skipped on dry-run.
if !dryRun && selectedTheme != "" {
if err := theming.Apply(cfg, selectedTheme); err != nil {
fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
}
}
ok, fail, skipped := sum.Counts()
fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped)
for _, r := range sum.Results {
line := fmt.Sprintf(" %-16s %s", r.ID, r.Status)
if r.Err != nil {
line += " — " + r.Err.Error()
}
fmt.Fprintln(os.Stdout, line)
}
if !dryRun {
if err := writeState(started, ended, sum); err != nil {
fmt.Fprintln(os.Stderr, "warning: state write failed:", err)
}
}
if fail > 0 {
return fmt.Errorf("%d module(s) failed", fail)
}
return nil
},
}
cmd.Flags().BoolVar(&noTUI, "no-tui", false, "plain-text output, no interactive UI")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned actions without applying")
cmd.Flags().BoolVar(&halt, "halt-on-error", false, "stop at first failure instead of continuing siblings")
cmd.Flags().BoolVar(&noSudo, "no-sudo", false, "skip upfront sudo -v (for tests / containers with passwordless sudo)")
cmd.Flags().StringSliceVar(&only, "only", nil, "comma-separated module IDs to run (exclusive with --skip)")
cmd.Flags().StringSliceVar(&skip, "skip", nil, "comma-separated module IDs to skip")
cmd.Flags().StringVar(&overlayPath, "config", "", "path to overlay.yaml (default: ~/.config/archduino/overlay.yaml)")
cmd.MarkFlagsMutuallyExclusive("only", "skip")
return cmd
}
func resolveSelection(reg *module.Registry, cfg config.Config, only, skip []string) ([]string, error) {
all := reg.All()
known := map[string]bool{}
for _, m := range all {
known[m.Meta().ID] = true
}
// Validate every explicit ID the user passed so a typo in --only does not
// silently fall through to "run all modules".
for _, id := range only {
if !known[id] {
return nil, fmt.Errorf("unknown module in --only: %q", id)
}
}
for _, id := range skip {
if !known[id] {
return nil, fmt.Errorf("unknown module in --skip: %q", id)
}
}
disabled := map[string]bool{}
for _, d := range cfg.DisabledModules {
disabled[d] = true
}
for _, d := range skip {
disabled[d] = true
}
onlySet := map[string]bool{}
for _, o := range only {
onlySet[o] = true
}
var out []string
for _, m := range all {
id := m.Meta().ID
if disabled[id] {
continue
}
if len(onlySet) > 0 && !onlySet[id] {
continue
}
out = append(out, id)
}
return out, nil
}
func writeState(started, ended time.Time, sum engine.Summary) error {
s, err := state.DefaultStore()
if err != nil {
return err
}
rec := state.RunRecord{StartedAt: started, EndedAt: ended}
for _, r := range sum.Results {
mr := state.ModuleRecord{ID: r.ID, Status: string(r.Status), At: ended}
if r.Err != nil {
mr.Error = r.Err.Error()
}
rec.Modules = append(rec.Modules, mr)
}
return s.Save(rec, 10)
}
func newThemeCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "theme [id]",
Short: "Switch the active colorscheme across tools",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
// "list" short-circuit.
if len(args) == 1 && args[0] == "list" {
for _, t := range theming.List() {
fmt.Printf("%s — %s\n", t.ID, t.DisplayName)
}
return nil
}
var id string
if len(args) == 1 {
id = args[0]
} else {
themes := theming.List()
opts := make([]huh.Option[string], 0, len(themes))
for _, t := range themes {
opts = append(opts, huh.NewOption(t.DisplayName, t.ID))
}
id = cfg.ThemeOverrides["__active"]
if id == "" && len(themes) > 0 {
id = themes[0].ID
}
form := huh.NewForm(huh.NewGroup(
huh.NewSelect[string]().Title("Choose a theme").Options(opts...).Value(&id),
))
if err := form.RunWithContext(cmd.Context()); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil
}
return err
}
}
if err := theming.Apply(cfg, id); err != nil {
return err
}
fmt.Printf("Applied theme: %s\n", id)
fmt.Println("Restart zellij and neovim to see the new colors.")
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml (defaults to ~/.config/archduino/overlay.yaml)")
return cmd
}
func newNewCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "new <project> <board>",
Short: "Scaffold a new Arduino sketch directory",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
project, board := args[0], args[1]
if err := newproject.Run(cfg, project, board); err != nil {
return err
}
fmt.Printf("Created %s/ (board: %s)\n", project, board)
fmt.Printf("Next: cd %s && nvim %s.ino\n", project, project)
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd
}
func newDoctorCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "doctor",
Short: "Report module convergence and pinned-tool version drift",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
reg, err := buildRegistry(cfg, cfg.ThemeOverrides["__active"])
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
code := doctor.Run(cmd.Context(), cfg, reg, platform.ExecRunner{}, os.Stdout)
if code != 0 {
os.Exit(code)
}
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,228 @@
# Installer Architecture Redesign
**Status:** Design approved, pending implementation plan
**Date:** 2026-04-13
**Scope:** Installer architecture only. Toolset overhaul, CLI browser, and TTY-font story are queued as follow-up specs.
## Context
The current Archduino installer is a Bash entry point (`install.sh`) that sources 13 files under `lib/`. It has grown beyond what Bash handles comfortably:
- **Monolithic / ordering-fragile.** `install.sh` sources lib files in a fixed order at top level; hard to run a single step and hard to re-run safely.
- **Not idempotent / no resume.** Re-running either duplicates work or silently skips things.
- **Mixed concerns per file.** Detection, templating, package install, and theme application are intertwined in every lib.
- **Thin UX.** Only choices are theme + verbose; no dry-run, no module selection, no preview.
- **Platform coupling.** Arch vs Ubuntu-WSL branches are scattered throughout.
The goal is a principled rebuild across all five axes.
## Decision summary
| Area | Choice |
|---|---|
| Language / runtime | Go, single static binary |
| UI | Bubble Tea + Huh (wizard for input, dashboard for execution) |
| Pre-install shim | Minimal Bash script: ensures `sudo`, `git`, `curl`, `ca-certificates`, fetches binary |
| Module contract | Four-phase: `Detect → Plan → Apply → Verify` |
| Configuration | Embedded defaults + optional `~/.config/archduino/overlay.yaml` |
| Privilege model | User-mode binary; `sudo` per privileged command; upfront `sudo -v` + keepalive goroutine |
| State / logging | `Detect()` is authoritative; run log is advisory UX only |
| Failure policy | DAG execution; failed node fails its descendants; siblings proceed; `--halt-on-error` flag available |
| Subcommands | One binary: `install`, `theme`, `new`, `doctor` |
| Platform scope (v1) | Arch-only native + WSL2; `PackageManager` interface ready for Debian/Fedora later |
## Architecture
### Module contract
Every installable unit implements:
```go
type Module interface {
Meta() ModuleMeta // id, human name, description, deps []string
Detect(ctx) (State, error) // read current system
Plan(ctx, State) (Plan, error) // diff vs desired; []Action with descriptions
Apply(ctx, Plan, *Reporter) error // execute; stream progress to Reporter
Verify(ctx) error // confirm desired state reached
}
```
- `State` is module-defined.
- `Plan` is a flat list of `Action{Description, Kind, Exec}` so the engine can render plans without knowing modules.
- `Reporter` decouples modules from the TUI.
- **`Detect()` is authoritative.** No cached state overrides what the system reports.
Initial module set (1:1 with today's `lib/*.sh`):
`packages`, `fonts`, `zsh`, `starship`, `zellij`, `neovim`, `yazi`, `arduino-cli`, `renode`, `git` (lazygit), `scaffold`, `shell-config`, `themes`.
### Execution engine
- **DAG-scheduled.** Modules declare `deps []string`. Engine topologically sorts and runs independent branches concurrently, default parallelism 4.
- **Failure policy.** Failed node marks its descendants `skipped-due-to-dep`; siblings proceed. End-of-run summary: `X succeeded, Y failed, Z skipped`.
- **Dry-run** (`--dry-run`, or wizard "Review plan" step): runs Detect + Plan for every selected module, renders the combined plan, exits.
- **Halt mode** (`--halt-on-error`): opts into fail-fast for scripted use.
- **Resume hint.** If a prior run log exists, TUI offers "last run: `starship` failed — resume? / start over? / pick modules?" Hint only; Detect decides what actually runs.
### Configuration
- **Embedded defaults** via `//go:embed`: package lists, theme files, Zellij layouts, config templates.
- **Optional overlay** at `~/.config/archduino/overlay.yaml`:
```yaml
version: 1
extra_packages: [htop, ncdu]
disabled_modules: [renode]
theme_overrides:
starship: ~/my-starship.toml
```
- Overlay is merged at load time. Schema is versioned; a migration path exists when it grows.
### Privilege model
- Binary runs as the invoking user.
- On `install` start, one `sudo -v` call; a keepalive goroutine refreshes the timestamp every 50 seconds while the installer is live.
- Privileged operations shell out to `sudo pacman …`, `sudo usermod …`. If sudo needs to re-prompt (timestamp expired), the TUI is suspended via `tea.ExecProcess`.
- Config files, git clones, and anything under `$HOME` run as the user — no root-owned files in home.
### Platform abstraction
```go
type PackageManager interface {
Install(pkgs []string) error
IsInstalled(pkg string) (bool, error)
Update() error
Name() string
}
```
- V1 ships `Pacman` only. Debian/Ubuntu/Fedora impls are stubbed; wiring them is a later spec.
- `Platform{Distro, IsWSL, Arch}` detection is centralized. WSL path sets `/etc/wsl.conf` hints and adds the user to `dialout`/`uucp`.
### State and logs
- `~/.local/state/archduino/runs/<timestamp>.json` — per-run structured log: `{module, phase, status, duration, error}`.
- `~/.local/state/archduino/last.json` — most recent run; read by TUI for the resume hint.
- Log rotation: keep the last 10 runs.
- Safe to delete at any time; never read for correctness decisions.
## User-facing surface
### Pre-install shim (`scripts/bootstrap.sh`)
~30 lines of Bash, served from a stable URL. Invocation:
```bash
curl -fsSL https://archduino.dev/install.sh | bash
```
Responsibilities in order:
1. Refuse to run as root.
2. Detect distro; abort with a clear message if not Arch (v1 scope).
3. Ensure `sudo`, `git`, `curl`, `ca-certificates` are installed (`pacman -S --needed`).
4. Detect architecture, fetch the matching release binary from GitHub Releases, verify its SHA256 against the published checksum file, install to `~/.local/bin/archduino`, `chmod +x`.
5. `exec archduino install`.
Everything further (base-devel, multilib, user groups, toolset) is handled by modules inside the binary.
### Subcommands
| Command | Purpose |
|---|---|
| `archduino install` | Default; wizard + dashboard flow |
| `archduino theme [name]` | Switch colorscheme across all tools; no arg = TUI picker |
| `archduino new <name> <board>` | Scaffold a project (current `arduino-new` behavior) |
| `archduino doctor` | Run Detect across all modules; report drift in a read-only dashboard |
| `archduino --version` / `completion <shell>` | Standard |
Global flags: `--dry-run`, `--halt-on-error`, `--verbose`, `--no-tui`, `--only <mods>`, `--skip <mods>`.
Shell aliases `theme` and `arduino-new` are preserved via thin wrappers dropped in `~/.local/bin` by the `shell-config` module.
### TUI flow (`install`)
**Wizard phase (Huh forms):**
1. Welcome — detected platform, WSL status, binary version.
2. Theme picker — 5 options, palette preview.
3. Module selector — checkbox list; pre-ticked from overlay or "all" by default; disabled modules grayed with reason.
4. Advanced (collapsible) — parallelism, halt-on-error toggle, overlay path override.
5. Review plan — rendered `Plan()` grouped by module, expandable. Back-navigable.
6. Confirm — `[y/N]` gate into Apply.
**Apply phase (Bubble Tea dashboard):**
```
┌─ archduino install ──────────────────────────────────┐
│ ✓ packages 12/12 2.4s │
│ ✓ fonts 1/1 0.8s │
│ ⠋ neovim 3/7 running: :MasonInstall… │
│ ⠋ arduino-cli 2/4 running: core install… │
│ · zellij pending (waits: packages) │
│ ✗ starship failed — config template parse │
│ ⊘ shell-config skipped (dep: starship) │
├──────────────────────────────────────────────────────┤
│ [tab] focus module [enter] expand log [q] abort │
└──────────────────────────────────────────────────────┘
```
- Concurrent modules visible live.
- `enter` on a row expands a scrollable log tail.
- On completion: summary view; footer offers `[r]etry failed / [v]iew full log / [q]uit`.
## Project layout
```
archduino/
├── cmd/archduino/ # main.go, cobra wiring
├── internal/
│ ├── engine/ # DAG scheduler, Reporter, dry-run, resume
│ ├── module/ # Module interface, Plan/Action types, registry
│ ├── modules/ # one package per module
│ │ ├── packages/
│ │ ├── zsh/
│ │ └── ...
│ ├── platform/ # detection; PackageManager interface + pacman impl
│ ├── privilege/ # sudo -v + keepalive
│ ├── config/ # embedded defaults + overlay merge
│ ├── state/ # run log, last-run tracking
│ ├── theme/ # theme model + switcher
│ └── tui/
│ ├── wizard/ # Huh forms
│ └── dashboard/ # Bubble Tea Apply view
├── assets/ # embedded configs, themes, zellij layout, templates
│ ├── themes/
│ ├── templates/
│ └── layouts/
├── scripts/bootstrap.sh
├── .github/workflows/
│ ├── ci.yml
│ └── release.yml # goreleaser
└── docs/
```
## Testing
- **Unit** — every module's `Detect` and `Plan` against an injected `System` fake; table-driven cases for clean / already-installed / partial / drifted.
- **Engine** — DAG scheduling, failure propagation, dry-run, resume; pure Go, fully covered.
- **Integration** — Dockerfile on `archlinux:base` runs `archduino install --no-tui --yes` end-to-end in CI; asserts key files/packages/services. One matrix entry for native Arch. WSL smoke test remains manual for v1.
- **TUI** — `teatest` for wizard navigation and dashboard state transitions. No screenshot tests.
## Distribution
- **GitHub Releases** via `goreleaser`: `linux/amd64`, `linux/arm64` static binaries; `SHA256SUMS` file.
- No AUR package for v1; the shim is the distribution channel. AUR can follow later.
- Semver; breaking changes bump major.
## Migration
- Existing `install.sh` + `lib/*.sh` stay on `master` at a tagged `v0-bash-final` so users can still run the old installer if they want.
- `feat/renode-dap` merges first; a new `feat/go-rewrite` branch hosts the rewrite.
- First Go release (`v1.0.0`) lands when feature parity with the current Bash installer is verified on a clean Arch VM.
## Out of scope (queued for follow-up specs)
- Toolset redesign (re-evaluating which CLI tools are curated).
- CLI browser integration.
- Nerd Font rendering on bare TTY (constrained by the Linux console's 512-glyph PSF limit; needs a separate design covering `fbterm`/`kmscon` or the "SSH-in-from-a-real-terminal" fallback).
- Non-Arch platform support (Debian/Ubuntu/Fedora).
- AUR packaging.

View File

@@ -0,0 +1,138 @@
# Plan 3a — Install Modules (Simple/Medium Tier) Design
**Status:** Approved 2026-04-14
**Scope:** Port 10 of the 13 Bash install modules to Go. Plan 3b (neovim, renode, scaffold) is a separate brainstorm.
## Goal
Port the 10 simple/medium `lib/*.sh` installer modules into Go packages under `internal/modules/`, each implementing the `module.Module` contract (Detect / Plan / Apply / Verify). Every module is unit-tested against an injected fake `platform.Runner`. Along the way, add the platform helpers these modules need (AUR, download, extract, git-clone, text-block writer) and fix the wizard `renderPlan` UI-blocking watchpoint flagged at the end of Plan 2.
## Architecture
Foundation tasks land first, then modules in simplest-first order:
1. **Wizard cache fix** — cache Detect+Plan results per module; invalidate on selection toggle. One small change, but it unblocks every downstream module from stalling the UI.
2. **Platform helpers** — `AURHelper` (yay), `Download`, `Extract`, `gitclient`, `textblock`. Each gets its own file, each gets TDD against a fake Runner (except `extract` which uses real tempdirs with fixture archives).
3. **Ten modules** in this order: packages → gittools(lazygit) → starship → yazi → zsh → fonts → zellij → themes → arduinocli → shellconfig.
All module inputs (package lists, versions, URLs, plugin repos, shell-config blocks) are typed fields on `config.Config` populated from the embedded `defaults.yaml`. Overlay override plumbing already exists from Plan 1. Modules take their inputs as struct fields at construction time — no globals.
## Tech Stack
Go, existing `internal/platform` + `internal/module` + `internal/engine` + `internal/tui` packages. No new external dependencies expected beyond what Plan 1/2 pulled in.
## Files
### New — platform helpers
- `internal/platform/aur.go` + `aur_test.go`
- `AURHelper` interface, `Yay` implementation.
- `EnsureInstalled(ctx) error` — bootstraps yay from AUR via `git clone https://aur.archlinux.org/yay.git /tmp/... && makepkg -si --noconfirm` when missing.
- `Install(ctx, pkgs []string) error` — `yay -S --needed --noconfirm <pkgs...>`.
- Takes a `Runner` and a `PackageManager` (to check if `yay` itself is installed).
- `internal/platform/download.go` + `download_test.go`
- `Download(ctx, url, destPath string, mode os.FileMode) error`.
- Atomic-ish: write to `destPath+".tmp"` then rename. Clean up on error.
- Tested with `httptest.Server`.
- `internal/platform/extract.go` + `extract_test.go`
- `ExtractTarGz(ctx, archivePath, destDir string) error`.
- `ExtractZip(ctx, archivePath, destDir string) error`.
- Tested against real fixture archives in `testdata/`.
- `internal/platform/git.go` + `git_test.go`
- `GitClient` struct taking a `Runner`.
- `Clone(ctx, url, dest string) error`.
- `EnsureRepo(ctx, url, dest string) error` — clone if `dest/.git` missing, otherwise noop.
- `internal/platform/textblock.go` + `textblock_test.go`
- `EnsureBlock(path, marker, content string) error` — writes or replaces a block delimited by `# >>> marker >>>` / `# <<< marker <<<` markers. Creates the file if missing.
- `RemoveBlock(path, marker string) error`.
- `HasBlock(path, marker string) (bool, error)`.
- Table-driven tests: insert, replace, remove, preserve surrounding content, missing-file, empty-file.
### New — modules
Each lives in its own package under `internal/modules/<name>/` with `<name>.go` + `<name>_test.go`.
- `packages` — installs base pacman package list from `cfg.Packages.Base`.
- `gittools` — installs lazygit (pacman). Named `gittools` to avoid collision with Go stdlib / pacman `git`.
- `starship` — installs starship (pacman).
- `yazi` — installs yazi (pacman).
- `zsh` — installs zsh (pacman) + clones oh-my-zsh + configured plugins via `gitclient`.
- `fonts` — installs AUR fonts (e.g. `ttf-jetbrains-mono-nerd`) via `AURHelper`. First action is `EnsureYayInstalled` if needed.
- `zellij` — installs zellij (pacman) + downloads config.
- `themes` — copies theme files from source directory (bundled under `lib/themes/` or overridable via `cfg.Themes.Source`).
- `arduinocli` — downloads arduino-cli tarball, extracts to `~/.local/bin`, runs `arduino-cli core install <cores...>` from `cfg.ArduinoCLI.Cores`.
- `shellconfig` — writes marker-delimited blocks into `~/.zshrc`, `~/.bashrc`, etc., using `textblock.EnsureBlock`. Blocks defined in `cfg.ShellConfig.Blocks`.
### Modified
- `internal/config/config.go` + `internal/config/defaults.yaml` — add typed fields:
- `Packages.Base []string`
- `Fonts []string`
- `ArduinoCLI{Version string, URL string, Cores []string}`
- `Starship{Version string}` (reserved; pacman install doesn't need it but overlay may)
- `Zsh{OhMyZshRepo string, Plugins []struct{Name, Repo string}}`
- `Zellij{ConfigURL string}`
- `Themes{Source string}` (defaults to embedded `lib/themes/`)
- `ShellConfig{Blocks []struct{Name, File, Content string}}`
- `internal/tui/wizard/wizard.go` — cache Detect+Plan per module; invalidate on selection toggle.
- Module registry (wherever `cmd/archduino/install.go` currently registers `hello`) — register the 10 new modules instead of / alongside `hello`.
## Module Contract Details
All modules follow the same shape:
- **Detect** — pure read. `PackageManager.IsInstalled` per pkg, `os.Stat` per file, `textblock.HasBlock` per marker. Returns a module-private `state` struct. Must be fast (wizard caches results, but Detect still shouldn't shell out more than necessary).
- **Plan** — given current state, returns `Plan{Actions: []Action}`. Empty actions = already converged (idempotent no-op). No side effects.
- **Apply** — executes each action sequentially, reporting progress via `Reporter.Progress(id, i, total, description)`.
- **Verify** — re-runs Detect and asserts convergence. Returns a descriptive error on mismatch.
Error semantics from the engine (`--halt-on-error` flag, sibling-on-failure continuation) apply unchanged.
### Module archetypes
1. **Pacman-only** (packages, gittools, starship, yazi, zellij base, themes deps if any): Detect = `IsInstalled` per pkg; Plan = install missing; Apply = `Pacman.Install`; Verify = all installed.
2. **AUR** (fonts): same as pacman-only but via `AURHelper`. Plan prepends an `EnsureYay` action if yay is not installed.
3. **Download + extract** (arduinocli): Detect = probe binary + `--version`; Plan = download + extract + symlink + `arduino-cli core install`; Verify = version string matches.
4. **Git-clone configs** (zsh oh-my-zsh + plugins, zellij config fetch if URL-based, themes if sourced from repo): Detect = check `.git` or file presence; Plan = `gitclient.EnsureRepo` or `Download`; Verify = path exists.
5. **Text-block writers** (shellconfig): Detect = `textblock.HasBlock` per block; Plan = `textblock.EnsureBlock`; Verify = re-scan.
## Testing Strategy
**Per-module tests (all unit, all fake-Runner-based):**
1. Detect with nothing installed → absent state.
2. Detect with everything installed → present state.
3. Plan(absent) → non-empty actions matching config inputs.
4. Plan(present) → empty actions (idempotent no-op).
5. Apply → records correct shell commands against fake Runner.
6. Verify after Apply → passes when fake reports success.
**Platform helper tests:**
- `aur_test.go` — yay bootstrap path (fake Runner sees `git clone` + `makepkg -si`), install path, already-installed short-circuit.
- `download_test.go` — `httptest.Server` for happy path + 404 + mid-download error cleanup.
- `extract_test.go` — real tar/zip fixtures in `testdata/` extracted into `t.TempDir()`.
- `git_test.go` — fake Runner for clone-if-missing, skip-if-present.
- `textblock_test.go` — table-driven: insert new, replace existing, remove, preserve surrounding content, missing-file, empty-file.
**Wizard caching test:** assert `Detect` is called once per module regardless of redraw count.
**No integration tests** that touch real pacman, real AUR, or the real network. That's Plan 4 (CI with Arch container). Manual smoke after plan completes.
## Non-Goals
- Plan 3b modules (neovim, renode, scaffold) — separate brainstorm.
- Subcommands beyond `install` (theme/new/doctor) — Plan 4.
- `bootstrap.sh`, goreleaser, CI — Plan 4.
- Debian/Fedora PackageManager implementations — Plan 4+.
- Integration tests against real package managers — Plan 4.
## Open Risks / Watchpoints
- **AUR bootstrap fragility** — if `makepkg -si --noconfirm` prompts despite `--noconfirm` (sudo password mid-build), the fake-Runner tests won't catch it. Manual smoke on fonts module is essential.
- **arduino-cli URL drift** — hardcoded version in `defaults.yaml` will go stale. Acceptable for v1; doctor subcommand in Plan 4 can warn.
- **Theme file source** — if themes ship bundled via Go `embed`, we need to decide that at module time. Default assumption: copy from `lib/themes/` on disk (same behavior as bash); switch to `embed` only if it's a clear win.

View File

@@ -0,0 +1,114 @@
# Plan 3b — Install Modules (Heavy Tier) Design
**Status:** Approved 2026-04-14
**Scope:** Port 2 of the 3 remaining Bash install modules (`neovim`, `renode`). The third (`scaffold`) is deferred to Plan 4, where its functionality is absorbed natively into the `archduino new` and `archduino theme` subcommands rather than shipped as bash helper scripts.
## Goal
Port the `neovim` and `renode` Bash modules to Go, each implementing the `module.Module` four-phase contract, unit-tested against a fake `platform.Runner`. Add one small platform helper (`WriteUserFile`), extend `config.Config` with the new modules' inputs, extend `tui.Theme` so the wizard's theme selection flows into NvChad's `chadrc.lua`, and register the two modules in the install command.
## Architecture
- **neovim**: pacman-install neovim (Arch ships 0.11.x, no upstream-tarball fallback); clone the NvChad starter to `~/.config/nvim` if no existing config; if a non-NvChad config is detected, back it up to `~/.config/nvim.bak.<RFC3339>` and install fresh (no LazyVim-specific salvage — deliberate scope cut); strip the starter's `nvim-lspconfig` block (we use native `vim.lsp`); render `chadrc.lua` from an embedded template with the selected theme's `NvChadTheme`; write five static Lua asset files (`arduino.lua`, `clangd.lua`, `lspconfig.lua`, `dap.lua`, `mappings.lua`); clear NvChad's base46 theme cache.
- **renode**: AUR-install `renode-bin` (via `AURHelper`); pacman-install `arm-none-eabi-gdb`; render and write `~/.local/bin/arduino-sim` from an embedded bash template driven by a platform map in config; download `wokwi-cli` from a version-pinned URL into `~/.local/bin/wokwi-cli` (mode 0755).
- Both modules' artifacts (Lua files, bash helper) live as real files under `internal/modules/<name>/assets/` and are bundled via `go:embed`. No sudo required at runtime; everything lands in user-owned paths.
- One new platform helper, `platform.WriteUserFile(path, data, mode)`, consolidates the repeated `os.MkdirAll + os.WriteFile` pattern and does an atomic tmp+rename write. Plan 3a modules are not retrofitted — they stay as-is.
## Tech Stack
Go stdlib only for the new helper and modules (`embed`, `text/template`, `os`, `path/filepath`). Relies on all Plan 3a platform helpers (pacman, AUR, download, extract, git) and on `internal/module` / `internal/engine` / `internal/tui` from Plans 1-2.
## Files
### New — platform helper
- `internal/platform/writefile.go` + `writefile_test.go`
- `WriteUserFile(path string, data []byte, mode os.FileMode) error`
- Creates parent directory (0755) if missing, writes to `path+".tmp"`, renames to `path`.
- Tests: happy path (content + mode), missing parent dir (creates it), overwrite preserves atomicity (crash-proof against partial writes is out of scope; atomic rename is sufficient).
### New — modules
- `internal/modules/neovim/neovim.go` + `neovim_test.go` + `assets/`
- `assets/chadrc.lua.tmpl` — `text/template` with `{{.NvChadTheme}}`.
- `assets/arduino.lua`, `assets/clangd.lua`, `assets/lspconfig.lua`, `assets/dap.lua`, `assets/mappings.lua` — static, copied verbatim from the bash heredocs in `lib/neovim.sh`.
- Constructor: `New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module`.
- State: `NvimInstalled, StarterCloned, ChadrcPresent, BackupNeeded bool`, `MissingAssets []string`, `ThemeName string` (resolved at Detect time).
- `internal/modules/renode/renode.go` + `renode_test.go` + `assets/`
- `assets/arduino-sim.tmpl` — `text/template` with `{{range .Platforms}}...{{end}}` rendering the case-statement platform map from config.
- Constructor: `New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module`.
- State: `RenodeInstalled, GdbInstalled, SimScriptCurrent, WokwiInstalled bool`.
### Modified
- `internal/config/config.go` + `internal/config/defaults.yaml`
- `type NeovimConfig struct { NvChadStarterRepo string }`
- `type RenodePlatform struct { Name, ReplPath, Machine string }`
- `type RenodeConfig struct { WokwiVersion, WokwiURL string; Platforms []RenodePlatform }`
- `Config` gains `Neovim NeovimConfig` and `Renode RenodeConfig`.
- `merge` gains overlay-replace clauses for both (matching Plan 3a's pattern).
- `defaults.yaml` populated with NvChad starter URL, pinned wokwi version/URL, and the three platform entries from `lib/renode.sh`.
- `internal/tui` (wherever `BuiltinThemes` is declared — likely `internal/tui/state.go` or a themes file)
- `Theme` gains `NvChadTheme string`. Populate for each builtin entry (one-line edits per theme).
- Helper `Theme(themes []Theme, id string) (Theme, bool)` for the neovim module to resolve its themeID. (If an equivalent helper already exists, reuse it.)
- `cmd/archduino/main.go` — `buildRegistry` gains a `selectedThemeID string` parameter; registers neovim and renode. TUI path: call `buildRegistry(cfg, tui.DefaultThemeID())` before the wizard, then after the wizard returns with a theme selection, call it again with the selected ID before invoking the dashboard. Plain-output path: pass `cfg`'s default theme directly.
## Module Contract Details
Both modules use the Plan 3a archetype pattern:
- **Detect** — pure reads: `PackageManager.IsInstalled`, `os.Stat`, byte-compare of rendered/static asset files against their destination paths. For neovim, `~/.config/nvim/lua/chadrc.lua` acts as the NvChad-marker; any `lua/` dir without chadrc triggers `BackupNeeded`.
- **Plan** — emit only missing actions; empty plan = already converged (idempotent).
- **Apply** — sequential actions with `Reporter.Progress`; no action is retried.
- **Verify** — re-run Detect; every boolean must be true and `MissingAssets`/related slices must be empty.
Error semantics unchanged from Plan 3a — engine's `--halt-on-error` and sibling-continuation rules apply.
## Theme Propagation
The wizard's `ThemeID` is captured into `tui.WizardState.ThemeID` as before. `buildRegistry` takes the themeID as a second parameter; for the TUI install path, the registry is rebuilt after the wizard confirms selection and before the dashboard runs. For the plain-output path (`--no-tui` / `--dry-run`), the default theme ID is used. The `neovim` module resolves the ID to a `tui.Theme` at construction and stores the `NvChadTheme` for Detect/Plan. If an unknown ID is passed, neovim falls back to the first builtin theme's NvChad name (no hard error — wizards shouldn't crash the installer on a config bug).
## Testing Strategy
### neovim (all unit, tempdir-based)
- `TestDetectFreshSystem` — no nvim, no config → plans pacman install + clone starter + strip lspconfig + write assets.
- `TestDetectExistingNvChad` — chadrc.lua present → no backup planned; missing-assets test still exercises Plan.
- `TestDetectNonNvChadTriggersBackup` — `lua/` with no chadrc → `BackupNeeded=true`; Plan includes a rename-to-bak action.
- `TestPlanApplyInstallsEverything` — fake Runner + fake GitClient + tempdir HOME; asserts pacman call, git clone call, backup done, assets written with expected content, chadrc contains the theme name.
- `TestPlanAfterApplyIsEmpty` — idempotency.
- `TestUnknownThemeFallsBackToFirst` — construction with a bogus ID still produces a valid chadrc.
### renode (all unit)
- `TestDetectNothingInstalled` — plans AUR install + pacman install + sim-script write + wokwi download.
- `TestDetectAllInstalled` — empty plan.
- `TestApplyWritesSimScript` — rendered script contains each configured platform's case-branch.
- `TestApplyDownloadsWokwi` — httptest server serves a short payload; verify file written to `~/.local/bin/wokwi-cli` with mode 0755.
### platform.WriteUserFile
- `TestWriteUserFileCreatesParent` — target under a non-existent dir → parent created.
- `TestWriteUserFileAtomicity` — two sequential writes of different content → final content matches the second; no `.tmp` leaked after success.
- `TestWriteUserFileErrorCleanup` — a failed write leaves no `.tmp` turd (simulate via read-only parent or unwritable tmp; accept either form if OS makes it awkward).
No integration tests against real pacman, yay, neovim, renode, or GitHub; that's Plan 4's CI.
## Non-Goals
- `scaffold` module — deferred to Plan 4 as native Go subcommands (`archduino new`, `archduino theme`).
- Upstream Neovim tarball fallback (`/opt/nvim-linux-x86_64`) — pacman is sufficient; version-drift concerns go to Plan 4's `doctor`.
- LazyVim → NvChad plugin salvage — existing non-NvChad configs get a full backup; user re-adds plugins manually.
- Wokwi GitHub-API "latest" lookup — version is pinned in config, overrideable via overlay.
- Retrofitting Plan 3a modules to use `WriteUserFile` — opportunistic cleanup only, not a Plan 3b deliverable.
- Retroactive theme-sync across every tool (starship, zellij, yazi, lazygit) — Plan 4's `archduino theme` subcommand.
## Open Risks / Watchpoints
- **NvChad starter drift** — if upstream changes `lua/plugins/init.lua`'s `nvim-lspconfig` block format, our sed-equivalent strip-logic in Go will miss it. Mitigation: implement as "find the `{` / `},` block containing the string `neovim/nvim-lspconfig` and delete it", unit-test against a fixture copy of the current starter's `init.lua`.
- **arduino-sim pinned to Renode's REPL paths** — if Renode renames `platforms/cpus/rp2040.repl`, the sim script breaks. Acceptable for v1; surface via Plan 4 doctor.
- **Theme propagation race** — rebuilding the registry twice around the wizard is ugly. Alternative considered (module-level `SetTheme` after construction) was rejected as un-idiomatic. Acceptable for 2 modules; revisit if more theme-aware modules appear.
- **Asset byte-equality idempotency** — if a user edits their `mappings.lua` by hand, every Apply will overwrite it. Documented behavior; `--force-assets` flag is a Plan 4 concern.

View File

@@ -0,0 +1,200 @@
# Plan 4a — User-Facing Subcommands Design
**Status:** Approved 2026-04-14
**Scope:** Ship three new subcommands — `archduino theme`, `archduino new`, `archduino doctor` — plus the shared `theming` package they all build on. This plan absorbs the functionality of `lib/scaffold.sh` (originally deferred from Plan 3b) and `lib/themes/*.sh` natively into Go, without shipping bash wrappers.
Plan 4b (reinstall/reset mode), 4c (bootstrap + goreleaser), and 4d (CI) are separate plans with independent specs.
## Goal
Complete user-facing feature parity with the Bash installer by adding three subcommands: a theme switcher that updates starship/zellij/yazi/lazygit/NvChad configs in place; a project scaffolder for Arduino sketches; and a doctor command that reports module convergence and pinned-tool version drift.
## Architecture
One new shared package and two subcommand packages:
- **`internal/theming/`** — reusable package that embeds per-theme config assets (5 themes × 4 tool configs + a manifest each) and exposes `List() []Theme`, `Get(id) (Theme, bool)`, and `Apply(cfg, themeID) error`. Called from both the `install` command (applies the wizard's selected theme at the end of an install run) and the new `theme` subcommand.
- **`internal/subcommands/newproject/`** — owns `archduino new <project> <board>`. Board list lives in `config.Config.Scaffolding.Boards`; `.ino` starter templates embedded.
- **`internal/subcommands/doctor/`** — owns `archduino doctor`. Iterates the module registry, calls `Detect` + `Plan` on each, reports per-module status and pinned-tool version drift.
`tui.BuiltinThemes` and the `tui.Theme.NvChadTheme` field retire: the canonical theme list is now discovered from `theming`'s embedded manifests. The wizard calls `theming.List()`. `tui.Theme` stays as a thin display value (`ID`, `Name`) used by the wizard's dropdown.
The existing `install` command gains a final step: after `engine.Run` succeeds, call `theming.Apply(cfg, finalState.ThemeID)` so the initial install leaves tools themed, not just installed. This is an improvement over the Bash behavior, where the theme was never applied during install.
## Tech Stack
Go stdlib + existing deps. `go:embed` for all assets. `gopkg.in/yaml.v3` for overlay round-trip updates. `huh` (already a dep) for the interactive theme picker. `text/template` used only where needed (neovim chadrc already uses it; theming's tool configs are static and don't).
## Files
### New — `internal/theming/`
- `theming.go`
- Exported: `type Theme struct { ID, DisplayName, NvChadTheme string }`
- `func List() []Theme` — returns themes in manifest-discovery order (sort by ID for determinism).
- `func Get(id string) (Theme, bool)`
- `func Apply(cfg config.Config, themeID string) error`
- Private: `discoverThemes()` run once at package init; reads `assets/*/theme.yaml`.
- `theming_test.go`
- `assets/<theme>/theme.yaml` — five files (`gruvbox`, `catppuccin`, `tokyonight`, `nord`, `dracula`), each containing:
```yaml
display_name: "Gruvbox Dark"
nvchad: "gruvbox"
```
- `assets/<theme>/starship.toml`, `zellij.kdl`, `yazi.toml`, `lazygit.yml` — 20 static config files lifted verbatim from the corresponding `write_*_config` heredocs in `lib/themes/<theme>.sh`. Tool config paths on disk are:
- starship.toml → `~/.config/starship.toml`
- zellij.kdl → `~/.config/zellij/config.kdl`
- yazi.toml → `~/.config/yazi/theme.toml`
- lazygit.yml → `~/.config/lazygit/config.yml`
### New — `internal/subcommands/newproject/`
- `newproject.go`
- `func Run(cfg config.Config, project, board string) error`.
- `newproject_test.go`
- `assets/<board>.ino` — six starter sketches (`uno`, `mega`, `nano`, `esp32`, `esp32s3`, `esp8266`) plus `default.ino`. Content lifted from `lib/scaffold.sh` heredocs.
### New — `internal/subcommands/doctor/`
- `doctor.go`
- `func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) (exitCode int)`
- `doctor_test.go`
### Modified
- `internal/config/config.go` + `internal/config/defaults.yaml`
- Add:
```go
type Board struct { Name, FQBN, Template string `yaml:"name,fqbn,template"` }
type Scaffolding struct { Boards []Board `yaml:"boards"` }
```
(adjust tag syntax — yaml tags per field as with existing types).
- `Config.Scaffolding Scaffolding`
- `merge` clause: `if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }`.
- Defaults: the 6 boards from `lib/scaffold.sh` FQBN_MAP with `template` set to the board name (so `template: "uno"` maps to `internal/subcommands/newproject/assets/uno.ino`).
- `internal/modules/neovim/neovim.go`
- Extract the chadrc render+write logic into an exported helper the theming package can call. Suggested signature: `func RewriteChadrc(themeID string, themes []Theme) error` (takes themes so the module doesn't depend on the theming package — avoid import cycle). Or simpler: `func WriteChadrc(nvChadThemeName string) error` — takes the already-resolved NvChad name, no theme-registry knowledge needed. The helper no-ops if `~/.config/nvim/lua/chadrc.lua` doesn't exist.
- `internal/tui/state.go`
- Remove `BuiltinThemes`, remove `NvChadTheme` field from `Theme`, remove `ThemeByID`. Wizard takes a `[]Theme` parameter or reads from `theming.List()` directly.
- Option: delete `tui.Theme` entirely and have the wizard use `theming.Theme` directly. Weigh the import coupling: `internal/tui` already imports `internal/config`, and `theming` imports `config` too, so `tui → theming` is fine.
- `internal/tui/wizard/wizard.go`
- Populate the theme dropdown from `theming.List()`.
- `cmd/archduino/main.go`
- Add three `cobra.Command` constructors: `newThemeCmd()`, `newProjectCmd()`, `newDoctorCmd()`.
- Register them on the root command alongside `install`.
- In the `install` RunE, after `engine.Run` returns success, call `theming.Apply(cfg, finalState.ThemeID)` and surface any error.
- `buildRegistry` no longer needs to receive `themeID` for neovim's chadrc — neovim's install path writes chadrc based on the ID threaded through `neovim.New`, and `theming.Apply` overwrites it afterward. Keeping the parameter is fine; it remains useful for the initial chadrc write.
## Command surfaces
- `archduino theme` → interactive huh-based picker listing themes; pre-selects `cfg.ThemeOverrides["__active"]` (or the first theme if unset). On confirm, calls `theming.Apply`.
- `archduino theme <id>` → non-interactive; calls `theming.Apply(cfg, id)`. Errors if unknown.
- `archduino theme list` → prints `<id> — <display_name>` one per line to stdout.
- `archduino new <project> <board>` → see data-flow below.
- `archduino doctor` → see data-flow below.
## Data Flow
### theming.Apply
1. Look up theme by ID; return error if unknown (message lists valid IDs).
2. For each of the four tool configs under `assets/<theme>/`, read via `embed.FS` and write to its target path via `platform.WriteUserFile` with mode 0o644.
3. If `~/.config/nvim/lua/chadrc.lua` exists, call `neovim.WriteChadrc(theme.NvChadTheme)` to re-render it. If it doesn't exist, skip silently (user hasn't run install yet, or removed nvim config).
4. Idempotently clear NvChad's theme cache: `os.RemoveAll(~/.cache/nvim/base46)` and `os.RemoveAll(~/.local/share/nvim/lazy/base46/lua/base46/themes)`. Errors ignored — cache may not exist.
5. Update the user's overlay file at `~/.config/archduino/overlay.yaml`: load existing YAML (or empty), set `theme_overrides.__active: <id>`, write back via a YAML round-trip using `gopkg.in/yaml.v3` decode-into-map-then-encode. First-run creates the file with the single key.
### newproject.Run
1. Validate `project`: non-empty, no `/` or `\\`, doesn't start with `.`.
2. Look up board in `cfg.Scaffolding.Boards`. If absent, return an error whose message lists all valid board names, one per line.
3. `os.Stat(project)`; if it exists, return "project directory already exists". Do not overwrite.
4. `os.MkdirAll(project, 0o755)`.
5. Determine `.ino` asset path: `<board.Template>.ino`, falling back to `default.ino` if the board-specific asset is missing. Write to `<project>/<project>.ino` via `platform.WriteUserFile` mode 0o644.
6. Write `<project>/sketch.yaml`:
```yaml
default_fqbn: <FQBN>
default_port: auto
```
7. Write `<project>/.clangd`:
```yaml
CompileFlags:
Add:
- -xc++
- -std=c++17
```
8. Print a success line to stdout: `Created <project>/ (fqbn: <FQBN>). Next: cd <project> && nvim <project>.ino`.
### doctor.Run
1. For each module in `reg.All()`: call `Detect(ctx)`, then `Plan(ctx, state)`. Format output one row per module:
- `[✓] <id>` — empty plan (converged).
- `[✗] <id> (N actions pending)` — non-empty plan.
- `[!] <id> — detect failed: <err>` — Detect/Plan returned error. Never propagate the error up; record and continue.
2. Version drift checks (only if `arduino-cli` / `wokwi-cli` are found on PATH via the injected `Runner`):
- `arduino-cli version --format json` → parse `VersionString`, compare to `cfg.ArduinoCLI.Version`. Print `[✓] arduino-cli <ver>` or `[!] arduino-cli drift: got <X>, want <Y>`.
- `wokwi-cli --version` → parse first line's version-like token, compare to `cfg.Renode.WokwiVersion`. Same output format.
3. Summary line: `<converged>/<total> converged, <pending> need-install, <drift> drift, <errors> errors`.
4. Exit code: 0 if all modules converged AND no drift AND no errors; else 1. Return value from `Run` is the exit code; the cobra RunE wrapper calls `os.Exit(code)`.
No network calls. No writes. Fast enough to run interactively (<5s on a typical install).
## Error Handling
- `theming.Apply`: first write error aborts. No rollback. User re-runs after fixing (partial state is a benign overlap of two themes' configs on disk until re-run). Overlay update happens last; if a config write fails, the overlay is not touched, so `__active` still reflects the previously-applied theme.
- `newproject.Run`: refuses to overwrite an existing project directory. All other failures (mkdir, write) bubble up to the cobra layer and print via `RunE`'s default error handling.
- `doctor.Run`: never returns a Go error. Module failures are reported inline; only a programmer error (e.g., nil registry) would panic.
## Testing
### theming_test.go (all unit, tempdir-based $HOME)
- `TestListReadsManifests` — asserts 5 themes discovered, each with non-empty DisplayName and NvChadTheme.
- `TestGetKnownAndUnknown` — happy path + false return for bogus ID.
- `TestApplyWritesAllFourConfigs` — `t.Setenv("HOME", …)`; `Apply(cfg, "dracula")`; assert each of the 4 target files exists with non-empty content and the first line matches the asset's first line (robust to copy-paste drift in tests).
- `TestApplyUpdatesOverlayActive` — after Apply, `~/.config/archduino/overlay.yaml` contains `theme_overrides.__active: dracula` (round-trip through `gopkg.in/yaml.v3`).
- `TestApplyPreservesOtherOverlayKeys` — seed overlay with `extra_packages: [foo]`, run Apply, assert `extra_packages` still present after write.
- `TestApplyRewritesChadrcIfPresent` — seed `~/.config/nvim/lua/chadrc.lua` with gruvbox; Apply dracula; assert file now contains `theme = "dracula"`.
- `TestApplyNoChadrcIsFine` — no chadrc on disk; Apply succeeds.
- `TestApplyUnknownThemeErrors`.
### newproject_test.go
- `TestNewCreatesProject` — config with `uno` board; project dir doesn't exist; `Run(cfg, "blinky", "uno")` in a tempdir (chdir); assert `blinky/blinky.ino` + `sketch.yaml` (contains `arduino:avr:uno`) + `.clangd` exist.
- `TestNewRefusesExistingDir` — pre-create the target dir; `Run` returns error; dir untouched.
- `TestNewFallsBackToDefaultTemplate` — config board entry whose `.template` points at an asset that doesn't exist (use a made-up board with `template: "xyz"`); Run succeeds and the generated `.ino` matches `default.ino` content.
- `TestNewUnknownBoardErrors` — error message lists all valid board names.
- `TestNewRejectsSlashInProjectName`.
### doctor_test.go
- `TestDoctorAllConvergedExitsZero` — single stub module returning empty Plan; runner that makes version commands not-found (`exec: "arduino-cli": executable file not found in $PATH`-style error — skip version checks cleanly). Exit code 0.
- `TestDoctorDivergedExitsOne` — single stub module returning a Plan with actions; exit code 1.
- `TestDoctorDetectErrorIsReported` — module whose Detect returns an error; doctor prints `[!]` and exits 1.
- `TestDoctorArduinoCLIDriftIsReported` — injected Runner returns a different version for `arduino-cli version --format json`; exit code 1; output contains "drift".
- `TestDoctorMissingBinariesSkipVersionCheck` — runner returns "not found"; no false drift report; exit reflects module status only.
### install-command integration (smoke-level)
- Existing `install` tests continue to pass; add a unit test that stubs `theming.Apply` and asserts `install` calls it after a successful `engine.Run` with the wizard's ThemeID.
No integration tests against real starship/zellij/arduino-cli/wokwi binaries — Plan 4d (CI) introduces those.
## Non-Goals
- Per-user theme creation / custom palettes — pinned to the five built-in themes.
- Live reload: users must restart their shell, zellij session, etc. manually (bash also required this).
- `archduino doctor --fix` mode — reserved for a future plan, possibly merged with Plan 4b's reinstall/reset.
- Version-drift remediation — doctor only reports.
- `archduino new` support for non-Arduino-CLI ecosystems (PlatformIO, etc.).
## Open Risks / Watchpoints
- **NvChad chadrc extraction:** pulling the render-and-write out of `neovim.Module.Plan`'s closures into a free function requires care — currently that closure is built inside `Plan`. Acceptable fix: factor a `writeChadrc(cfg string, nvChadTheme string) error` free function in the neovim package, have both the Plan action and the new `WriteChadrc` export use it.
- **tui.Theme retirement:** wizard test coverage is decent but `BuiltinThemes` is referenced in `internal/tui/state_test.go`; removing the symbol requires updating those tests. Grep before deleting.
- **Overlay YAML round-trip:** `gopkg.in/yaml.v3`'s node-based decoder preserves comments and order; using `map[string]any` decode/encode will lose both. Acceptable for v1 (the user's overlay is config, not hand-commented prose); document the behavior in the `theming.Apply` godoc.
- **Doctor version parsing:** `arduino-cli version --format json` shape is stable, but `wokwi-cli --version`'s output format is upstream-controlled. A tiny regex `v?\d+\.\d+\.\d+` covers the common case; if it grows fragile, fall back to byte-for-byte compare with `cfg.Renode.WokwiVersion`.
- **Theme-applied-during-install is a behavior change** vs. Bash. It means a fresh install writes tool configs. If the user had pre-existing starship/zellij configs they liked, they get overwritten. Mitigation: `install` preserves a `.bak.<timestamp>` copy before the theme apply step. Document clearly in the install command's help text.

42
go.mod Normal file
View File

@@ -0,0 +1,42 @@
module gitlab.hive-brain.de/MDietermann/Archduino
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/colorprofile v0.3.2 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

88
go.sum Normal file
View File

@@ -0,0 +1,88 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913 h1:MvJqk9htSLLxNlNQRqfRMhObcytCT4+xfO4830kNlVs=
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

179
internal/config/config.go Normal file
View File

@@ -0,0 +1,179 @@
// Package config loads embedded defaults and optionally merges a user
// overlay from ~/.config/archduino/overlay.yaml.
package config
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
//go:embed defaults.yaml
var defaultsYAML []byte
type Packages struct {
Base []string `yaml:"base"`
}
type ArduinoCLIConfig struct {
Version string `yaml:"version"`
URL string `yaml:"url"`
Cores []string `yaml:"cores"`
}
type ZshPlugin struct {
Name string `yaml:"name"`
Repo string `yaml:"repo"`
}
type ZshConfig struct {
OhMyZshRepo string `yaml:"oh_my_zsh_repo"`
Plugins []ZshPlugin `yaml:"plugins"`
}
type ZellijConfig struct {
ConfigURL string `yaml:"config_url"`
}
type ThemesConfig struct {
Source string `yaml:"source"`
}
type ShellConfigBlock struct {
Name string `yaml:"name"`
File string `yaml:"file"`
Content string `yaml:"content"`
}
type ShellConfigSection struct {
Blocks []ShellConfigBlock `yaml:"blocks"`
}
type NeovimConfig struct {
NvChadStarterRepo string `yaml:"nvchad_starter_repo"`
}
type RenodePlatform struct {
Name string `yaml:"name"`
ReplPath string `yaml:"repl_path"`
Machine string `yaml:"machine"`
}
type RenodeConfig struct {
WokwiVersion string `yaml:"wokwi_version"`
WokwiURL string `yaml:"wokwi_url"`
Platforms []RenodePlatform `yaml:"platforms"`
}
type Board struct {
Name string `yaml:"name"`
FQBN string `yaml:"fqbn"`
Template string `yaml:"template"`
}
type Scaffolding struct {
Boards []Board `yaml:"boards"`
}
// Config is the fully-resolved runtime configuration.
type Config struct {
Version int `yaml:"version"`
ExtraPackages []string `yaml:"extra_packages"`
DisabledModules []string `yaml:"disabled_modules"`
ThemeOverrides map[string]string `yaml:"theme_overrides"`
Packages Packages `yaml:"packages"`
Fonts []string `yaml:"fonts"`
ArduinoCLI ArduinoCLIConfig `yaml:"arduino_cli"`
Zsh ZshConfig `yaml:"zsh"`
Zellij ZellijConfig `yaml:"zellij"`
Themes ThemesConfig `yaml:"themes"`
ShellConfig ShellConfigSection `yaml:"shell_config"`
Neovim NeovimConfig `yaml:"neovim"`
Renode RenodeConfig `yaml:"renode"`
Scaffolding Scaffolding `yaml:"scaffolding"`
}
const currentVersion = 1
// Load returns defaults merged with the overlay at overlayPath (if present).
// If overlayPath is "", the default location (~/.config/archduino/overlay.yaml)
// is used. A missing overlay is not an error.
func Load(overlayPath string) (Config, error) {
var cfg Config
if err := yaml.Unmarshal(defaultsYAML, &cfg); err != nil {
return Config{}, fmt.Errorf("parse embedded defaults: %w", err)
}
if overlayPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return cfg, nil
}
overlayPath = filepath.Join(home, ".config", "archduino", "overlay.yaml")
}
data, err := os.ReadFile(overlayPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return Config{}, fmt.Errorf("read overlay %s: %w", overlayPath, err)
}
var overlay Config
if err := yaml.Unmarshal(data, &overlay); err != nil {
return Config{}, fmt.Errorf("parse overlay %s: %w", overlayPath, err)
}
if overlay.Version != 0 && overlay.Version != currentVersion {
return Config{}, fmt.Errorf("overlay %s: unsupported version %d (want %d)", overlayPath, overlay.Version, currentVersion)
}
return merge(cfg, overlay), nil
}
func merge(base, over Config) Config {
out := base
if len(over.ExtraPackages) > 0 {
out.ExtraPackages = append(append([]string{}, base.ExtraPackages...), over.ExtraPackages...)
}
if len(over.DisabledModules) > 0 {
out.DisabledModules = append(append([]string{}, base.DisabledModules...), over.DisabledModules...)
}
if len(over.ThemeOverrides) > 0 {
merged := make(map[string]string, len(base.ThemeOverrides)+len(over.ThemeOverrides))
for k, v := range base.ThemeOverrides {
merged[k] = v
}
for k, v := range over.ThemeOverrides {
merged[k] = v
}
out.ThemeOverrides = merged
}
if len(over.Packages.Base) > 0 {
out.Packages = over.Packages
}
if len(over.Fonts) > 0 {
out.Fonts = over.Fonts
}
if over.ArduinoCLI.Version != "" {
out.ArduinoCLI = over.ArduinoCLI
}
if over.Zsh.OhMyZshRepo != "" {
out.Zsh = over.Zsh
}
if over.Zellij.ConfigURL != "" {
out.Zellij = over.Zellij
}
if over.Themes.Source != "" {
out.Themes = over.Themes
}
if len(over.ShellConfig.Blocks) > 0 {
out.ShellConfig = over.ShellConfig
}
if over.Neovim.NvChadStarterRepo != "" {
out.Neovim = over.Neovim
}
if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 {
out.Renode = over.Renode
}
if len(over.Scaffolding.Boards) > 0 {
out.Scaffolding = over.Scaffolding
}
return out
}

View File

@@ -0,0 +1,133 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaultsWhenNoOverlay(t *testing.T) {
dir := t.TempDir()
cfg, err := Load(filepath.Join(dir, "missing.yaml"))
if err != nil {
t.Fatal(err)
}
if cfg.Version != 1 {
t.Errorf("version = %d", cfg.Version)
}
if len(cfg.ExtraPackages) != 0 {
t.Errorf("extra_packages = %v", cfg.ExtraPackages)
}
}
func TestLoadMergesOverlay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "overlay.yaml")
content := []byte(`
version: 1
extra_packages: [htop, ncdu]
disabled_modules: [renode]
theme_overrides:
starship: /tmp/mine.toml
`)
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if got := cfg.ExtraPackages; len(got) != 2 || got[0] != "htop" || got[1] != "ncdu" {
t.Errorf("extra_packages = %v", got)
}
if len(cfg.DisabledModules) != 1 || cfg.DisabledModules[0] != "renode" {
t.Errorf("disabled_modules = %v", cfg.DisabledModules)
}
if cfg.ThemeOverrides["starship"] != "/tmp/mine.toml" {
t.Errorf("theme_overrides = %v", cfg.ThemeOverrides)
}
}
func TestDefaultsCarryModuleInputs(t *testing.T) {
cfg, err := Load("/nonexistent/overlay.yaml")
if err != nil {
t.Fatal(err)
}
if len(cfg.Packages.Base) == 0 {
t.Error("Packages.Base empty")
}
if len(cfg.Fonts) == 0 {
t.Error("Fonts empty")
}
if cfg.ArduinoCLI.Version == "" {
t.Error("ArduinoCLI.Version empty")
}
if cfg.ArduinoCLI.URL == "" {
t.Error("ArduinoCLI.URL empty")
}
if cfg.Zsh.OhMyZshRepo == "" {
t.Error("Zsh.OhMyZshRepo empty")
}
if len(cfg.ShellConfig.Blocks) == 0 {
t.Error("ShellConfig.Blocks empty")
}
}
func TestDefaultsCarryPlan3bInputs(t *testing.T) {
cfg, err := Load("/nonexistent/overlay.yaml")
if err != nil {
t.Fatal(err)
}
if cfg.Neovim.NvChadStarterRepo == "" {
t.Error("Neovim.NvChadStarterRepo empty")
}
if cfg.Renode.WokwiVersion == "" {
t.Error("Renode.WokwiVersion empty")
}
if cfg.Renode.WokwiURL == "" {
t.Error("Renode.WokwiURL empty")
}
if len(cfg.Renode.Platforms) == 0 {
t.Error("Renode.Platforms empty")
}
want := map[string]string{"stm32f4": "STM32F4_Discovery", "rp2040": "RP2040", "nrf52840": "nRF52840"}
for _, p := range cfg.Renode.Platforms {
if want[p.Name] != p.Machine {
t.Errorf("platform %q: machine=%q want %q", p.Name, p.Machine, want[p.Name])
}
}
}
func TestLoadRejectsUnknownVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "overlay.yaml")
if err := os.WriteFile(path, []byte("version: 99\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("expected version error")
}
}
func TestDefaultsCarryScaffoldingBoards(t *testing.T) {
cfg, err := Load("/nonexistent/overlay.yaml")
if err != nil {
t.Fatal(err)
}
if len(cfg.Scaffolding.Boards) < 6 {
t.Fatalf("want >= 6 boards, got %d", len(cfg.Scaffolding.Boards))
}
want := map[string]string{
"uno": "arduino:avr:uno", "mega": "arduino:avr:mega", "nano": "arduino:avr:nano",
"esp32": "esp32:esp32:esp32", "esp32s3": "esp32:esp32:esp32s3", "esp8266": "esp8266:esp8266:nodemcuv2",
}
got := map[string]string{}
for _, b := range cfg.Scaffolding.Boards {
got[b.Name] = b.FQBN
}
for name, fqbn := range want {
if got[name] != fqbn {
t.Errorf("board %q: got fqbn %q, want %q", name, got[name], fqbn)
}
}
}

View File

@@ -0,0 +1,108 @@
version: 1
extra_packages: []
disabled_modules: []
theme_overrides: {}
packages:
base:
- base-devel
- git
- curl
- wget
- unzip
- zip
- tree
- htop
- fastfetch
- ripgrep
- fd
- bat
- eza
- jq
- zsh
- clang
- ca-certificates
fonts:
- ttf-jetbrains-mono-nerd
arduino_cli:
version: "0.35.3"
url: "https://github.com/arduino/arduino-cli/releases/download/v0.35.3/arduino-cli_0.35.3_Linux_64bit.tar.gz"
cores:
- arduino:avr
zsh:
oh_my_zsh_repo: "https://github.com/ohmyzsh/ohmyzsh.git"
plugins:
- name: zsh-autosuggestions
repo: https://github.com/zsh-users/zsh-autosuggestions
- name: zsh-syntax-highlighting
repo: https://github.com/zsh-users/zsh-syntax-highlighting.git
zellij:
config_url: ""
themes:
source: "lib/themes"
shell_config:
blocks:
- name: archduino-path
file: ".zshrc"
content: |
export PATH="$HOME/.local/bin:$PATH"
- name: archduino-editor
file: ".zshrc"
content: |
export EDITOR="nvim"
export VISUAL="nvim"
- name: archduino-starship
file: ".zshrc"
content: |
eval "$(starship init zsh)"
- name: archduino-zellij
file: ".zshrc"
content: |
if [[ -z "$ZELLIJ" ]]; then
zellij attach --create archduino
fi
- name: archduino-aliases
file: ".zshrc"
content: |
alias adev="zellij --layout arduino"
alias ac="arduino-cli"
alias acc="arduino-cli compile"
alias acu="arduino-cli upload"
alias acm="arduino-cli monitor"
alias acb="arduino-cli board list"
alias lg="lazygit"
alias v="nvim"
alias y="yazi"
alias theme="archduino-theme"
neovim:
nvchad_starter_repo: "https://github.com/NvChad/starter"
renode:
wokwi_version: "0.26.1"
wokwi_url: "https://github.com/wokwi/wokwi-cli/releases/download/v0.26.1/wokwi-cli-linuxstatic-x64"
platforms:
- name: stm32f4
repl_path: "platforms/boards/stm32f4_discovery.repl"
machine: "STM32F4_Discovery"
- name: rp2040
repl_path: "platforms/cpus/rp2040.repl"
machine: "RP2040"
- name: nrf52840
repl_path: "platforms/cpus/nrf52840.repl"
machine: "nRF52840"
scaffolding:
boards:
- {name: uno, fqbn: "arduino:avr:uno", template: uno}
- {name: mega, fqbn: "arduino:avr:mega", template: mega}
- {name: nano, fqbn: "arduino:avr:nano", template: nano}
- {name: esp32, fqbn: "esp32:esp32:esp32", template: esp32}
- {name: esp32s3, fqbn: "esp32:esp32:esp32s3", template: esp32s3}
- {name: esp8266, fqbn: "esp8266:esp8266:nodemcuv2", template: esp8266}

91
internal/engine/dag.go Normal file
View File

@@ -0,0 +1,91 @@
package engine
import (
"fmt"
"sort"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// TopoSort returns module IDs in a valid dependency order.
// Ties are broken alphabetically for deterministic output.
// Returns an error on unknown deps or cycles.
func TopoSort(metas []module.Meta) ([]string, error) {
known := make(map[string]bool, len(metas))
for _, m := range metas {
known[m.ID] = true
}
for _, m := range metas {
for _, d := range m.Deps {
if !known[d] {
return nil, fmt.Errorf("module %q depends on unknown %q", m.ID, d)
}
}
}
// Kahn's algorithm with alphabetical tie-breaking.
indeg := make(map[string]int, len(metas))
children := make(map[string][]string, len(metas))
for _, m := range metas {
if _, ok := indeg[m.ID]; !ok {
indeg[m.ID] = 0
}
for _, d := range m.Deps {
indeg[m.ID]++
children[d] = append(children[d], m.ID)
}
}
var ready []string
for id, d := range indeg {
if d == 0 {
ready = append(ready, id)
}
}
sort.Strings(ready)
var order []string
for len(ready) > 0 {
next := ready[0]
ready = ready[1:]
order = append(order, next)
kids := append([]string(nil), children[next]...)
sort.Strings(kids)
for _, c := range kids {
indeg[c]--
if indeg[c] == 0 {
ready = append(ready, c)
sort.Strings(ready)
}
}
}
if len(order) != len(metas) {
return nil, fmt.Errorf("dependency cycle detected among modules")
}
return order, nil
}
// Descendants returns the set of module IDs transitively downstream of root.
// Root itself is NOT included.
func Descendants(metas []module.Meta, root string) map[string]bool {
children := make(map[string][]string)
for _, m := range metas {
for _, d := range m.Deps {
children[d] = append(children[d], m.ID)
}
}
out := make(map[string]bool)
var walk func(string)
walk = func(id string) {
for _, c := range children[id] {
if out[c] {
continue
}
out[c] = true
walk(c)
}
}
walk(root)
return out
}

View File

@@ -0,0 +1,79 @@
package engine
import (
"reflect"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
func TestTopoSort(t *testing.T) {
cases := []struct {
name string
metas []module.Meta
want []string
err bool
}{
{
name: "linear chain",
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"b"}}},
want: []string{"a", "b", "c"},
},
{
name: "independent modules sorted alphabetically",
metas: []module.Meta{{ID: "c"}, {ID: "a"}, {ID: "b"}},
want: []string{"a", "b", "c"},
},
{
name: "diamond",
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"a"}}, {ID: "d", Deps: []string{"b", "c"}}},
want: []string{"a", "b", "c", "d"},
},
{
name: "unknown dep",
metas: []module.Meta{{ID: "a", Deps: []string{"missing"}}},
err: true,
},
{
name: "cycle",
metas: []module.Meta{{ID: "a", Deps: []string{"b"}}, {ID: "b", Deps: []string{"a"}}},
err: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := TopoSort(tc.metas)
if tc.err {
if err == nil {
t.Fatalf("want error, got nil (order=%v)", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("order = %v, want %v", got, tc.want)
}
})
}
}
func TestDescendants(t *testing.T) {
metas := []module.Meta{
{ID: "a"},
{ID: "b", Deps: []string{"a"}},
{ID: "c", Deps: []string{"a"}},
{ID: "d", Deps: []string{"b"}},
{ID: "e"}, // independent
}
got := Descendants(metas, "a")
want := map[string]bool{"b": true, "c": true, "d": true}
if !reflect.DeepEqual(got, want) {
t.Fatalf("descendants(a) = %v, want %v", got, want)
}
if len(Descendants(metas, "e")) != 0 {
t.Fatalf("descendants(e) should be empty")
}
}

187
internal/engine/engine.go Normal file
View File

@@ -0,0 +1,187 @@
package engine
import (
"context"
"errors"
"fmt"
"sort"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// RunOptions controls a single engine run.
type RunOptions struct {
Selected []string // module IDs to run; empty = all
DryRun bool // Detect + Plan only, no Apply/Verify
HaltOnError bool // stop at first failure instead of continuing siblings
Reporter module.Reporter
}
// ModuleStatus is the terminal state of a module within a run.
type ModuleStatus string
const (
StatusSucceeded ModuleStatus = "succeeded"
StatusFailed ModuleStatus = "failed"
StatusSkipped ModuleStatus = "skipped"
StatusPlanned ModuleStatus = "planned" // dry-run only
)
// ModuleResult captures what happened for one module.
type ModuleResult struct {
ID string
Status ModuleStatus
Plan module.Plan
Err error
}
// Summary aggregates results.
type Summary struct {
Results []ModuleResult
}
// Counts returns (succeeded, failed, skipped).
func (s Summary) Counts() (int, int, int) {
var ok, fail, skip int
for _, r := range s.Results {
switch r.Status {
case StatusSucceeded, StatusPlanned:
ok++
case StatusFailed:
fail++
case StatusSkipped:
skip++
}
}
return ok, fail, skip
}
// Run drives the selected modules through the four phases in DAG order.
// Parallelism is intentionally omitted in this foundation; a later plan
// will introduce it. Sequential execution is a correct baseline.
func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, error) {
mods, err := resolveSelection(reg, opts.Selected)
if err != nil {
return Summary{}, err
}
metas := make([]module.Meta, 0, len(mods))
for _, m := range mods {
metas = append(metas, m.Meta())
}
order, err := TopoSort(metas)
if err != nil {
return Summary{}, fmt.Errorf("module graph: %w", err)
}
byID := make(map[string]module.Module, len(mods))
for _, m := range mods {
byID[m.Meta().ID] = m
}
results := make(map[string]*ModuleResult, len(mods))
skipped := make(map[string]bool)
for _, id := range order {
if skipped[id] {
results[id] = &ModuleResult{ID: id, Status: StatusSkipped, Err: errors.New("skipped: upstream failed")}
continue
}
m := byID[id]
res := &ModuleResult{ID: id}
results[id] = res
state, err := m.Detect(ctx)
if err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("detect: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
plan, err := m.Plan(ctx, state)
if err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("plan: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
res.Plan = plan
if opts.DryRun {
res.Status = StatusPlanned
continue
}
if err := m.Apply(ctx, plan, opts.Reporter); err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("apply: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
if err := m.Verify(ctx); err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("verify: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
res.Status = StatusSucceeded
}
summary := Summary{}
// Emit results in topo order for stable output.
ids := make([]string, 0, len(results))
for id := range results {
ids = append(ids, id)
}
sort.SliceStable(ids, func(i, j int) bool {
return indexOf(order, ids[i]) < indexOf(order, ids[j])
})
for _, id := range ids {
summary.Results = append(summary.Results, *results[id])
}
return summary, nil
}
func resolveSelection(reg *module.Registry, selected []string) ([]module.Module, error) {
all := reg.All()
if len(selected) == 0 {
return all, nil
}
out := make([]module.Module, 0, len(selected))
for _, id := range selected {
m, ok := reg.Get(id)
if !ok {
return nil, fmt.Errorf("unknown module %q", id)
}
out = append(out, m)
}
return out, nil
}
func markFailed(metas []module.Meta, id string, skipped map[string]bool) {
for d := range Descendants(metas, id) {
skipped[d] = true
}
}
func indexOf(s []string, v string) int {
for i, x := range s {
if x == v {
return i
}
}
return -1
}

View File

@@ -0,0 +1,146 @@
package engine
import (
"context"
"errors"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// scriptedModule lets tests control what each phase returns.
type scriptedModule struct {
id string
deps []string
detect func() (module.State, error)
plan func() (module.Plan, error)
apply func() error
verify func() error
}
func (s scriptedModule) Meta() module.Meta { return module.Meta{ID: s.id, Name: s.id, Deps: s.deps} }
func (s scriptedModule) Detect(ctx context.Context) (module.State, error) {
if s.detect != nil {
return s.detect()
}
return nil, nil
}
func (s scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) {
if s.plan != nil {
return s.plan()
}
return module.Plan{}, nil
}
func (s scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error {
if s.apply != nil {
return s.apply()
}
return nil
}
func (s scriptedModule) Verify(ctx context.Context) error {
if s.verify != nil {
return s.verify()
}
return nil
}
func newReg(t *testing.T, mods ...module.Module) *module.Registry {
t.Helper()
r := module.NewRegistry()
for _, m := range mods {
if err := r.Register(m); err != nil {
t.Fatal(err)
}
}
return r
}
func TestRunAllSucceed(t *testing.T) {
reg := newReg(t,
scriptedModule{id: "a"},
scriptedModule{id: "b", deps: []string{"a"}},
)
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
ok, fail, skip := sum.Counts()
if ok != 2 || fail != 0 || skip != 0 {
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
}
if sum.Results[0].ID != "a" || sum.Results[1].ID != "b" {
t.Fatalf("order = %v", sum.Results)
}
}
func TestRunDryRunDoesNotApply(t *testing.T) {
applied := false
reg := newReg(t, scriptedModule{
id: "a",
apply: func() error { applied = true; return nil },
})
sum, err := Run(context.Background(), reg, RunOptions{DryRun: true, Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
if applied {
t.Fatal("Apply was called during dry-run")
}
if sum.Results[0].Status != StatusPlanned {
t.Fatalf("status = %v", sum.Results[0].Status)
}
}
func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
reg := newReg(t,
scriptedModule{id: "root"},
scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }},
scriptedModule{id: "child", deps: []string{"fails"}},
scriptedModule{id: "sibling", deps: []string{"root"}},
)
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
status := map[string]ModuleStatus{}
for _, r := range sum.Results {
status[r.ID] = r.Status
}
if status["root"] != StatusSucceeded {
t.Errorf("root = %v", status["root"])
}
if status["fails"] != StatusFailed {
t.Errorf("fails = %v", status["fails"])
}
if status["child"] != StatusSkipped {
t.Errorf("child = %v", status["child"])
}
if status["sibling"] != StatusSucceeded {
t.Errorf("sibling = %v (should have run)", status["sibling"])
}
}
func TestRunHaltOnError(t *testing.T) {
reg := newReg(t,
scriptedModule{id: "a", apply: func() error { return errors.New("nope") }},
scriptedModule{id: "b"}, // independent
)
sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
// "b" should not appear in results because we halted before reaching it.
for _, r := range sum.Results {
if r.ID == "b" {
t.Fatalf("b was run despite HaltOnError")
}
}
}
func TestRunUnknownModule(t *testing.T) {
reg := module.NewRegistry()
_, err := Run(context.Background(), reg, RunOptions{Selected: []string{"ghost"}})
if err == nil {
t.Fatal("expected error for unknown module")
}
}

View File

@@ -0,0 +1,80 @@
// Package engine runs modules through the Detect/Plan/Apply/Verify phases.
package engine
import (
"fmt"
"io"
"sync"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// Event is a single item streamed from a module during Apply.
type Event struct {
ModuleID string
Kind EventKind
Current int
Total int
Message string
}
type EventKind int
const (
EventProgress EventKind = iota
EventLog
)
// BufferedReporter stores every event in memory. Used in tests.
type BufferedReporter struct {
mu sync.Mutex
events []Event
}
func NewBufferedReporter() *BufferedReporter { return &BufferedReporter{} }
func (b *BufferedReporter) Progress(id string, current, total int, msg string) {
b.mu.Lock()
defer b.mu.Unlock()
b.events = append(b.events, Event{ModuleID: id, Kind: EventProgress, Current: current, Total: total, Message: msg})
}
func (b *BufferedReporter) Log(id, line string) {
b.mu.Lock()
defer b.mu.Unlock()
b.events = append(b.events, Event{ModuleID: id, Kind: EventLog, Message: line})
}
func (b *BufferedReporter) Events() []Event {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]Event, len(b.events))
copy(out, b.events)
return out
}
// PlainReporter writes human-readable lines to w. Concurrency-safe.
type PlainReporter struct {
mu sync.Mutex
w io.Writer
}
func NewPlainReporter(w io.Writer) *PlainReporter { return &PlainReporter{w: w} }
func (p *PlainReporter) Progress(id string, current, total int, msg string) {
p.mu.Lock()
defer p.mu.Unlock()
fmt.Fprintf(p.w, "[%s] (%d/%d) %s\n", id, current, total, msg)
}
func (p *PlainReporter) Log(id, line string) {
p.mu.Lock()
defer p.mu.Unlock()
fmt.Fprintf(p.w, "[%s] %s\n", id, line)
}
// Compile-time guarantee both types satisfy module.Reporter.
var (
_ module.Reporter = (*BufferedReporter)(nil)
_ module.Reporter = (*PlainReporter)(nil)
)

View File

@@ -0,0 +1,56 @@
package engine
import (
"bytes"
"strings"
"sync"
"testing"
)
func TestBufferedReporterRecordsEventsInOrder(t *testing.T) {
r := NewBufferedReporter()
r.Progress("m", 1, 3, "step 1")
r.Log("m", "hello")
r.Progress("m", 2, 3, "step 2")
ev := r.Events()
if len(ev) != 3 {
t.Fatalf("want 3 events, got %d", len(ev))
}
if ev[0].Kind != EventProgress || ev[0].Current != 1 {
t.Errorf("event[0] = %+v", ev[0])
}
if ev[1].Kind != EventLog || ev[1].Message != "hello" {
t.Errorf("event[1] = %+v", ev[1])
}
}
func TestBufferedReporterIsConcurrencySafe(t *testing.T) {
r := NewBufferedReporter()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
r.Progress("m", i, 50, "x")
}(i)
}
wg.Wait()
if got := len(r.Events()); got != 50 {
t.Fatalf("want 50 events, got %d", got)
}
}
func TestPlainReporterFormat(t *testing.T) {
var buf bytes.Buffer
r := NewPlainReporter(&buf)
r.Progress("packages", 2, 10, "installing tio")
r.Log("packages", "pacman: done")
out := buf.String()
for _, want := range []string{"[packages] (2/10) installing tio", "[packages] pacman: done"} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q; got:\n%s", want, out)
}
}
}

61
internal/module/module.go Normal file
View File

@@ -0,0 +1,61 @@
// Package module defines the four-phase contract every installable unit
// implements, along with the value types exchanged with the engine.
package module
import "context"
// Meta is static, declarative information about a module.
type Meta struct {
ID string // stable machine identifier, e.g. "packages"
Name string // human label, e.g. "Base packages"
Description string // one-line summary for TUI
Deps []string // IDs of modules that must complete before this one
}
// State is an opaque, module-defined snapshot of current system state.
// The engine never inspects it; it is threaded from Detect → Plan.
type State any
// ActionKind categorizes what an Action does, for UI grouping only.
type ActionKind int
const (
ActionUnknown ActionKind = iota
ActionPackageInstall
ActionFileWrite
ActionSymlink
ActionCommand
ActionGitClone
ActionGroupAdd
)
// Action is a single planned change with a human-readable description.
type Action struct {
Description string
Kind ActionKind
Exec func(ctx context.Context) error
}
// Plan is an ordered list of actions returned by Module.Plan.
type Plan struct {
Actions []Action
}
// IsEmpty reports whether the plan would make no changes.
func (p Plan) IsEmpty() bool { return len(p.Actions) == 0 }
// Module is the four-phase contract.
type Module interface {
Meta() Meta
Detect(ctx context.Context) (State, error)
Plan(ctx context.Context, current State) (Plan, error)
Apply(ctx context.Context, plan Plan, r Reporter) error
Verify(ctx context.Context) error
}
// Reporter is how modules stream progress events. Defined here as an
// interface to avoid an import cycle with the engine package.
type Reporter interface {
Progress(moduleID string, current, total int, message string)
Log(moduleID, line string)
}

View File

@@ -0,0 +1,56 @@
package module
import (
"fmt"
"sort"
"sync"
)
// Registry holds all known modules keyed by Meta.ID.
type Registry struct {
mu sync.RWMutex
modules map[string]Module
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{modules: make(map[string]Module)}
}
// Register adds a module. Returns an error if the ID is already present
// or empty. Safe for concurrent use.
func (r *Registry) Register(m Module) error {
id := m.Meta().ID
if id == "" {
return fmt.Errorf("module has empty ID")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, dup := r.modules[id]; dup {
return fmt.Errorf("module %q already registered", id)
}
r.modules[id] = m
return nil
}
// Get returns the module with the given ID, or (nil, false) if absent.
func (r *Registry) Get(id string) (Module, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.modules[id]
return m, ok
}
// All returns all registered modules sorted by ID for determinism.
func (r *Registry) All() []Module {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Module, 0, len(r.modules))
for _, m := range r.modules {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Meta().ID < out[j].Meta().ID
})
return out
}

View File

@@ -0,0 +1,57 @@
package module
import (
"context"
"testing"
)
type fakeModule struct{ id string; deps []string }
func (f fakeModule) Meta() Meta { return Meta{ID: f.id, Name: f.id, Deps: f.deps} }
func (f fakeModule) Detect(context.Context) (State, error) { return nil, nil }
func (f fakeModule) Plan(context.Context, State) (Plan, error) { return Plan{}, nil }
func (f fakeModule) Apply(context.Context, Plan, Reporter) error { return nil }
func (f fakeModule) Verify(context.Context) error { return nil }
func TestRegistry(t *testing.T) {
t.Run("register then get", func(t *testing.T) {
r := NewRegistry()
if err := r.Register(fakeModule{id: "a"}); err != nil {
t.Fatal(err)
}
m, ok := r.Get("a")
if !ok || m.Meta().ID != "a" {
t.Fatalf("want module a, got %v ok=%v", m, ok)
}
})
t.Run("duplicate register errors", func(t *testing.T) {
r := NewRegistry()
_ = r.Register(fakeModule{id: "a"})
if err := r.Register(fakeModule{id: "a"}); err == nil {
t.Fatal("expected duplicate error")
}
})
t.Run("empty id errors", func(t *testing.T) {
r := NewRegistry()
if err := r.Register(fakeModule{id: ""}); err == nil {
t.Fatal("expected empty-id error")
}
})
t.Run("all is sorted by id", func(t *testing.T) {
r := NewRegistry()
_ = r.Register(fakeModule{id: "b"})
_ = r.Register(fakeModule{id: "a"})
_ = r.Register(fakeModule{id: "c"})
all := r.All()
got := []string{all[0].Meta().ID, all[1].Meta().ID, all[2].Meta().ID}
want := []string{"a", "b", "c"}
for i := range got {
if got[i] != want[i] {
t.Fatalf("All() = %v, want %v", got, want)
}
}
})
}

View File

@@ -0,0 +1,138 @@
// Package arduinocli installs arduino-cli and its configured cores.
package arduinocli
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
cfg config.ArduinoCLIConfig
runner platform.Runner
}
func New(cfg config.ArduinoCLIConfig, r platform.Runner) Module {
return Module{cfg: cfg, runner: r}
}
type state struct {
BinaryPath string
BinaryInstalled bool
MissingCores []string
}
func (Module) Meta() module.Meta {
return module.Meta{ID: "arduinocli", Name: "arduino-cli", Description: "Install arduino-cli and cores."}
}
func binaryPath() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".local", "bin", "arduino-cli"), nil
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
bp, err := binaryPath()
if err != nil {
return nil, err
}
s := state{BinaryPath: bp}
if _, err := os.Stat(bp); err == nil {
s.BinaryInstalled = true
out, _ := m.runner.Output(ctx, bp, "core", "list")
for _, core := range m.cfg.Cores {
if !bytes.Contains(out, []byte(core)) {
s.MissingCores = append(s.MissingCores, core)
}
}
} else {
s.MissingCores = append([]string{}, m.cfg.Cores...)
}
return s, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
var actions []module.Action
if !s.BinaryInstalled {
bp := s.BinaryPath
actions = append(actions, module.Action{
Description: "download + install arduino-cli " + m.cfg.Version,
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
tmpDir, err := os.MkdirTemp("", "arduinocli-")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
archive := filepath.Join(tmpDir, "arduino-cli.tar.gz")
if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil {
return err
}
if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil {
return err
}
data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli"))
if err != nil {
return err
}
return os.WriteFile(bp, data, 0o755)
},
})
}
for _, core := range s.MissingCores {
core := core
actions = append(actions, module.Action{
Description: "arduino-cli core update-index (for " + core + ")",
Kind: module.ActionCommand,
Exec: func(ctx context.Context) error {
return m.runner.Run(ctx, s.BinaryPath, "core", "update-index")
},
})
actions = append(actions, module.Action{
Description: "arduino-cli core install " + core,
Kind: module.ActionCommand,
Exec: func(ctx context.Context) error {
return m.runner.Run(ctx, s.BinaryPath, "core", "install", core)
},
})
}
return module.Plan{Actions: actions}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
s := st.(state)
if !s.BinaryInstalled {
return fmt.Errorf("arduino-cli binary missing")
}
if len(s.MissingCores) > 0 {
return fmt.Errorf("cores missing: %v", s.MissingCores)
}
return nil
}

View File

@@ -0,0 +1,94 @@
package arduinocli
import (
"archive/tar"
"compress/gzip"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
coreListOut []byte
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if filepath.Base(name) == "arduino-cli" && len(args) >= 2 && args[0] == "core" && args[1] == "list" {
return f.coreListOut, nil
}
return nil, nil
}
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func makeTarGz(t *testing.T, entries map[string][]byte) string {
t.Helper()
p := filepath.Join(t.TempDir(), "a.tgz")
f, _ := os.Create(p)
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
for name, data := range entries {
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))})
_, _ = tw.Write(data)
}
tw.Close()
gz.Close()
f.Close()
return p
}
func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\n")})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := os.ReadFile(archive)
w.Write(data)
}))
defer srv.Close()
r := &fakeRunner{coreListOut: []byte("ID Installed\n")}
m := New(config.ArduinoCLIConfig{
Version: "1.0.0",
URL: srv.URL + "/arduino-cli.tgz",
Cores: []string{"arduino:avr"},
}, r)
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(home, ".local", "bin", "arduino-cli")); err != nil {
t.Fatalf("binary not installed: %v", err)
}
sawCore := false
for _, c := range r.runCalls {
if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" && c[3] == "arduino:avr" {
sawCore = true
}
}
if !sawCore {
t.Fatalf("core install not run; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,82 @@
// Package fonts installs AUR fonts via the yay helper.
package fonts
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
fonts []string
pm platform.PackageManager
aur platform.AURHelper
}
func New(fonts []string, pm platform.PackageManager, aur platform.AURHelper) Module {
return Module{fonts: fonts, pm: pm, aur: aur}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "fonts", Name: "Nerd fonts", Description: "Install AUR nerd fonts."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, f := range m.fonts {
ok, err := m.pm.IsInstalled(ctx, f)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, f)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{
{
Description: "ensure yay installed",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
},
{
Description: "yay install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.Install(ctx, missing) },
},
}}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
if miss := st.(state).Missing; len(miss) > 0 {
return fmt.Errorf("fonts still missing: %v", miss)
}
return nil
}

View File

@@ -0,0 +1,72 @@
package fonts
import (
"context"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte("ok"), nil
}
return nil, errOne
}
return nil, nil
}
type eo struct{}
func (eo) Error() string { return "exit 1" }
var errOne = eo{}
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func TestFontsPlanApplyUsesYay(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yay": true}}
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
sawYay := false
for _, c := range r.runCalls {
if len(c) >= 2 && c[0] == "yay" && c[1] == "-S" {
sawYay = true
}
}
if !sawYay {
t.Fatalf("no yay install; calls=%v", r.runCalls)
}
}
func TestFontsDetectPresentNoActions(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yay": true, "ttf-jetbrains-mono-nerd": true}}
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if !p.IsEmpty() {
t.Fatal("want empty plan when already installed")
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,74 @@
// Package gittools installs a declarative list of pacman packages.
package gittools
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
pkgs []string
pm platform.PackageManager
}
func New(pkgs []string, pm platform.PackageManager) Module {
return Module{pkgs: pkgs, pm: pm}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "gittools", Name: "Git tools", Description: "Install lazygit."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, p := range m.pkgs {
ok, err := m.pm.IsInstalled(ctx, p)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, p)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: "pacman install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
}}}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
if missing := st.(state).Missing; len(missing) > 0 {
return fmt.Errorf("packages not installed after apply: %v", missing)
}
return nil
}

View File

@@ -0,0 +1,113 @@
package gittools
import (
"context"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool // key: pkg name
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte(args[1] + " 1.0-1\n"), nil
}
return nil, errExitOne
}
return nil, nil
}
type exitOneErr struct{}
func (exitOneErr) Error() string { return "exit status 1" }
var errExitOne = exitOneErr{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectAbsent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"lazygit"}, platform.NewPacman(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if len(s.Missing) != 1 {
t.Fatalf("want 1 missing, got %v", s.Missing)
}
}
func TestDetectPresent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
m := New([]string{"lazygit"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
if len(st.(state).Missing) != 0 {
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
}
}
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
m := New([]string{"lazygit"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: []string{"lazygit"}})
if len(p.Actions) != 1 {
t.Fatalf("want 1 action, got %d", len(p.Actions))
}
if !strings.Contains(p.Actions[0].Description, "lazygit") {
t.Fatalf("desc: %s", p.Actions[0].Description)
}
}
func TestPlanPresentEmpty(t *testing.T) {
m := New([]string{"lazygit"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: nil})
if !p.IsEmpty() {
t.Fatal("want empty plan")
}
}
func TestApplyShellsOut(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"lazygit"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
sawInstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
sawInstall = true
}
}
if !sawInstall {
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
}
}
func TestVerifyAfterApply(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
m := New([]string{"lazygit"}, platform.NewPacman(r))
if err := m.Verify(context.Background()); err != nil {
t.Fatal(err)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,98 @@
// Package hello is a toy module that writes a marker file. It exists to
// validate the engine end-to-end before real modules arrive in Plan 3.
package hello
import (
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
const defaultRelPath = ".cache/archduino/hello.ok"
const markerContent = "archduino-hello\n"
// Module is the exported module value. Path is overridable for tests.
type Module struct {
Path string // absolute path; if empty, ~/.cache/archduino/hello.ok is used
}
func (m Module) resolve() (string, error) {
if m.Path != "" {
return m.Path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, defaultRelPath), nil
}
func (Module) Meta() module.Meta {
return module.Meta{
ID: "hello",
Name: "Hello",
Description: "Toy module that writes a marker file (engine smoke test).",
}
}
type state struct{ Present bool }
func (m Module) Detect(ctx context.Context) (module.State, error) {
path, err := m.resolve()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return state{Present: false}, nil
}
return nil, err
}
return state{Present: string(data) == markerContent}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if s.Present {
return module.Plan{}, nil
}
path, err := m.resolve()
if err != nil {
return module.Plan{}, err
}
return module.Plan{Actions: []module.Action{{
Description: "write marker " + path,
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(markerContent), 0o644)
},
}}}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
if !st.(state).Present {
return fmt.Errorf("hello: marker not present after apply")
}
return nil
}

View File

@@ -0,0 +1,57 @@
package hello
import (
"context"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
)
func TestHelloFullLifecycle(t *testing.T) {
path := filepath.Join(t.TempDir(), "hello.ok")
m := Module{Path: path}
ctx := context.Background()
st, err := m.Detect(ctx)
if err != nil {
t.Fatal(err)
}
if st.(state).Present {
t.Fatal("marker should be absent on fresh tmpdir")
}
plan, err := m.Plan(ctx, st)
if err != nil {
t.Fatal(err)
}
if plan.IsEmpty() {
t.Fatal("plan should have one action")
}
if err := m.Apply(ctx, plan, engine.NewBufferedReporter()); err != nil {
t.Fatal(err)
}
if err := m.Verify(ctx); err != nil {
t.Fatal(err)
}
st2, _ := m.Detect(ctx)
if !st2.(state).Present {
t.Fatal("marker should be present after apply")
}
plan2, _ := m.Plan(ctx, st2)
if !plan2.IsEmpty() {
t.Fatal("second plan should be empty (idempotent)")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != markerContent {
t.Fatalf("content = %q", data)
}
}

View File

@@ -0,0 +1,10 @@
return {
{
"stevearc/vim-arduino",
ft = { "arduino", "cpp", "c" },
config = function()
vim.g.arduino_use_cli = 1
vim.g.arduino_cli_path = "arduino-cli"
end,
},
}

View File

@@ -0,0 +1,8 @@
---@type ChadrcConfig
local M = {}
M.base46 = {
theme = "{{.NvChadTheme}}",
}
return M

View File

@@ -0,0 +1,10 @@
return {
cmd = {
"clangd",
"--background-index",
"--clang-tidy",
"--header-insertion=iwyu",
"--completion-style=detailed",
},
filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" },
}

View File

@@ -0,0 +1,57 @@
return {
{
"mfussenegger/nvim-dap",
keys = { "<leader>d" },
config = function()
local dap = require("dap")
-- GDB remote adapter (connects to Renode's GDB server)
dap.adapters.gdb_remote = {
type = "executable",
command = vim.fn.executable("arm-none-eabi-gdb") == 1
and "arm-none-eabi-gdb"
or "gdb-multiarch",
args = { "-i", "dap" },
}
-- Default debug configurations for Arduino/embedded
dap.configurations.c = {
{
name = "Attach to Renode (GDB :3333)",
type = "gdb_remote",
request = "attach",
target = "localhost:3333",
cwd = "${workspaceFolder}",
program = function()
return vim.fn.input("ELF path: ", vim.fn.getcwd() .. "/build/", "file")
end,
},
}
dap.configurations.cpp = dap.configurations.c
dap.configurations.arduino = dap.configurations.c
-- Breakpoint signs
vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError" })
vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticOk", linehl = "DiffAdd" })
end,
},
{
"rcarriga/nvim-dap-ui",
dependencies = { "mfussenegger/nvim-dap", "nvim-neotest/nvim-nio" },
keys = { "<leader>du" },
config = function()
local dapui = require("dapui")
dapui.setup()
-- Auto open/close UI on debug sessions
local dap = require("dap")
dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end
dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end
dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end
end,
},
{
"nvim-neotest/nvim-nio",
lazy = true,
},
}

View File

@@ -0,0 +1,2 @@
vim.lsp.enable("clangd")
return {}

View File

@@ -0,0 +1,20 @@
require "nvchad.mappings"
local map = vim.keymap.set
map("n", "<leader>ac", "<cmd>ArduinoChooseBoard<cr>", { desc = "Arduino Choose Board" })
map("n", "<leader>ap", "<cmd>ArduinoChoosePort<cr>", { desc = "Arduino Choose Port" })
map("n", "<leader>av", "<cmd>ArduinoVerify<cr>", { desc = "Arduino Verify (compile)" })
map("n", "<leader>au", "<cmd>ArduinoUpload<cr>", { desc = "Arduino Upload" })
map("n", "<leader>as", "<cmd>ArduinoSerial<cr>", { desc = "Arduino Serial Monitor" })
map("n", "<leader>ai", "<cmd>ArduinoInfo<cr>", { desc = "Arduino Board Info" })
map("n", "<leader>aw", "<cmd>!wokwi-cli .<cr>", { desc = "Arduino Wokwi Simulate" })
-- Debug (DAP)
map("n", "<leader>ds", function() require("dap").continue() end, { desc = "Debug Start/Continue" })
map("n", "<leader>db", function() require("dap").toggle_breakpoint() end, { desc = "Debug Toggle Breakpoint" })
map("n", "<leader>dn", function() require("dap").step_over() end, { desc = "Debug Step Over" })
map("n", "<leader>di", function() require("dap").step_into() end, { desc = "Debug Step Into" })
map("n", "<leader>do", function() require("dap").step_out() end, { desc = "Debug Step Out" })
map("n", "<leader>dx", function() require("dap").terminate() end, { desc = "Debug Terminate" })
map("n", "<leader>du", function() require("dapui").toggle() end, { desc = "Debug UI Toggle" })

View File

@@ -0,0 +1,463 @@
// Package neovim installs Neovim via pacman, clones the NvChad starter,
// backs up any non-NvChad config, strips the starter's nvim-lspconfig
// block, and writes Archduino's Lua asset files.
package neovim
import (
"bytes"
"context"
"embed"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"time"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
)
//go:embed assets/*
var assets embed.FS
// Register WriteChadrc with the theming package so theming.Apply can
// rewrite chadrc.lua on theme changes without importing this package
// (which would be a cycle: neovim imports theming).
func init() {
theming.ChadrcWriter = WriteChadrc
}
// assetMap lists each asset and its destination under ~/.config/nvim.
// Order matters: Detect iterates in this order and Apply writes in this order.
var assetMap = []struct {
SrcPath string // under assets/
DstRel string // under ~/.config/nvim/
}{
{"assets/arduino.lua", "lua/plugins/arduino.lua"},
{"assets/dap.lua", "lua/plugins/dap.lua"},
{"assets/lspconfig.lua", "lua/plugins/lspconfig.lua"},
{"assets/mappings.lua", "lua/mappings.lua"},
{"assets/clangd.lua", "lsp/clangd.lua"},
}
// Module installs and configures Neovim + NvChad for Archduino.
type Module struct {
cfg config.NeovimConfig
themeID string
pm platform.PackageManager
git platform.GitClient
}
// New constructs a Neovim Module.
func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module {
return Module{cfg: cfg, themeID: themeID, pm: pm, git: git}
}
type state struct {
NvimInstalled bool
ConfigDirExists bool
ChadrcPresent bool
BackupNeeded bool
BackupSource string
StarterCloned bool
MissingAssets []string
ChadrcDrift bool
LspconfigBlock bool
StarterLspFile bool
ThemeName string
RenderedChadrc []byte
}
// Meta returns the module metadata.
func (Module) Meta() module.Meta {
return module.Meta{ID: "neovim", Name: "Neovim + NvChad", Description: "Install Neovim and configure NvChad with Archduino plugins."}
}
func nvimCfg() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".config", "nvim"), nil
}
func (m Module) resolvedTheme() string {
if t, ok := theming.Get(m.themeID); ok {
return t.NvChadTheme
}
return theming.Default().NvChadTheme
}
// WriteChadrc renders chadrc.lua for the given NvChad theme name and writes
// it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist
// and the surrounding nvim config dir does not exist, the function returns
// nil without writing anything (the user hasn't installed neovim yet).
//
// Exported so the theming package can re-render chadrc on theme switches
// without re-running the full Module pipeline.
func WriteChadrc(nvChadThemeName string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
cfg := filepath.Join(home, ".config", "nvim")
chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua")
// If chadrc doesn't exist, only write when the parent dir exists. A
// fresh system where nvim was never installed should be a no-op.
if _, err := os.Stat(chadrcPath); err != nil {
if _, err := os.Stat(cfg); err != nil {
return nil
}
}
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl")
if err != nil {
return err
}
tmpl, err := template.New("chadrc").Parse(string(tmplBytes))
if err != nil {
return err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil {
return err
}
return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644)
}
func (m Module) renderChadrc() ([]byte, error) {
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl")
if err != nil {
return nil, err
}
tmpl, err := template.New("chadrc").Parse(string(tmplBytes))
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: m.resolvedTheme()}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Detect inspects the filesystem and returns the current state.
func (m Module) Detect(ctx context.Context) (module.State, error) {
cfg, err := nvimCfg()
if err != nil {
return nil, err
}
var s state
s.ThemeName = m.resolvedTheme()
if m.pm != nil {
ok, err := m.pm.IsInstalled(ctx, "neovim")
if err != nil {
return nil, err
}
s.NvimInstalled = ok
}
rendered, err := m.renderChadrc()
if err != nil {
return nil, err
}
s.RenderedChadrc = rendered
if fi, err := os.Stat(cfg); err == nil && fi.IsDir() {
s.ConfigDirExists = true
if _, err := os.Stat(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil {
s.ChadrcPresent = true
}
if _, err := os.Stat(filepath.Join(cfg, "lua")); err == nil && !s.ChadrcPresent {
s.BackupNeeded = true
s.BackupSource = cfg
}
}
if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil {
s.StarterCloned = true
data, _ := os.ReadFile(filepath.Join(cfg, "lua", "plugins", "init.lua"))
if bytes.Contains(data, []byte("nvim-lspconfig")) {
s.LspconfigBlock = true
}
}
if _, err := os.Stat(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")); err == nil {
s.StarterLspFile = true
}
if cur, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil {
if !bytes.Equal(cur, rendered) {
s.ChadrcDrift = true
}
}
for _, a := range assetMap {
want, _ := assets.ReadFile(a.SrcPath)
got, err := os.ReadFile(filepath.Join(cfg, a.DstRel))
if err != nil || !bytes.Equal(got, want) {
s.MissingAssets = append(s.MissingAssets, a.DstRel)
}
}
return s, nil
}
// stripLspconfigBlock removes the plugin-table entry that references
// "neovim/nvim-lspconfig" from the NvChad starter's init.lua. It walks the
// source line-by-line: find the marker line, scan backward for the opening
// `{` of that entry, then scan forward counting braces until the matching
// `}` (plus trailing `,` if present). Robust against nested tables inside
// the entry (e.g. `opts = {}`) that defeat single-level regexes.
func stripLspconfigBlock(src []byte) []byte {
lines := strings.Split(string(src), "\n")
markerIdx := -1
for i, l := range lines {
if strings.Contains(l, "neovim/nvim-lspconfig") {
markerIdx = i
break
}
}
if markerIdx == -1 {
return src
}
// Scan backward for the opening `{` that begins the plugin entry.
// We track braces relative to the marker line so nested tables on
// earlier lines don't confuse the search: we want the first `{`
// whose pair would enclose the marker.
startLine := -1
depth := 0
for i := markerIdx; i >= 0; i-- {
// Count braces on this line from right to left.
for j := len(lines[i]) - 1; j >= 0; j-- {
c := lines[i][j]
if c == '}' {
depth++
} else if c == '{' {
if depth == 0 {
startLine = i
// break out of both loops
i = -1
break
}
depth--
}
}
}
if startLine == -1 {
return src
}
// Scan forward from markerIdx for the matching `}`.
endLine := -1
depth = 1 // we're already inside the `{` at startLine
// Start just after the opening `{` on startLine.
for i := startLine; i < len(lines); i++ {
start := 0
if i == startLine {
// skip past the first `{` on this line
idx := strings.Index(lines[i], "{")
if idx >= 0 {
start = idx + 1
}
}
for j := start; j < len(lines[i]); j++ {
c := lines[i][j]
if c == '{' {
depth++
} else if c == '}' {
depth--
if depth == 0 {
endLine = i
break
}
}
}
if endLine != -1 {
break
}
}
if endLine == -1 {
return src
}
// Include an optional trailing comma and whitespace-only lines after endLine.
cutEnd := endLine
// If the `}` line also has a trailing `,`, that's fine; keep the line as-is
// but drop it. If the endLine text after the closing `}` is just `,` or
// `,` + whitespace, keep cutEnd = endLine. Otherwise also keep cutEnd.
// Skip a single blank line after the block to avoid leaving a stray gap.
if cutEnd+1 < len(lines) && strings.TrimSpace(lines[cutEnd+1]) == "" {
cutEnd++
}
out := make([]string, 0, len(lines)-(cutEnd-startLine+1))
out = append(out, lines[:startLine]...)
out = append(out, lines[cutEnd+1:]...)
return []byte(strings.Join(out, "\n"))
}
// Plan computes the idempotent set of actions to converge to desired state.
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
cfg, err := nvimCfg()
if err != nil {
return module.Plan{}, err
}
var actions []module.Action
if !s.NvimInstalled && m.pm != nil {
actions = append(actions, module.Action{
Description: "pacman install neovim",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"neovim"}) },
})
}
if s.BackupNeeded {
src := s.BackupSource
actions = append(actions, module.Action{
Description: "back up existing non-NvChad config",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
ts := time.Now().UTC().Format("20060102T150405Z")
return os.Rename(src, src+".bak."+ts)
},
})
}
if !s.StarterCloned || s.BackupNeeded {
repo := m.cfg.NvChadStarterRepo
actions = append(actions, module.Action{
Description: "clone NvChad starter",
Kind: module.ActionGitClone,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil {
return err
}
if err := m.git.Clone(ctx, repo, cfg); err != nil {
return err
}
return os.RemoveAll(filepath.Join(cfg, ".git"))
},
})
}
if s.LspconfigBlock || !s.StarterCloned || s.BackupNeeded {
actions = append(actions, module.Action{
Description: "strip nvim-lspconfig block from starter init.lua",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
p := filepath.Join(cfg, "lua", "plugins", "init.lua")
data, err := os.ReadFile(p)
if err != nil {
return err
}
return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644)
},
})
}
if s.StarterLspFile || !s.StarterCloned || s.BackupNeeded {
actions = append(actions, module.Action{
Description: "remove starter lspconfig.lua",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
err := os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua"))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
},
})
}
if s.ChadrcDrift || !s.ChadrcPresent || s.BackupNeeded {
rendered := append([]byte{}, s.RenderedChadrc...)
actions = append(actions, module.Action{
Description: "write chadrc.lua with theme " + s.ThemeName,
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), rendered, 0o644)
},
})
}
for _, dst := range s.MissingAssets {
dst := dst
actions = append(actions, module.Action{
Description: "write asset " + dst,
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
var src string
for _, a := range assetMap {
if a.DstRel == dst {
src = a.SrcPath
break
}
}
data, err := assets.ReadFile(src)
if err != nil {
return err
}
return platform.WriteUserFile(filepath.Join(cfg, dst), data, 0o644)
},
})
}
if len(actions) > 0 {
actions = append(actions, module.Action{
Description: "clear NvChad base46 theme cache",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
home, _ := os.UserHomeDir()
_ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes"))
_ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46"))
return nil
},
})
}
return module.Plan{Actions: actions}, nil
}
// Apply executes the planned actions in order.
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
// Verify re-Detects and returns an error if state is not converged.
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
s := st.(state)
if !s.NvimInstalled {
return fmt.Errorf("neovim not installed")
}
if !s.ChadrcPresent {
return fmt.Errorf("chadrc.lua missing")
}
if s.ChadrcDrift {
return fmt.Errorf("chadrc.lua content drifted")
}
if len(s.MissingAssets) > 0 {
return fmt.Errorf("missing assets: %v", s.MissingAssets)
}
if s.LspconfigBlock {
return fmt.Errorf("nvim-lspconfig block not stripped")
}
if s.StarterLspFile {
return fmt.Errorf("starter lspconfig.lua still present")
}
return nil
}

View File

@@ -0,0 +1,210 @@
package neovim
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls, outCalls [][]string
installed map[string]bool
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
if name == "git" && len(args) >= 1 && args[0] == "clone" {
dest := args[len(args)-1]
os.MkdirAll(filepath.Join(dest, "lua", "plugins"), 0o755)
os.WriteFile(filepath.Join(dest, "lua", "plugins", "init.lua"),
[]byte("return {\n \"neovim/nvim-lspconfig\",\n opts = {},\n},\n{\n \"other\",\n},\n"),
0o644)
os.MkdirAll(filepath.Join(dest, "lua", "configs"), 0o755)
os.WriteFile(filepath.Join(dest, "lua", "configs", "lspconfig.lua"), []byte("stub"), 0o644)
}
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte("ok"), nil
}
return nil, errExit1
}
return nil, nil
}
type exitOne struct{}
func (exitOne) Error() string { return "exit 1" }
var errExit1 = exitOne{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectFreshSystem(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if s.NvimInstalled {
t.Error("nvim should be absent")
}
if s.ChadrcPresent {
t.Error("chadrc should be absent")
}
if s.BackupNeeded {
t.Error("no backup on empty system")
}
}
func TestDetectNonNvChadTriggersBackup(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755)
os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
if !st.(state).BackupNeeded {
t.Fatal("expected BackupNeeded=true")
}
}
func TestPlanApplyInstallsEverything(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if len(p.Actions) == 0 {
t.Fatal("empty plan")
}
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
sawPacman, sawClone := false, false
for _, c := range r.runCalls {
if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" {
sawPacman = true
}
if c[0] == "git" && len(c) >= 2 && c[1] == "clone" {
sawClone = true
}
}
if !sawPacman || !sawClone {
t.Fatalf("missing steps; calls=%v", r.runCalls)
}
nvimCfg := filepath.Join(home, ".config", "nvim")
chadrc, err := os.ReadFile(filepath.Join(nvimCfg, "lua", "chadrc.lua"))
if err != nil {
t.Fatalf("chadrc missing: %v", err)
}
if !strings.Contains(string(chadrc), `theme = "dracula"`) {
t.Fatalf("chadrc theme not applied: %s", chadrc)
}
initLua, _ := os.ReadFile(filepath.Join(nvimCfg, "lua", "plugins", "init.lua"))
if strings.Contains(string(initLua), "nvim-lspconfig") {
t.Fatalf("nvim-lspconfig not stripped: %s", initLua)
}
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "configs", "lspconfig.lua")); !os.IsNotExist(err) {
t.Fatal("starter lspconfig.lua should be removed")
}
for _, name := range []string{"arduino.lua", "dap.lua"} {
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "plugins", name)); err != nil {
t.Errorf("asset %s missing: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "mappings.lua")); err != nil {
t.Errorf("mappings.lua missing: %v", err)
}
if _, err := os.Stat(filepath.Join(nvimCfg, "lsp", "clangd.lua")); err != nil {
t.Errorf("clangd.lua missing: %v", err)
}
}
func TestPlanAfterApplyIsEmpty(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
_ = m.Apply(context.Background(), p, nopReporter{})
st2, _ := m.Detect(context.Background())
p2, _ := m.Plan(context.Background(), st2)
if !p2.IsEmpty() {
t.Fatalf("second plan not empty: %+v", p2.Actions)
}
}
func TestUnknownThemeFallsBackToDefault(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua"))
// Unknown theme IDs fall back to theming.Default(), which is gruvbox.
if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
t.Fatalf("fallback theme not applied: %s", chadrc)
}
}
func TestWriteChadrcWritesFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg := filepath.Join(home, ".config", "nvim")
if err := os.MkdirAll(cfg, 0o755); err != nil {
t.Fatal(err)
}
if err := WriteChadrc("dracula"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `theme = "dracula"`) {
t.Fatalf("theme not applied: %s", data)
}
}
func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := WriteChadrc("gruvbox"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")); !os.IsNotExist(err) {
t.Fatal("chadrc should not be created on a bare system")
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,74 @@
// Package packages installs a declarative list of pacman packages.
package packages
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
pkgs []string
pm platform.PackageManager
}
func New(pkgs []string, pm platform.PackageManager) Module {
return Module{pkgs: pkgs, pm: pm}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "packages", Name: "Base packages", Description: "Install base pacman package list."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, p := range m.pkgs {
ok, err := m.pm.IsInstalled(ctx, p)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, p)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: "pacman install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
}}}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
if missing := st.(state).Missing; len(missing) > 0 {
return fmt.Errorf("packages not installed after apply: %v", missing)
}
return nil
}

View File

@@ -0,0 +1,113 @@
package packages
import (
"context"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool // key: pkg name
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte(args[1] + " 1.0-1\n"), nil
}
return nil, errExitOne
}
return nil, nil
}
type exitOneErr struct{}
func (exitOneErr) Error() string { return "exit status 1" }
var errExitOne = exitOneErr{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectAbsent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if len(s.Missing) != 2 {
t.Fatalf("want 2 missing, got %v", s.Missing)
}
}
func TestDetectPresent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"curl": true, "wget": true}}
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
if len(st.(state).Missing) != 0 {
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
}
}
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
m := New([]string{"curl", "wget"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: []string{"curl", "wget"}})
if len(p.Actions) != 1 {
t.Fatalf("want 1 action, got %d", len(p.Actions))
}
if !strings.Contains(p.Actions[0].Description, "curl") {
t.Fatalf("desc: %s", p.Actions[0].Description)
}
}
func TestPlanPresentEmpty(t *testing.T) {
m := New([]string{"curl"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: nil})
if !p.IsEmpty() {
t.Fatal("want empty plan")
}
}
func TestApplyShellsOut(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"curl"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
sawInstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
sawInstall = true
}
}
if !sawInstall {
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
}
}
func TestVerifyAfterApply(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"curl": true}}
m := New([]string{"curl"}, platform.NewPacman(r))
if err := m.Verify(context.Background()); err != nil {
t.Fatal(err)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,54 @@
#!/bin/bash
set -euo pipefail
usage() {
echo "Usage: arduino-sim <firmware.elf> [platform]"
echo ""
echo "Launch Renode with a GDB server for debugging firmware."
echo "Connect nvim-dap or gdb to localhost:3333"
echo ""
echo "Platforms:"
{{range .Platforms}} echo " {{.Name}} {{.Machine}}"
{{end}} exit 1
}
[[ $# -lt 1 ]] && usage
ELF="$1"
PLATFORM="${2:-{{(index .Platforms 0).Name}}}"
[[ ! -f "$ELF" ]] && echo "Error: $ELF not found" && exit 1
case "$PLATFORM" in
{{range .Platforms}} {{.Name}})
RENODE_PLATFORM="{{.ReplPath}}"
MACHINE="{{.Machine}}"
;;
{{end}} *)
echo "Unknown platform: $PLATFORM"
usage
;;
esac
RESC=$(mktemp /tmp/renode-XXXXXX.resc)
cat >"$RESC" <<EOF
using sysbus
mach create "$MACHINE"
machine LoadPlatformDescription @$RENODE_PLATFORM
sysbus LoadELF @$(realpath "$ELF")
machine StartGdbServer 3333
showAnalyzer sysbus.uart0 Antmicro.Renode.Analyzers.LoggingUartAnalyzer
start
EOF
echo "Starting Renode ($MACHINE) with GDB server on :3333"
echo "Connect debugger: nvim → <leader>ds or gdb → target remote :3333"
echo ""
renode --disable-xwt "$RESC"
rm -f "$RESC"

View File

@@ -0,0 +1,187 @@
// Package renode installs the Renode emulator (AUR), arm-none-eabi-gdb,
// the arduino-sim helper script, and wokwi-cli.
package renode
import (
"bytes"
"context"
"embed"
"fmt"
"os"
"path/filepath"
"text/template"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
//go:embed assets/arduino-sim.tmpl
var assetsFS embed.FS
// Module installs Renode and adjacent emulator tooling.
type Module struct {
cfg config.RenodeConfig
pm platform.PackageManager
aur platform.AURHelper
}
// New constructs a renode Module.
func New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module {
return Module{cfg: cfg, pm: pm, aur: aur}
}
type state struct {
RenodeInstalled bool
GdbInstalled bool
SimScriptCurrent bool
WokwiInstalled bool
RenderedSim []byte
SimPath string
WokwiPath string
}
// Meta returns the module metadata.
func (Module) Meta() module.Meta {
return module.Meta{ID: "renode", Name: "Renode emulator", Description: "Install Renode, ARM GDB, arduino-sim helper, and Wokwi CLI."}
}
func binPath() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".local", "bin"), nil
}
func (m Module) renderSim() ([]byte, error) {
tmplBytes, err := assetsFS.ReadFile("assets/arduino-sim.tmpl")
if err != nil {
return nil, err
}
tmpl, err := template.New("sim").Parse(string(tmplBytes))
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, m.cfg); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Detect inspects the system and returns current state.
func (m Module) Detect(ctx context.Context) (module.State, error) {
bin, err := binPath()
if err != nil {
return nil, err
}
s := state{
SimPath: filepath.Join(bin, "arduino-sim"),
WokwiPath: filepath.Join(bin, "wokwi-cli"),
}
if ok, err := m.pm.IsInstalled(ctx, "renode-bin"); err == nil && ok {
s.RenodeInstalled = true
}
if ok, err := m.pm.IsInstalled(ctx, "arm-none-eabi-gdb"); err == nil && ok {
s.GdbInstalled = true
}
rendered, err := m.renderSim()
if err != nil {
return nil, err
}
s.RenderedSim = rendered
if cur, err := os.ReadFile(s.SimPath); err == nil && bytes.Equal(cur, rendered) {
s.SimScriptCurrent = true
}
if _, err := os.Stat(s.WokwiPath); err == nil {
s.WokwiInstalled = true
}
return s, nil
}
// Plan computes the set of actions needed to converge to desired state.
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
var actions []module.Action
if !s.RenodeInstalled {
actions = append(actions, module.Action{
Description: "ensure yay installed",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
})
actions = append(actions, module.Action{
Description: "yay install renode-bin",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.Install(ctx, []string{"renode-bin"}) },
})
}
if !s.GdbInstalled {
actions = append(actions, module.Action{
Description: "pacman install arm-none-eabi-gdb",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"arm-none-eabi-gdb"}) },
})
}
if !s.SimScriptCurrent {
rendered := append([]byte{}, s.RenderedSim...)
path := s.SimPath
actions = append(actions, module.Action{
Description: "write arduino-sim helper",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
return platform.WriteUserFile(path, rendered, 0o755)
},
})
}
if !s.WokwiInstalled {
url := m.cfg.WokwiURL
dst := s.WokwiPath
actions = append(actions, module.Action{
Description: "download wokwi-cli " + m.cfg.WokwiVersion,
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return platform.Download(ctx, url, dst, 0o755)
},
})
}
return module.Plan{Actions: actions}, nil
}
// Apply executes the planned actions in order.
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
// Verify re-Detects and errors if state hasn't converged.
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
s := st.(state)
if !s.RenodeInstalled {
return fmt.Errorf("renode-bin not installed")
}
if !s.GdbInstalled {
return fmt.Errorf("arm-none-eabi-gdb not installed")
}
if !s.SimScriptCurrent {
return fmt.Errorf("arduino-sim script drift")
}
if !s.WokwiInstalled {
return fmt.Errorf("wokwi-cli missing")
}
return nil
}

View File

@@ -0,0 +1,145 @@
package renode
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls, outCalls [][]string
installed map[string]bool
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte("ok"), nil
}
return nil, errExit1
}
return nil, nil
}
type exitOne struct{}
func (exitOne) Error() string { return "exit 1" }
var errExit1 = exitOne{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func sampleCfg(url string) config.RenodeConfig {
return config.RenodeConfig{
WokwiVersion: "0.28.1",
WokwiURL: url,
Platforms: []config.RenodePlatform{
{Name: "stm32f4", ReplPath: "platforms/boards/stm32f4_discovery.repl", Machine: "STM32F4_Discovery"},
{Name: "rp2040", ReplPath: "platforms/cpus/rp2040.repl", Machine: "RP2040"},
},
}
}
func TestDetectNothingInstalled(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if s.RenodeInstalled || s.GdbInstalled || s.SimScriptCurrent || s.WokwiInstalled {
t.Fatalf("want all absent, got %+v", s)
}
}
func TestDetectAllInstalled(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("x"), 0o755)
r := &fakeRunner{installed: map[string]bool{"renode-bin": true, "arm-none-eabi-gdb": true}}
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
// Apply once to seed the sim script, then re-detect.
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
_ = m.Apply(context.Background(), p, nopReporter{})
st2, _ := m.Detect(context.Background())
p2, _ := m.Plan(context.Background(), st2)
if !p2.IsEmpty() {
t.Fatalf("second plan not empty: %+v", p2.Actions)
}
}
func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("wokwi-binary"))
}))
defer srv.Close()
r := &fakeRunner{installed: map[string]bool{"yay": true}}
m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
// arduino-sim rendered with both platforms
sim, err := os.ReadFile(filepath.Join(home, ".local", "bin", "arduino-sim"))
if err != nil {
t.Fatalf("arduino-sim not written: %v", err)
}
if !strings.Contains(string(sim), "stm32f4)") {
t.Errorf("stm32f4 branch missing: %s", sim)
}
if !strings.Contains(string(sim), "rp2040)") {
t.Errorf("rp2040 branch missing: %s", sim)
}
info, _ := os.Stat(filepath.Join(home, ".local", "bin", "arduino-sim"))
if info.Mode().Perm() != 0o755 {
t.Errorf("arduino-sim mode %v", info.Mode().Perm())
}
// wokwi-cli downloaded
wokwi, err := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli"))
if err != nil {
t.Fatalf("wokwi-cli missing: %v", err)
}
if string(wokwi) != "wokwi-binary" {
t.Errorf("wokwi-cli content %q", wokwi)
}
// yay install renode-bin called
sawYay := false
for _, c := range r.runCalls {
if c[0] == "yay" && len(c) >= 2 && c[1] == "-S" {
sawYay = true
}
}
if !sawYay {
t.Fatalf("yay install missing; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,90 @@
// Package shellconfig writes marker-delimited blocks into rc files.
package shellconfig
import (
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct{ cfg config.ShellConfigSection }
func New(cfg config.ShellConfigSection) Module { return Module{cfg: cfg} }
type state struct{ Missing []config.ShellConfigBlock }
func (Module) Meta() module.Meta {
return module.Meta{ID: "shellconfig", Name: "Shell config blocks", Description: "Write archduino blocks into rc files."}
}
func resolveFile(name string) (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, name), nil
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []config.ShellConfigBlock
for _, b := range m.cfg.Blocks {
p, err := resolveFile(b.File)
if err != nil {
return nil, err
}
has, err := platform.HasBlock(p, b.Name)
if err != nil {
return nil, err
}
if !has {
missing = append(missing, b)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
var actions []module.Action
for _, b := range s.Missing {
b := b
actions = append(actions, module.Action{
Description: fmt.Sprintf("write block %s into %s", b.Name, b.File),
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
p, err := resolveFile(b.File)
if err != nil {
return err
}
return platform.EnsureBlock(p, b.Name, b.Content)
},
})
}
return module.Plan{Actions: actions}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
if miss := st.(state).Missing; len(miss) > 0 {
return fmt.Errorf("shell blocks still missing: %d", len(miss))
}
return nil
}

View File

@@ -0,0 +1,58 @@
package shellconfig
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func TestShellConfigWritesBlocks(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
m := New(config.ShellConfigSection{
Blocks: []config.ShellConfigBlock{
{Name: "path", File: ".zshrc", Content: `export PATH="$HOME/.local/bin:$PATH"`},
},
})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(home, ".zshrc"))
if !strings.Contains(string(data), ">>> path >>>") {
t.Fatalf("block not present: %q", data)
}
if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) {
t.Fatalf("content missing: %q", data)
}
}
func TestShellConfigIdempotent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg := config.ShellConfigSection{Blocks: []config.ShellConfigBlock{
{Name: "x", File: ".zshrc", Content: "echo x"},
}}
m := New(cfg)
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
_ = m.Apply(context.Background(), p, nop{})
st2, _ := m.Detect(context.Background())
p2, _ := m.Plan(context.Background(), st2)
if !p2.IsEmpty() {
t.Fatalf("second plan not empty: %+v", p2.Actions)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,74 @@
// Package starship installs a declarative list of pacman packages.
package starship
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
pkgs []string
pm platform.PackageManager
}
func New(pkgs []string, pm platform.PackageManager) Module {
return Module{pkgs: pkgs, pm: pm}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "starship", Name: "Starship prompt", Description: "Install starship prompt."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, p := range m.pkgs {
ok, err := m.pm.IsInstalled(ctx, p)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, p)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: "pacman install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
}}}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
if missing := st.(state).Missing; len(missing) > 0 {
return fmt.Errorf("packages not installed after apply: %v", missing)
}
return nil
}

View File

@@ -0,0 +1,113 @@
package starship
import (
"context"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool // key: pkg name
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte(args[1] + " 1.0-1\n"), nil
}
return nil, errExitOne
}
return nil, nil
}
type exitOneErr struct{}
func (exitOneErr) Error() string { return "exit status 1" }
var errExitOne = exitOneErr{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectAbsent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"starship"}, platform.NewPacman(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if len(s.Missing) != 1 {
t.Fatalf("want 1 missing, got %v", s.Missing)
}
}
func TestDetectPresent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"starship": true}}
m := New([]string{"starship"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
if len(st.(state).Missing) != 0 {
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
}
}
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
m := New([]string{"starship"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: []string{"starship"}})
if len(p.Actions) != 1 {
t.Fatalf("want 1 action, got %d", len(p.Actions))
}
if !strings.Contains(p.Actions[0].Description, "starship") {
t.Fatalf("desc: %s", p.Actions[0].Description)
}
}
func TestPlanPresentEmpty(t *testing.T) {
m := New([]string{"starship"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: nil})
if !p.IsEmpty() {
t.Fatal("want empty plan")
}
}
func TestApplyShellsOut(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"starship"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
sawInstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
sawInstall = true
}
}
if !sawInstall {
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
}
}
func TestVerifyAfterApply(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"starship": true}}
m := New([]string{"starship"}, platform.NewPacman(r))
if err := m.Verify(context.Background()); err != nil {
t.Fatal(err)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,115 @@
// Package themes installs archduino theme files into ~/.config/archduino/themes.
package themes
import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
type Module struct{ cfg config.ThemesConfig }
func New(cfg config.ThemesConfig) Module { return Module{cfg: cfg} }
type filePair struct{ src, dst string }
type state struct{ Missing []filePair }
func (Module) Meta() module.Meta {
return module.Meta{ID: "themes", Name: "Colorscheme themes", Description: "Install archduino themes."}
}
func destRoot() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".config", "archduino", "themes"), nil
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
dst, err := destRoot()
if err != nil {
return nil, err
}
var missing []filePair
err = filepath.WalkDir(m.cfg.Source, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(m.cfg.Source, p)
target := filepath.Join(dst, rel)
a, errA := os.ReadFile(p)
if errA != nil {
return errA
}
b, errB := os.ReadFile(target)
if errB != nil || !bytes.Equal(a, b) {
missing = append(missing, filePair{src: p, dst: target})
}
return nil
})
if err != nil {
if os.IsNotExist(err) {
return state{}, nil
}
return nil, err
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
pairs := append([]filePair{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: fmt.Sprintf("copy %d theme files", len(pairs)),
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
for _, fp := range pairs {
if err := os.MkdirAll(filepath.Dir(fp.dst), 0o755); err != nil {
return err
}
data, err := os.ReadFile(fp.src)
if err != nil {
return err
}
if err := os.WriteFile(fp.dst, data, 0o644); err != nil {
return err
}
}
return nil
},
}}}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
if miss := st.(state).Missing; len(miss) > 0 {
return fmt.Errorf("themes still missing: %d files", len(miss))
}
return nil
}

View File

@@ -0,0 +1,66 @@
package themes
import (
"context"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func TestThemesCopiesFiles(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "one.toml"), []byte("A"), 0o644); err != nil {
t.Fatal(err)
}
os.MkdirAll(filepath.Join(src, "sub"), 0o755)
os.WriteFile(filepath.Join(src, "sub", "two.toml"), []byte("B"), 0o644)
home := t.TempDir()
t.Setenv("HOME", home)
m := New(config.ThemesConfig{Source: src})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "one.toml"))
if string(got) != "A" {
t.Fatalf("one.toml: %q", got)
}
got, _ = os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "sub", "two.toml"))
if string(got) != "B" {
t.Fatalf("two.toml: %q", got)
}
}
func TestThemesIdempotent(t *testing.T) {
src := t.TempDir()
os.WriteFile(filepath.Join(src, "x.toml"), []byte("X"), 0o644)
home := t.TempDir()
t.Setenv("HOME", home)
m := New(config.ThemesConfig{Source: src})
_ = applyOnce(t, m)
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if !p.IsEmpty() {
t.Fatalf("second plan not empty: %+v", p.Actions)
}
}
func applyOnce(t *testing.T, m Module) error {
t.Helper()
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
return m.Apply(context.Background(), p, nop{})
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,74 @@
// Package yazi installs a declarative list of pacman packages.
package yazi
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
pkgs []string
pm platform.PackageManager
}
func New(pkgs []string, pm platform.PackageManager) Module {
return Module{pkgs: pkgs, pm: pm}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "yazi", Name: "Yazi file manager", Description: "Install yazi."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, p := range m.pkgs {
ok, err := m.pm.IsInstalled(ctx, p)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, p)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: "pacman install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
}}}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
if missing := st.(state).Missing; len(missing) > 0 {
return fmt.Errorf("packages not installed after apply: %v", missing)
}
return nil
}

View File

@@ -0,0 +1,113 @@
package yazi
import (
"context"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool // key: pkg name
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte(args[1] + " 1.0-1\n"), nil
}
return nil, errExitOne
}
return nil, nil
}
type exitOneErr struct{}
func (exitOneErr) Error() string { return "exit status 1" }
var errExitOne = exitOneErr{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectAbsent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"yazi"}, platform.NewPacman(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if len(s.Missing) != 1 {
t.Fatalf("want 1 missing, got %v", s.Missing)
}
}
func TestDetectPresent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
m := New([]string{"yazi"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
if len(st.(state).Missing) != 0 {
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
}
}
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
m := New([]string{"yazi"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: []string{"yazi"}})
if len(p.Actions) != 1 {
t.Fatalf("want 1 action, got %d", len(p.Actions))
}
if !strings.Contains(p.Actions[0].Description, "yazi") {
t.Fatalf("desc: %s", p.Actions[0].Description)
}
}
func TestPlanPresentEmpty(t *testing.T) {
m := New([]string{"yazi"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: nil})
if !p.IsEmpty() {
t.Fatal("want empty plan")
}
}
func TestApplyShellsOut(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"yazi"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
sawInstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
sawInstall = true
}
}
if !sawInstall {
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
}
}
func TestVerifyAfterApply(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
m := New([]string{"yazi"}, platform.NewPacman(r))
if err := m.Verify(context.Background()); err != nil {
t.Fatal(err)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,110 @@
// Package zellij installs zellij and optionally writes a remote config.
package zellij
import (
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
cfg config.ZellijConfig
pm platform.PackageManager
}
func New(cfg config.ZellijConfig, pm platform.PackageManager) Module {
return Module{cfg: cfg, pm: pm}
}
type state struct {
Installed bool
ConfigOK bool
ConfigPath string
}
func (Module) Meta() module.Meta {
return module.Meta{ID: "zellij", Name: "Zellij terminal multiplexer", Description: "Install zellij and config."}
}
func configPath() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".config", "zellij", "config.kdl"), nil
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var s state
ok, err := m.pm.IsInstalled(ctx, "zellij")
if err != nil {
return nil, err
}
s.Installed = ok
p, err := configPath()
if err != nil {
return nil, err
}
s.ConfigPath = p
if m.cfg.ConfigURL == "" {
s.ConfigOK = true
} else if _, err := os.Stat(p); err == nil {
s.ConfigOK = true
}
return s, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
var actions []module.Action
if !s.Installed {
actions = append(actions, module.Action{
Description: "pacman install zellij",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zellij"}) },
})
}
if !s.ConfigOK {
actions = append(actions, module.Action{
Description: "download zellij config",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(s.ConfigPath), 0o755); err != nil {
return err
}
return platform.Download(ctx, m.cfg.ConfigURL, s.ConfigPath, 0o644)
},
})
}
return module.Plan{Actions: actions}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
s := st.(state)
if !s.Installed {
return fmt.Errorf("zellij not installed")
}
if !s.ConfigOK {
return fmt.Errorf("zellij config missing")
}
return nil
}

View File

@@ -0,0 +1,84 @@
package zellij
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte("ok"), nil
}
return nil, errOne
}
return nil, nil
}
type eo struct{}
func (eo) Error() string { return "exit 1" }
var errOne = eo{}
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func TestZellijPlanAppliesInstallAndConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("keybinds {}\n"))
}))
defer srv.Close()
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.ZellijConfig{ConfigURL: srv.URL}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(home, ".config", "zellij", "config.kdl"))
if err != nil {
t.Fatalf("config not written: %v", err)
}
if string(got) != "keybinds {}\n" {
t.Fatalf("unexpected content %q", got)
}
}
func TestZellijInstalledPresentEmptyPlan(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{"zellij": true}}
m := New(config.ZellijConfig{ConfigURL: ""}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if !p.IsEmpty() {
t.Fatalf("want empty plan, got %+v", p)
}
}
var _ module.Module = Module{}

132
internal/modules/zsh/zsh.go Normal file
View File

@@ -0,0 +1,132 @@
// Package zsh installs zsh, oh-my-zsh, and configured plugins.
package zsh
import (
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
cfg config.ZshConfig
pm platform.PackageManager
git platform.GitClient
}
func New(cfg config.ZshConfig, pm platform.PackageManager, git platform.GitClient) Module {
return Module{cfg: cfg, pm: pm, git: git}
}
type state struct {
ZshInstalled bool
OMZInstalled bool
MissingPlugins []config.ZshPlugin
}
func omzHome() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".oh-my-zsh"), nil
}
func (Module) Meta() module.Meta {
return module.Meta{ID: "zsh", Name: "Zsh + oh-my-zsh", Description: "Install zsh, oh-my-zsh, and plugins."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var s state
ok, err := m.pm.IsInstalled(ctx, "zsh")
if err != nil {
return nil, err
}
s.ZshInstalled = ok
omz, err := omzHome()
if err != nil {
return nil, err
}
if _, err := os.Stat(omz); err == nil {
s.OMZInstalled = true
}
for _, p := range m.cfg.Plugins {
path := filepath.Join(omz, "custom", "plugins", p.Name, ".git")
if _, err := os.Stat(path); err != nil {
s.MissingPlugins = append(s.MissingPlugins, p)
}
}
return s, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
var actions []module.Action
if !s.ZshInstalled {
actions = append(actions, module.Action{
Description: "pacman install zsh",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zsh"}) },
})
}
if !s.OMZInstalled {
actions = append(actions, module.Action{
Description: "clone oh-my-zsh from " + m.cfg.OhMyZshRepo,
Kind: module.ActionGitClone,
Exec: func(ctx context.Context) error {
omz, err := omzHome()
if err != nil {
return err
}
return m.git.EnsureRepo(ctx, m.cfg.OhMyZshRepo, omz)
},
})
}
for _, p := range s.MissingPlugins {
p := p // capture
actions = append(actions, module.Action{
Description: "clone plugin " + p.Name,
Kind: module.ActionGitClone,
Exec: func(ctx context.Context) error {
omz, err := omzHome()
if err != nil {
return err
}
return m.git.EnsureRepo(ctx, p.Repo, filepath.Join(omz, "custom", "plugins", p.Name))
},
})
}
return module.Plan{Actions: actions}, nil
}
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
for i, a := range p.Actions {
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
if err := a.Exec(ctx); err != nil {
return err
}
}
return nil
}
func (m Module) Verify(ctx context.Context) error {
st, err := m.Detect(ctx)
if err != nil {
return err
}
s := st.(state)
if !s.ZshInstalled {
return fmt.Errorf("zsh not installed")
}
if !s.OMZInstalled {
return fmt.Errorf("oh-my-zsh not installed")
}
if len(s.MissingPlugins) > 0 {
return fmt.Errorf("plugins missing: %v", s.MissingPlugins)
}
return nil
}

View File

@@ -0,0 +1,117 @@
package zsh
import (
"context"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] {
return []byte("ok"), nil
}
return nil, errOne
}
return nil, nil
}
type eo struct{}
func (eo) Error() string { return "exit 1" }
var errOne = eo{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestZshDetectAbsent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.ZshConfig{
OhMyZshRepo: "https://x/omz.git",
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
}, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
s := st.(state)
if s.ZshInstalled {
t.Error("want zsh absent")
}
if s.OMZInstalled {
t.Error("want omz absent")
}
if len(s.MissingPlugins) != 1 {
t.Errorf("want 1 plugin missing, got %v", s.MissingPlugins)
}
}
func TestZshDetectAllPresent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0o755)
os.MkdirAll(filepath.Join(home, ".oh-my-zsh", "custom", "plugins", "zsh-autosuggestions", ".git"), 0o755)
r := &fakeRunner{installed: map[string]bool{"zsh": true}}
m := New(config.ZshConfig{
OhMyZshRepo: "https://x/omz.git",
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
}, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
s := st.(state)
if !s.ZshInstalled || !s.OMZInstalled || len(s.MissingPlugins) != 0 {
t.Fatalf("want all present, got %+v", s)
}
}
func TestZshPlanApplyInstallsEverything(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.ZshConfig{
OhMyZshRepo: "https://x/omz.git",
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
}, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if len(p.Actions) == 0 {
t.Fatal("want non-empty plan")
}
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
sawPacman, sawOMZ, sawPlugin := false, false, false
for _, c := range r.runCalls {
if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" {
sawPacman = true
}
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/omz.git" {
sawOMZ = true
}
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/a.git" {
sawPlugin = true
}
}
if !sawPacman || !sawOMZ || !sawPlugin {
t.Fatalf("missing step; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

57
internal/platform/aur.go Normal file
View File

@@ -0,0 +1,57 @@
package platform
import (
"context"
"os"
"path/filepath"
"strings"
)
// AURHelper abstracts an AUR front-end (currently only yay).
type AURHelper interface {
EnsureInstalled(ctx context.Context) error
Install(ctx context.Context, pkgs []string) error
}
type Yay struct {
Runner Runner
pm *Pacman
}
func NewYay(r Runner) *Yay { return &Yay{Runner: r, pm: NewPacman(r)} }
func (y *Yay) EnsureInstalled(ctx context.Context) error {
ok, _ := y.pm.IsInstalled(ctx, "yay")
if ok {
return nil
}
// Bootstrap: install build deps, clone AUR yay, makepkg -si.
if err := y.pm.Install(ctx, []string{"base-devel", "git"}); err != nil {
return err
}
tmp, err := os.MkdirTemp("", "archduino-yay-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
clone := filepath.Join(tmp, "yay")
if err := y.Runner.Run(ctx, "git", "clone", "https://aur.archlinux.org/yay.git", clone); err != nil {
return err
}
// `makepkg -si --noconfirm` needs to cd into the cloned dir. os/exec does
// not have a per-command cwd without exec.Cmd; our Runner interface does
// not expose that, so we invoke via sh -c with a pushd-style prefix.
return y.Runner.Run(ctx, "sh", "-c", "cd "+shellQuote(clone)+" && makepkg -si --noconfirm")
}
func (y *Yay) Install(ctx context.Context, pkgs []string) error {
if len(pkgs) == 0 {
return nil
}
args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...)
return y.Runner.Run(ctx, "yay", args...)
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@@ -0,0 +1,89 @@
package platform
import (
"context"
"strings"
"testing"
)
type aurFakeRunner struct {
runCalls [][]string
outCalls [][]string
// yayInstalled toggles what `pacman -Q yay` returns.
yayInstalled bool
}
func (f *aurFakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *aurFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" && args[1] == "yay" {
if f.yayInstalled {
return []byte("yay 12.0-1\n"), nil
}
return nil, &fakeExitErr{}
}
return nil, nil
}
type fakeExitErr struct{}
func (fakeExitErr) Error() string { return "exit 1" }
func TestAUREnsureInstalledSkipsWhenYayPresent(t *testing.T) {
r := &aurFakeRunner{yayInstalled: true}
y := NewYay(r)
if err := y.EnsureInstalled(context.Background()); err != nil {
t.Fatal(err)
}
if len(r.runCalls) != 0 {
t.Fatalf("no installs expected, got %v", r.runCalls)
}
}
func TestAUREnsureInstalledBootstrapsYay(t *testing.T) {
r := &aurFakeRunner{yayInstalled: false}
y := NewYay(r)
if err := y.EnsureInstalled(context.Background()); err != nil {
t.Fatal(err)
}
// Expect: pacman install base-devel+git, git clone yay, makepkg -si in that dir.
sawPacman := false
sawClone := false
sawMakepkg := false
for _, c := range r.runCalls {
if len(c) >= 2 && c[0] == "sudo" && c[1] == "pacman" {
sawPacman = true
}
if len(c) >= 2 && c[0] == "git" && c[1] == "clone" {
sawClone = true
}
if len(c) >= 3 && c[0] == "sh" && c[1] == "-c" && strings.Contains(c[2], "makepkg") {
sawMakepkg = true
}
}
if !sawPacman || !sawClone || !sawMakepkg {
t.Fatalf("missing bootstrap step; calls=%v", r.runCalls)
}
}
func TestAURInstall(t *testing.T) {
r := &aurFakeRunner{yayInstalled: true}
y := NewYay(r)
if err := y.Install(context.Background(), []string{"ttf-jetbrains-mono-nerd"}); err != nil {
t.Fatal(err)
}
if len(r.runCalls) == 0 {
t.Fatal("expected yay install call")
}
c := r.runCalls[len(r.runCalls)-1]
want := []string{"yay", "-S", "--needed", "--noconfirm", "ttf-jetbrains-mono-nerd"}
for i, w := range want {
if i >= len(c) || c[i] != w {
t.Fatalf("call = %v, want %v", c, want)
}
}
}

View File

@@ -0,0 +1,42 @@
package platform
import (
"context"
"fmt"
"io"
"net/http"
"os"
)
// Download fetches url into destPath atomically (temp file + rename) with
// the given file mode. Any non-2xx response returns an error and leaves
// no file behind.
func Download(ctx context.Context, url, destPath string, mode os.FileMode) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("download %s: http %d", url, resp.StatusCode)
}
tmp := destPath + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, destPath)
}

View File

@@ -0,0 +1,45 @@
package platform
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestDownloadHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("payload"))
}))
defer srv.Close()
dir := t.TempDir()
dest := filepath.Join(dir, "out.bin")
if err := Download(context.Background(), srv.URL, dest, 0o755); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(dest)
if string(got) != "payload" {
t.Fatalf("got %q want payload", got)
}
info, _ := os.Stat(dest)
if info.Mode().Perm() != 0o755 {
t.Fatalf("mode=%v want 0755", info.Mode().Perm())
}
}
func TestDownload404Errors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusNotFound)
}))
defer srv.Close()
dir := t.TempDir()
dest := filepath.Join(dir, "out.bin")
if err := Download(context.Background(), srv.URL, dest, 0o644); err == nil {
t.Fatal("want error on 404")
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatal("dest should not exist after failed download")
}
}

View File

@@ -0,0 +1,114 @@
package platform
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func safeJoin(dest, name string) (string, error) {
p := filepath.Join(dest, name)
cleanDest := filepath.Clean(dest) + string(os.PathSeparator)
if !strings.HasPrefix(filepath.Clean(p)+string(os.PathSeparator), cleanDest) {
return "", fmt.Errorf("unsafe path %q escapes %q", name, dest)
}
return p, nil
}
func ExtractTarGz(ctx context.Context, archivePath, destDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
if err := ctx.Err(); err != nil {
return err
}
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target, err := safeJoin(destDir, h.Name)
if err != nil {
return err
}
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(h.Mode)); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
if err != nil {
return err
}
if _, err := io.Copy(w, tr); err != nil {
w.Close()
return err
}
w.Close()
}
}
return nil
}
func ExtractZip(ctx context.Context, archivePath, destDir string) error {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer zr.Close()
for _, zf := range zr.File {
if err := ctx.Err(); err != nil {
return err
}
target, err := safeJoin(destDir, zf.Name)
if err != nil {
return err
}
if zf.FileInfo().IsDir() {
if err := os.MkdirAll(target, zf.Mode()); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := zf.Open()
if err != nil {
return err
}
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode())
if err != nil {
rc.Close()
return err
}
_, err = io.Copy(w, rc)
rc.Close()
w.Close()
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,80 @@
package platform
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"os"
"path/filepath"
"testing"
)
func makeTarGz(t *testing.T, entries map[string]string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "a.tar.gz")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
for name, body := range entries {
tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))})
tw.Write([]byte(body))
}
tw.Close()
gz.Close()
f.Close()
return p
}
func makeZip(t *testing.T, entries map[string]string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "a.zip")
f, _ := os.Create(p)
zw := zip.NewWriter(f)
for name, body := range entries {
w, _ := zw.Create(name)
w.Write([]byte(body))
}
zw.Close()
f.Close()
return p
}
func TestExtractTarGz(t *testing.T) {
src := makeTarGz(t, map[string]string{"hello.txt": "world", "sub/x.txt": "y"})
dest := t.TempDir()
if err := ExtractTarGz(context.Background(), src, dest); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(dest, "hello.txt"))
if string(got) != "world" {
t.Fatalf("got %q", got)
}
got, _ = os.ReadFile(filepath.Join(dest, "sub", "x.txt"))
if string(got) != "y" {
t.Fatalf("got %q", got)
}
}
func TestExtractZip(t *testing.T) {
src := makeZip(t, map[string]string{"a.txt": "A"})
dest := t.TempDir()
if err := ExtractZip(context.Background(), src, dest); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(dest, "a.txt"))
if string(got) != "A" {
t.Fatalf("got %q", got)
}
}
func TestExtractRejectsPathTraversal(t *testing.T) {
src := makeTarGz(t, map[string]string{"../evil.txt": "x"})
dest := t.TempDir()
if err := ExtractTarGz(context.Background(), src, dest); err == nil {
t.Fatal("want error on path traversal")
}
}

23
internal/platform/git.go Normal file
View File

@@ -0,0 +1,23 @@
package platform
import (
"context"
"os"
"path/filepath"
)
// GitClient wraps `git` via a Runner.
type GitClient struct{ Runner Runner }
// Clone always runs `git clone --depth=1 url dest`.
func (g GitClient) Clone(ctx context.Context, url, dest string) error {
return g.Runner.Run(ctx, "git", "clone", "--depth=1", url, dest)
}
// EnsureRepo clones url into dest if dest/.git is missing; otherwise noop.
func (g GitClient) EnsureRepo(ctx context.Context, url, dest string) error {
if _, err := os.Stat(filepath.Join(dest, ".git")); err == nil {
return nil
}
return g.Clone(ctx, url, dest)
}

View File

@@ -0,0 +1,55 @@
package platform
import (
"context"
"os"
"path/filepath"
"testing"
)
type gitFakeRunner struct {
calls [][]string
err error
}
func (f *gitFakeRunner) Run(_ context.Context, name string, args ...string) error {
f.calls = append(f.calls, append([]string{name}, args...))
return f.err
}
func (f *gitFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
return nil, nil
}
func TestGitEnsureRepoClonesWhenMissing(t *testing.T) {
r := &gitFakeRunner{}
g := GitClient{Runner: r}
dest := filepath.Join(t.TempDir(), "repo")
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
t.Fatal(err)
}
if len(r.calls) != 1 {
t.Fatalf("want 1 call, got %d: %v", len(r.calls), r.calls)
}
got := r.calls[0]
want := []string{"git", "clone", "--depth=1", "https://x/y.git", dest}
for i := range want {
if i >= len(got) || got[i] != want[i] {
t.Fatalf("call = %v, want %v", got, want)
}
}
}
func TestGitEnsureRepoSkipsWhenPresent(t *testing.T) {
dest := t.TempDir()
if err := os.MkdirAll(filepath.Join(dest, ".git"), 0o755); err != nil {
t.Fatal(err)
}
r := &gitFakeRunner{}
g := GitClient{Runner: r}
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
t.Fatal(err)
}
if len(r.calls) != 0 {
t.Fatalf("expected no calls, got %v", r.calls)
}
}

View File

@@ -0,0 +1,85 @@
package platform
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
// ExecRunner runs commands via os/exec. This is the production Runner.
type ExecRunner struct{}
func (ExecRunner) Run(ctx context.Context, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
var stderr bytes.Buffer
cmd.Stdout = nil
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg)
}
return nil
}
func (ExecRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, stderr.String())
}
return out, nil
}
// Pacman implements PackageManager via `pacman` on Arch.
type Pacman struct {
Runner Runner
}
func NewPacman(r Runner) *Pacman {
if r == nil {
r = ExecRunner{}
}
return &Pacman{Runner: r}
}
func (Pacman) Name() string { return "pacman" }
// IsInstalled returns true when `pacman -Q <pkg>` succeeds.
func (p Pacman) IsInstalled(ctx context.Context, pkg string) (bool, error) {
if pkg == "" {
return false, errors.New("empty package name")
}
_, err := p.Runner.Output(ctx, "pacman", "-Q", pkg)
if err == nil {
return true, nil
}
// pacman -Q exits 1 when not installed; we treat any error as "not installed"
// because false-positives on transient errors here are harmless (Install
// is idempotent via --needed).
return false, nil
}
// Install runs `sudo pacman -S --needed --noconfirm <pkgs...>`.
func (p Pacman) Install(ctx context.Context, pkgs []string) error {
if len(pkgs) == 0 {
return nil
}
args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, pkgs...)
return p.Runner.Run(ctx, "sudo", args...)
}
// Update runs `sudo pacman -Syu --noconfirm` (full sync + system upgrade).
// On Arch, a bare `pacman -Sy` followed by an install is the partial-upgrade
// anti-pattern that can break the system when a dep's ABI changes, so we
// always pair the sync with the upgrade.
func (p Pacman) Update(ctx context.Context) error {
return p.Runner.Run(ctx, "sudo", "pacman", "-Syu", "--noconfirm")
}

View File

@@ -0,0 +1,87 @@
package platform
import (
"context"
"errors"
"reflect"
"testing"
)
type fakeRunner struct {
runCalls [][]string
outputCalls [][]string
outputErr error
output []byte
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outputCalls = append(f.outputCalls, append([]string{name}, args...))
return f.output, f.outputErr
}
func TestPacmanInstallShellsOutCorrectly(t *testing.T) {
r := &fakeRunner{}
p := NewPacman(r)
if err := p.Install(context.Background(), []string{"tio", "git"}); err != nil {
t.Fatal(err)
}
if len(r.runCalls) != 1 {
t.Fatalf("run calls = %d", len(r.runCalls))
}
want := []string{"sudo", "pacman", "-S", "--needed", "--noconfirm", "tio", "git"}
if !reflect.DeepEqual(r.runCalls[0], want) {
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
}
}
func TestPacmanInstallEmptyIsNoop(t *testing.T) {
r := &fakeRunner{}
p := NewPacman(r)
if err := p.Install(context.Background(), nil); err != nil {
t.Fatal(err)
}
if len(r.runCalls) != 0 {
t.Fatalf("expected no calls, got %v", r.runCalls)
}
}
func TestPacmanIsInstalledTrue(t *testing.T) {
r := &fakeRunner{output: []byte("tio 3.2-1")}
p := NewPacman(r)
ok, err := p.IsInstalled(context.Background(), "tio")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("want installed=true")
}
}
func TestPacmanIsInstalledFalseOnError(t *testing.T) {
r := &fakeRunner{outputErr: errors.New("not found")}
p := NewPacman(r)
ok, err := p.IsInstalled(context.Background(), "ghost")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("want installed=false")
}
}
func TestPacmanUpdate(t *testing.T) {
r := &fakeRunner{}
p := NewPacman(r)
if err := p.Update(context.Background()); err != nil {
t.Fatal(err)
}
want := []string{"sudo", "pacman", "-Syu", "--noconfirm"}
if !reflect.DeepEqual(r.runCalls[0], want) {
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
}
}

View File

@@ -0,0 +1,19 @@
package platform
import "context"
// PackageManager abstracts OS-level package operations so later plans
// can add Debian/Fedora without touching modules.
type PackageManager interface {
Name() string
IsInstalled(ctx context.Context, pkg string) (bool, error)
Install(ctx context.Context, pkgs []string) error
Update(ctx context.Context) error
}
// Runner executes a command with arguments. Injected into package managers
// so tests can stub shell-outs.
type Runner interface {
Run(ctx context.Context, name string, args ...string) error
Output(ctx context.Context, name string, args ...string) ([]byte, error)
}

View File

@@ -0,0 +1,63 @@
// Package platform detects the host OS/distro/WSL status.
package platform
import (
"bufio"
"fmt"
"io"
"os"
"runtime"
"strings"
)
// Distro is a narrow enum of distros we care about.
type Distro string
const (
DistroArch Distro = "arch"
DistroUnknown Distro = "unknown"
)
// Platform holds detected host information.
type Platform struct {
Distro Distro
IsWSL bool
Arch string // e.g. "amd64", "arm64"
}
// Detect reads the live system. Returns DistroUnknown rather than an error
// when /etc/os-release is missing or unrecognized; callers decide what to do.
func Detect() (Platform, error) {
f, err := os.Open("/etc/os-release")
distro := DistroUnknown
if err == nil {
distro = parseDistro(f)
_ = f.Close()
} else if !os.IsNotExist(err) {
return Platform{}, fmt.Errorf("open /etc/os-release: %w", err)
}
wsl := false
if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
lower := strings.ToLower(string(data))
wsl = strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
}
return Platform{Distro: distro, IsWSL: wsl, Arch: runtime.GOARCH}, nil
}
func parseDistro(r io.Reader) Distro {
sc := bufio.NewScanner(r)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "ID=") {
continue
}
id := strings.Trim(strings.TrimPrefix(line, "ID="), `"`)
if id == "arch" {
return DistroArch
}
return DistroUnknown
}
return DistroUnknown
}

View File

@@ -0,0 +1,45 @@
package platform
import (
"strings"
"testing"
)
func TestParseDistroArch(t *testing.T) {
osRelease := `NAME="Arch Linux"
ID=arch
ID_LIKE=archlinux
`
if got := parseDistro(strings.NewReader(osRelease)); got != DistroArch {
t.Fatalf("got %v", got)
}
}
func TestParseDistroUbuntu(t *testing.T) {
osRelease := `NAME="Ubuntu"
ID=ubuntu
`
if got := parseDistro(strings.NewReader(osRelease)); got != DistroUnknown {
t.Fatalf("got %v", got)
}
}
func TestParseDistroQuoted(t *testing.T) {
osRelease := `ID="arch"
`
if got := parseDistro(strings.NewReader(osRelease)); got != DistroArch {
t.Fatalf("got %v", got)
}
}
func TestDetectReturnsArch(t *testing.T) {
p, err := Detect()
if err != nil {
t.Fatal(err)
}
if p.Arch == "" {
t.Fatal("arch is empty")
}
// We don't assert Distro here because the test host may not be Arch.
// The parse-level tests above cover that logic.
}

View File

@@ -0,0 +1,78 @@
package platform
import (
"fmt"
"os"
"strings"
)
func blockMarkers(marker string) (string, string) {
return fmt.Sprintf("# >>> %s >>>", marker), fmt.Sprintf("# <<< %s <<<", marker)
}
// EnsureBlock writes a marker-delimited block into path. If the file does
// not exist it is created. If a block with the same marker already exists
// it is replaced in place. Surrounding content is preserved.
func EnsureBlock(path, marker, content string) error {
start, end := blockMarkers(marker)
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
block := start + "\n" + strings.TrimRight(content, "\n") + "\n" + end + "\n"
text := string(existing)
si := strings.Index(text, start)
ei := strings.Index(text, end)
if si >= 0 && ei > si {
endPos := ei + len(end)
if endPos < len(text) && text[endPos] == '\n' {
endPos++
}
text = text[:si] + block + text[endPos:]
} else {
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
text += block
}
return os.WriteFile(path, []byte(text), 0o644)
}
// HasBlock reports whether path contains a block with the given marker.
// A missing file yields (false, nil).
func HasBlock(path, marker string) (bool, error) {
start, _ := blockMarkers(marker)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return strings.Contains(string(data), start), nil
}
// RemoveBlock deletes a marker-delimited block if present.
func RemoveBlock(path, marker string) error {
start, end := blockMarkers(marker)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
text := string(data)
si := strings.Index(text, start)
ei := strings.Index(text, end)
if si < 0 || ei <= si {
return nil
}
endPos := ei + len(end)
if endPos < len(text) && text[endPos] == '\n' {
endPos++
}
text = text[:si] + text[endPos:]
return os.WriteFile(path, []byte(text), 0o644)
}

View File

@@ -0,0 +1,88 @@
package platform
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureBlockInsertsIntoMissingFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
if err := EnsureBlock(p, "archduino", "export FOO=1"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(p)
want := "# >>> archduino >>>\nexport FOO=1\n# <<< archduino <<<\n"
if string(got) != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestEnsureBlockReplacesExisting(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
initial := "alias ll='ls -l'\n# >>> archduino >>>\nold content\n# <<< archduino <<<\necho tail\n"
if err := os.WriteFile(p, []byte(initial), 0o644); err != nil {
t.Fatal(err)
}
if err := EnsureBlock(p, "archduino", "new content"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(p)
want := "alias ll='ls -l'\n# >>> archduino >>>\nnew content\n# <<< archduino <<<\necho tail\n"
if string(got) != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestEnsureBlockIdempotent(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
_ = EnsureBlock(p, "a", "x")
before, _ := os.ReadFile(p)
_ = EnsureBlock(p, "a", "x")
after, _ := os.ReadFile(p)
if string(before) != string(after) {
t.Fatalf("not idempotent: %q vs %q", before, after)
}
}
func TestHasBlockTrueFalse(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
has, err := HasBlock(p, "a")
if err != nil {
t.Fatal(err)
}
if has {
t.Fatal("missing file should report has=false")
}
_ = os.WriteFile(p, []byte("no blocks here\n"), 0o644)
has, _ = HasBlock(p, "a")
if has {
t.Fatal("unblocked file should report has=false")
}
_ = EnsureBlock(p, "a", "x")
has, _ = HasBlock(p, "a")
if !has {
t.Fatal("blocked file should report has=true")
}
}
func TestRemoveBlock(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
_ = os.WriteFile(p, []byte("head\n# >>> a >>>\nmid\n# <<< a <<<\ntail\n"), 0o644)
if err := RemoveBlock(p, "a"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(p)
if strings.Contains(string(got), ">>> a >>>") {
t.Fatalf("block not removed: %q", got)
}
if !strings.Contains(string(got), "head") || !strings.Contains(string(got), "tail") {
t.Fatalf("surrounding content lost: %q", got)
}
}

View File

@@ -0,0 +1,25 @@
package platform
import (
"os"
"path/filepath"
)
// WriteUserFile writes data to path atomically (tmp + rename) with the
// given mode, creating any missing parent directories (mode 0755).
// Intended for user-owned destinations under $HOME — no sudo.
func WriteUserFile(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}

View File

@@ -0,0 +1,54 @@
package platform
import (
"os"
"path/filepath"
"testing"
)
func TestWriteUserFileCreatesParent(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "a", "b", "c.txt")
if err := WriteUserFile(dest, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(dest)
if string(got) != "hi" {
t.Fatalf("content %q", got)
}
info, _ := os.Stat(dest)
if info.Mode().Perm() != 0o644 {
t.Fatalf("mode %v", info.Mode().Perm())
}
}
func TestWriteUserFileOverwritesAtomically(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "x")
if err := WriteUserFile(dest, []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
if err := WriteUserFile(dest, []byte("two"), 0o644); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(dest)
if string(got) != "two" {
t.Fatalf("got %q want two", got)
}
// No .tmp sibling should be left behind.
if _, err := os.Stat(dest + ".tmp"); !os.IsNotExist(err) {
t.Fatalf(".tmp left behind: %v", err)
}
}
func TestWriteUserFileExecMode(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "bin")
if err := WriteUserFile(dest, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(dest)
if info.Mode().Perm() != 0o755 {
t.Fatalf("mode %v want 0755", info.Mode().Perm())
}
}

View File

@@ -0,0 +1,82 @@
// Package privilege manages sudo timestamp acquisition and keepalive so
// a long-running installer only prompts once.
package privilege
import (
"context"
"fmt"
"os"
"os/exec"
"time"
)
// Keepalive holds the lifetime of a background sudo-refresh goroutine.
type Keepalive struct {
cancel context.CancelFunc
done chan struct{}
}
// Stop terminates the keepalive goroutine. Safe to call multiple times.
func (k *Keepalive) Stop() {
if k == nil || k.cancel == nil {
return
}
k.cancel()
<-k.done
k.cancel = nil
}
// Acquirer abstracts the `sudo` binary so tests can inject a fake.
type Acquirer interface {
Validate(ctx context.Context) error // sudo -v (may prompt)
ValidateNonInteractive(ctx context.Context) error // sudo -n -v
}
// ExecSudo is the production Acquirer.
type ExecSudo struct{}
func (ExecSudo) Validate(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "sudo", "-v")
// Connect to the real terminal so sudo can prompt for the password.
// exec.Cmd.Stdin defaults to /dev/null, not the parent process's stdin.
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func (ExecSudo) ValidateNonInteractive(ctx context.Context) error {
return exec.CommandContext(ctx, "sudo", "-n", "-v").Run()
}
// Acquire runs `sudo -v` once (prompting if needed) and returns a Keepalive
// that refreshes the timestamp every `interval` using ValidateNonInteractive.
// If the initial Validate fails, no goroutine is started and the error is returned.
func Acquire(ctx context.Context, a Acquirer, interval time.Duration) (*Keepalive, error) {
if a == nil {
a = ExecSudo{}
}
if interval <= 0 {
interval = 50 * time.Second
}
if err := a.Validate(ctx); err != nil {
return nil, fmt.Errorf("sudo -v failed: %w", err)
}
kCtx, cancel := context.WithCancel(ctx)
k := &Keepalive{cancel: cancel, done: make(chan struct{})}
go func() {
defer close(k.done)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-kCtx.Done():
return
case <-t.C:
_ = a.ValidateNonInteractive(kCtx) // best-effort
}
}
}()
return k, nil
}

View File

@@ -0,0 +1,65 @@
package privilege
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
type fakeAcquirer struct {
validateErr error
validates int32
refreshes int32
}
func (f *fakeAcquirer) Validate(ctx context.Context) error {
atomic.AddInt32(&f.validates, 1)
return f.validateErr
}
func (f *fakeAcquirer) ValidateNonInteractive(ctx context.Context) error {
atomic.AddInt32(&f.refreshes, 1)
return nil
}
func TestAcquireFailsIfValidateFails(t *testing.T) {
a := &fakeAcquirer{validateErr: errors.New("wrong password")}
_, err := Acquire(context.Background(), a, 10*time.Millisecond)
if err == nil {
t.Fatal("expected error")
}
if atomic.LoadInt32(&a.refreshes) != 0 {
t.Fatal("keepalive ran despite Validate failure")
}
}
func TestAcquireStartsKeepaliveAndStops(t *testing.T) {
a := &fakeAcquirer{}
k, err := Acquire(context.Background(), a, 5*time.Millisecond)
if err != nil {
t.Fatal(err)
}
time.Sleep(25 * time.Millisecond)
k.Stop()
got := atomic.LoadInt32(&a.refreshes)
if got < 2 {
t.Fatalf("refreshes = %d, want >= 2", got)
}
// After Stop, no additional refreshes should occur.
snap := atomic.LoadInt32(&a.refreshes)
time.Sleep(20 * time.Millisecond)
if after := atomic.LoadInt32(&a.refreshes); after != snap {
t.Fatalf("refreshes continued after Stop: %d → %d", snap, after)
}
}
func TestStopIsIdempotent(t *testing.T) {
k, err := Acquire(context.Background(), &fakeAcquirer{}, 5*time.Millisecond)
if err != nil {
t.Fatal(err)
}
k.Stop()
k.Stop() // must not panic or hang
}

109
internal/state/state.go Normal file
View File

@@ -0,0 +1,109 @@
// Package state persists a per-run log used only for resume hints in the UI.
// Correctness always comes from live Detect() calls.
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// RunRecord is the JSON document written to runs/<ts>.json.
type RunRecord struct {
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
Modules []ModuleRecord `json:"modules"`
}
// ModuleRecord captures how one module fared.
type ModuleRecord struct {
ID string `json:"id"`
Status string `json:"status"` // succeeded|failed|skipped|planned
Duration string `json:"duration"`
Error string `json:"error,omitempty"`
At time.Time `json:"at"`
}
// Store is a file-backed run log.
type Store struct {
Root string // e.g. ~/.local/state/archduino
}
// DefaultStore returns a Store rooted at $XDG_STATE_HOME/archduino or
// ~/.local/state/archduino.
func DefaultStore() (*Store, error) {
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
return &Store{Root: filepath.Join(xdg, "archduino")}, nil
}
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
return &Store{Root: filepath.Join(home, ".local", "state", "archduino")}, nil
}
// Save writes record as runs/<ts>.json and updates last.json. Both paths are
// created if missing. Old runs beyond `keep` are deleted.
func (s *Store) Save(rec RunRecord, keep int) error {
if keep <= 0 {
keep = 10
}
runsDir := filepath.Join(s.Root, "runs")
if err := os.MkdirAll(runsDir, 0o755); err != nil {
return err
}
name := rec.StartedAt.UTC().Format("20060102T150405Z") + ".json"
path := filepath.Join(runsDir, name)
data, err := json.MarshalIndent(rec, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(s.Root, "last.json"), data, 0o644); err != nil {
return err
}
return rotate(runsDir, keep)
}
// LastRun reads last.json if present.
func (s *Store) LastRun() (RunRecord, bool, error) {
data, err := os.ReadFile(filepath.Join(s.Root, "last.json"))
if err != nil {
if os.IsNotExist(err) {
return RunRecord{}, false, nil
}
return RunRecord{}, false, err
}
var rec RunRecord
if err := json.Unmarshal(data, &rec); err != nil {
return RunRecord{}, false, fmt.Errorf("parse last.json: %w", err)
}
return rec, true, nil
}
func rotate(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
if len(names) <= keep {
return nil
}
sort.Strings(names) // lexicographic == chronological (UTC stamp)
for _, n := range names[:len(names)-keep] {
_ = os.Remove(filepath.Join(dir, n))
}
return nil
}

View File

@@ -0,0 +1,63 @@
package state
import (
"os"
"path/filepath"
"testing"
"time"
)
func newStore(t *testing.T) *Store {
t.Helper()
return &Store{Root: t.TempDir()}
}
func TestSaveAndLoadLast(t *testing.T) {
s := newStore(t)
rec := RunRecord{
StartedAt: time.Date(2026, 4, 13, 10, 0, 0, 0, time.UTC),
EndedAt: time.Date(2026, 4, 13, 10, 1, 0, 0, time.UTC),
Modules: []ModuleRecord{{ID: "hello", Status: "succeeded", Duration: "1s"}},
}
if err := s.Save(rec, 10); err != nil {
t.Fatal(err)
}
got, ok, err := s.LastRun()
if err != nil || !ok {
t.Fatalf("LastRun err=%v ok=%v", err, ok)
}
if len(got.Modules) != 1 || got.Modules[0].ID != "hello" {
t.Fatalf("modules = %+v", got.Modules)
}
}
func TestLastRunMissingReturnsFalse(t *testing.T) {
s := newStore(t)
_, ok, err := s.LastRun()
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected ok=false")
}
}
func TestRotateKeepsNewest(t *testing.T) {
s := newStore(t)
for i := 0; i < 12; i++ {
rec := RunRecord{
StartedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC),
EndedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC),
}
if err := s.Save(rec, 5); err != nil {
t.Fatal(err)
}
}
entries, err := os.ReadDir(filepath.Join(s.Root, "runs"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 5 {
t.Fatalf("runs count = %d, want 5", len(entries))
}
}

View File

@@ -0,0 +1,104 @@
// Package doctor reports module convergence and pinned-tool version drift.
package doctor
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
// Run inspects every module in the registry plus pinned tool versions and
// writes a human-readable report to w. Returns 0 when everything is
// converged with no drift; 1 otherwise. Never panics on module errors.
func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int {
var converged, pending, errs, drift int
for _, m := range reg.All() {
id := m.Meta().ID
st, err := m.Detect(ctx)
if err != nil {
fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err)
errs++
continue
}
pl, err := m.Plan(ctx, st)
if err != nil {
fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err)
errs++
continue
}
if pl.IsEmpty() {
fmt.Fprintf(w, "[✓] %s\n", id)
converged++
} else {
fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions))
pending++
}
}
drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w)
drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w)
fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs)
if pending > 0 || drift > 0 || errs > 0 {
return 1
}
return 0
}
type arduinoCLIVersion struct {
VersionString string `json:"VersionString"`
}
func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
if want == "" {
return 0
}
out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json")
if err != nil {
return 0 // not installed → silent skip
}
var v arduinoCLIVersion
if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" {
fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n")
return 1
}
if v.VersionString != want {
fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want)
return 1
}
fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString)
return 0
}
var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
if want == "" {
return 0
}
out, err := runner.Output(ctx, "wokwi-cli", "--version")
if err != nil {
return 0
}
m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out)))
if len(m) < 2 {
fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n")
return 1
}
got := m[1]
if got != want {
fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want)
return 1
}
fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got)
return 0
}

View File

@@ -0,0 +1,135 @@
package doctor
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// convergedModule returns an empty Plan — always converged.
type convergedModule struct{ id string }
func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} }
func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil }
func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{}, nil
}
func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (c convergedModule) Verify(context.Context) error { return nil }
type pendingModule struct{ id string }
func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} }
func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil }
func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
}
func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (p pendingModule) Verify(context.Context) error { return nil }
type detectFailModule struct{ id string }
func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} }
func (d detectFailModule) Detect(context.Context) (module.State, error) { return nil, errors.New("boom") }
func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{}, nil
}
func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (d detectFailModule) Verify(context.Context) error { return nil }
// notFoundRunner simulates "command not found" for version checks.
type notFoundRunner struct{}
func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") }
func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) {
return nil, errors.New("not found")
}
// fixedRunner returns a preset version string for a matching command.
type fixedRunner struct {
arduinoOut []byte
arduinoErr error
wokwiOut []byte
wokwiErr error
}
func (f fixedRunner) Run(context.Context, string, ...string) error { return nil }
func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
switch name {
case "arduino-cli":
return f.arduinoOut, f.arduinoErr
case "wokwi-cli":
return f.wokwiOut, f.wokwiErr
}
return nil, errors.New("no match")
}
func TestDoctorAllConvergedExitsZero(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 0 {
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
}
if !strings.Contains(buf.String(), "[✓] foo") {
t.Errorf("missing converged mark: %s", buf.String())
}
}
func TestDoctorDivergedExitsOne(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(pendingModule{id: "bar"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 1 {
t.Fatalf("exit %d, want 1", code)
}
if !strings.Contains(buf.String(), "[✗] bar") {
t.Errorf("missing diverged mark: %s", buf.String())
}
}
func TestDoctorDetectErrorIsReported(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(detectFailModule{id: "baz"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 1 {
t.Fatalf("exit %d, want 1", code)
}
if !strings.Contains(buf.String(), "[!] baz") {
t.Errorf("missing error mark: %s", buf.String())
}
}
func TestDoctorArduinoCLIDriftIsReported(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)}
var buf bytes.Buffer
code := Run(context.Background(), cfg, reg, runner, &buf)
if code != 1 {
t.Fatalf("exit %d, want 1 (drift)", code)
}
if !strings.Contains(buf.String(), "drift") {
t.Errorf("missing drift report: %s", buf.String())
}
}
func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
var buf bytes.Buffer
code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf)
if code != 0 {
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
}
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,22 @@
#include <WiFi.h>
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASS";
void setup() {
Serial.begin(115200);
delay(1000);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected! IP: ");
Serial.println(WiFi.localIP());
}
void loop() {
delay(1000);
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,22 @@
#include <ESP8266WiFi.h>
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASS";
void setup() {
Serial.begin(115200);
delay(1000);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected! IP: ");
Serial.println(WiFi.localIP());
}
void loop() {
delay(1000);
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,75 @@
// Package newproject scaffolds a new Arduino sketch directory.
package newproject
import (
"embed"
"fmt"
"os"
"path/filepath"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
//go:embed assets
var assetsFS embed.FS
const clangdContent = `CompileFlags:
Add:
- -xc++
- -std=c++17
`
// Run creates a new Arduino project directory. It errors if the project
// directory already exists, if the board is unknown, or if the project name
// contains a path separator.
func Run(cfg config.Config, project, board string) error {
if project == "" {
return fmt.Errorf("project name required")
}
if strings.ContainsAny(project, "/\\") || strings.HasPrefix(project, ".") {
return fmt.Errorf("invalid project name %q", project)
}
var b *config.Board
var valid []string
for i := range cfg.Scaffolding.Boards {
bp := &cfg.Scaffolding.Boards[i]
valid = append(valid, bp.Name)
if bp.Name == board {
b = bp
}
}
if b == nil {
return fmt.Errorf("unknown board %q; valid: %s", board, strings.Join(valid, ", "))
}
if _, err := os.Stat(project); err == nil {
return fmt.Errorf("project directory %q already exists", project)
}
if err := os.MkdirAll(project, 0o755); err != nil {
return err
}
// Pick .ino template: try <template>.ino, else default.ino.
inoData, err := assetsFS.ReadFile(filepath.Join("assets", b.Template+".ino"))
if err != nil {
inoData, err = assetsFS.ReadFile("assets/default.ino")
if err != nil {
return err
}
}
if err := platform.WriteUserFile(filepath.Join(project, project+".ino"), inoData, 0o644); err != nil {
return err
}
sketch := fmt.Sprintf("default_fqbn: %s\ndefault_port: auto\n", b.FQBN)
if err := platform.WriteUserFile(filepath.Join(project, "sketch.yaml"), []byte(sketch), 0o644); err != nil {
return err
}
if err := platform.WriteUserFile(filepath.Join(project, ".clangd"), []byte(clangdContent), 0o644); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,84 @@
package newproject
import (
"os"
"path/filepath"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
)
func sampleCfg() config.Config {
return config.Config{
Scaffolding: config.Scaffolding{Boards: []config.Board{
{Name: "uno", FQBN: "arduino:avr:uno", Template: "uno"},
{Name: "xyz", FQBN: "fake:fqbn:xyz", Template: "does-not-exist"},
}},
}
}
func TestRunCreatesProject(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := Run(sampleCfg(), "blinky", "uno"); err != nil {
t.Fatal(err)
}
ino, err := os.ReadFile(filepath.Join("blinky", "blinky.ino"))
if err != nil {
t.Fatalf("blinky.ino missing: %v", err)
}
if len(ino) == 0 {
t.Error("blinky.ino empty")
}
sketch, err := os.ReadFile(filepath.Join("blinky", "sketch.yaml"))
if err != nil {
t.Fatalf("sketch.yaml missing: %v", err)
}
if !strings.Contains(string(sketch), "arduino:avr:uno") {
t.Errorf("sketch.yaml missing fqbn: %s", sketch)
}
if _, err := os.Stat(filepath.Join("blinky", ".clangd")); err != nil {
t.Errorf(".clangd missing: %v", err)
}
}
func TestRunRefusesExistingDir(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
os.Mkdir("blinky", 0o755)
if err := Run(sampleCfg(), "blinky", "uno"); err == nil {
t.Fatal("want error on existing dir")
}
}
func TestRunFallsBackToDefaultTemplate(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := Run(sampleCfg(), "thing", "xyz"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join("thing", "thing.ino")); err != nil {
t.Fatalf("thing.ino missing: %v", err)
}
}
func TestRunUnknownBoardErrors(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
err := Run(sampleCfg(), "thing", "bogus")
if err == nil {
t.Fatal("want error")
}
if !strings.Contains(err.Error(), "uno") {
t.Errorf("error should list valid boards: %v", err)
}
}
func TestRunRejectsSlashInProjectName(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := Run(sampleCfg(), "bad/name", "uno"); err == nil {
t.Fatal("want error on slashes")
}
}

View File

@@ -0,0 +1,13 @@
gui:
theme:
activeBorderColor:
- "#f9e2af"
- bold
inactiveBorderColor:
- "#585b70"
selectedLineBgColor:
- "#313244"
cherryPickedCommitFgColor:
- "#a6e3a1"
defaultFgColor:
- "#cdd6f4"

View File

@@ -0,0 +1,26 @@
palette = "catppuccin_mocha"
format = """$directory$git_branch$git_status$character"""
[directory]
style = "bold fg:bright-yellow"
truncation_length = 3
[git_branch]
style = "bold fg:bright-green"
format = " [$branch]($style) "
[git_status]
style = "bold fg:bright-red"
[character]
success_symbol = "[❯](bold fg:bright-green)"
error_symbol = "[❯](bold fg:bright-red)"
[palettes.catppuccin_mocha]
bright-yellow = "#f9e2af"
bright-green = "#a6e3a1"
bright-red = "#f38ba8"
bright-blue = "#89b4fa"
bright-purple = "#cba6f7"
bright-aqua = "#94e2d5"
bright-orange = "#fab387"

View File

@@ -0,0 +1,2 @@
display_name: "Catppuccin Mocha"
nvchad: "catppuccin"

View File

@@ -0,0 +1,30 @@
[manager]
cwd = { fg = "#f9e2af" }
tab_active = { fg = "#1e1e2e", bg = "#f9e2af" }
tab_inactive = { fg = "#bac2de", bg = "#313244" }
border_symbol = "│"
border_style = { fg = "#585b70" }
count_copied = { fg = "#1e1e2e", bg = "#a6e3a1" }
count_cut = { fg = "#1e1e2e", bg = "#f38ba8" }
count_selected = { fg = "#1e1e2e", bg = "#f9e2af" }
[status]
separator_open = ""
separator_close = ""
mode_normal = { fg = "#1e1e2e", bg = "#a6e3a1", bold = true }
mode_select = { fg = "#1e1e2e", bg = "#f9e2af", bold = true }
mode_unset = { fg = "#1e1e2e", bg = "#f38ba8", bold = true }
progress_label = { fg = "#cdd6f4", bold = true }
progress_normal = { fg = "#89b4fa", bg = "#313244" }
progress_error = { fg = "#f38ba8", bg = "#313244" }
[input]
border = { fg = "#f9e2af" }
title = {}
value = {}
selected = { reversed = true }
[select]
border = { fg = "#f9e2af" }
active = { fg = "#a6e3a1", bold = true }
inactive = {}

View File

@@ -0,0 +1,35 @@
theme "catppuccin-mocha"
default_layout "default"
default_mode "normal"
pane_frames false
simplified_ui true
mouse_mode false
keybinds {
shared {
bind "Alt h" { MoveFocusOrTab "Left"; }
bind "Alt l" { MoveFocusOrTab "Right"; }
bind "Alt j" { MoveFocus "Down"; }
bind "Alt k" { MoveFocus "Up"; }
bind "Alt n" { NewPane "Down"; }
bind "Alt v" { NewPane "Right"; }
bind "Alt f" { ToggleFloatingPanes; }
bind "Alt t" { NewTab; }
}
}
themes {
catppuccin-mocha {
fg "#cdd6f4"
bg "#1e1e2e"
black "#181825"
red "#f38ba8"
green "#a6e3a1"
yellow "#f9e2af"
blue "#89b4fa"
magenta "#cba6f7"
cyan "#94e2d5"
white "#bac2de"
orange "#fab387"
}
}

View File

@@ -0,0 +1,13 @@
gui:
theme:
activeBorderColor:
- "#f1fa8c"
- bold
inactiveBorderColor:
- "#6272a4"
selectedLineBgColor:
- "#44475a"
cherryPickedCommitFgColor:
- "#50fa7b"
defaultFgColor:
- "#f8f8f2"

View File

@@ -0,0 +1,26 @@
palette = "dracula"
format = """$directory$git_branch$git_status$character"""
[directory]
style = "bold fg:bright-yellow"
truncation_length = 3
[git_branch]
style = "bold fg:bright-green"
format = " [$branch]($style) "
[git_status]
style = "bold fg:bright-red"
[character]
success_symbol = "[❯](bold fg:bright-green)"
error_symbol = "[❯](bold fg:bright-red)"
[palettes.dracula]
bright-yellow = "#f1fa8c"
bright-green = "#50fa7b"
bright-red = "#ff5555"
bright-blue = "#bd93f9"
bright-purple = "#ff79c6"
bright-aqua = "#8be9fd"
bright-orange = "#ffb86c"

View File

@@ -0,0 +1,2 @@
display_name: "Dracula"
nvchad: "dracula"

View File

@@ -0,0 +1,30 @@
[manager]
cwd = { fg = "#f1fa8c" }
tab_active = { fg = "#282a36", bg = "#f1fa8c" }
tab_inactive = { fg = "#bfbfbf", bg = "#44475a" }
border_symbol = "│"
border_style = { fg = "#6272a4" }
count_copied = { fg = "#282a36", bg = "#50fa7b" }
count_cut = { fg = "#282a36", bg = "#ff5555" }
count_selected = { fg = "#282a36", bg = "#f1fa8c" }
[status]
separator_open = ""
separator_close = ""
mode_normal = { fg = "#282a36", bg = "#50fa7b", bold = true }
mode_select = { fg = "#282a36", bg = "#f1fa8c", bold = true }
mode_unset = { fg = "#282a36", bg = "#ff5555", bold = true }
progress_label = { fg = "#f8f8f2", bold = true }
progress_normal = { fg = "#bd93f9", bg = "#44475a" }
progress_error = { fg = "#ff5555", bg = "#44475a" }
[input]
border = { fg = "#f1fa8c" }
title = {}
value = {}
selected = { reversed = true }
[select]
border = { fg = "#f1fa8c" }
active = { fg = "#50fa7b", bold = true }
inactive = {}

View File

@@ -0,0 +1,35 @@
theme "dracula"
default_layout "default"
default_mode "normal"
pane_frames false
simplified_ui true
mouse_mode false
keybinds {
shared {
bind "Alt h" { MoveFocusOrTab "Left"; }
bind "Alt l" { MoveFocusOrTab "Right"; }
bind "Alt j" { MoveFocus "Down"; }
bind "Alt k" { MoveFocus "Up"; }
bind "Alt n" { NewPane "Down"; }
bind "Alt v" { NewPane "Right"; }
bind "Alt f" { ToggleFloatingPanes; }
bind "Alt t" { NewTab; }
}
}
themes {
dracula {
fg "#f8f8f2"
bg "#282a36"
black "#21222c"
red "#ff5555"
green "#50fa7b"
yellow "#f1fa8c"
blue "#bd93f9"
magenta "#ff79c6"
cyan "#8be9fd"
white "#bfbfbf"
orange "#ffb86c"
}
}

Some files were not shown because too many files have changed in this diff Show More