46 KiB
Plan 3b — Install Modules (Heavy Tier) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Port the neovim and renode Bash installers to Go modules, add one shared WriteUserFile helper, extend tui.Theme so the wizard's theme selection flows into NvChad, and register the two modules in the install command.
Architecture: Five tasks of foundation (WriteUserFile helper, theme field, config extension, buildRegistry theme-plumbing, and the neovim-starter sed-strip fixture), then one task per module, then registration + smoke. Assets (Lua files, arduino-sim bash) live under internal/modules/<name>/assets/ and are bundled via go:embed.
Tech Stack: Go stdlib only (embed, text/template, os, path/filepath). Depends on Plan 3a helpers (pacman, AUR, download, extract, git) and on internal/module / internal/engine / internal/tui from Plans 1-2.
Reference: existing surfaces
From Plan 3a / earlier work:
// internal/module/module.go
type Module interface {
Meta() Meta
Detect(ctx) (State, error)
Plan(ctx, State) (Plan, error)
Apply(ctx, Plan, Reporter) error
Verify(ctx) error
}
// internal/platform
type PackageManager interface { Name() string; IsInstalled(ctx, pkg string) (bool, error); Install(ctx, pkgs []string) error; Update(ctx) error }
type AURHelper interface { EnsureInstalled(ctx) error; Install(ctx, pkgs []string) error }
type Runner interface { Run(ctx, name string, args ...string) error; Output(ctx, name string, args ...string) ([]byte, error) }
type GitClient struct { Runner Runner }
func Download(ctx, url, destPath string, mode os.FileMode) error
// internal/tui/state.go (before this plan)
type Theme struct { ID, Name string }
var BuiltinThemes = []Theme{ {ID: "gruvbox", Name: "Gruvbox Dark"}, ... }
// cmd/archduino/main.go (before this plan)
func buildRegistry(cfg config.Config) (*module.Registry, error)
Plan-3a fakeRunner pattern (replicate inside each new module's _test.go — don't share):
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{}
Task 1: WriteUserFile platform helper
Files:
-
Create:
internal/platform/writefile.go -
Create:
internal/platform/writefile_test.go -
Step 1: Write the failing test
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()) }
}
- Step 2: Run test, confirm fail
go test ./internal/platform/... -run TestWriteUserFile -v
Expected: undefined: WriteUserFile.
- Step 3: Implement
writefile.go
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
}
- Step 4: Run tests, confirm pass
go test ./internal/platform/... -v
- Step 5: Commit
git add internal/platform/writefile.go internal/platform/writefile_test.go
git commit -m "feat(platform): add WriteUserFile atomic user-space writer"
Task 2: Extend tui.Theme with NvChad name + lookup helper
Files:
-
Modify:
internal/tui/state.go -
Modify:
internal/tui/state_test.go -
Step 1: Write the failing test
Add to internal/tui/state_test.go:
func TestBuiltinThemesCarryNvChadName(t *testing.T) {
for _, th := range BuiltinThemes {
if th.NvChadTheme == "" {
t.Errorf("theme %q has empty NvChadTheme", th.ID)
}
}
}
func TestThemeByIDReturnsMatch(t *testing.T) {
got, ok := ThemeByID(BuiltinThemes, "dracula")
if !ok { t.Fatal("dracula should exist") }
if got.ID != "dracula" { t.Fatalf("got id=%q", got.ID) }
if got.NvChadTheme == "" { t.Fatal("NvChadTheme empty") }
}
func TestThemeByIDFallsBackWhenUnknown(t *testing.T) {
_, ok := ThemeByID(BuiltinThemes, "does-not-exist")
if ok { t.Fatal("unknown id should report ok=false") }
}
- Step 2: Run test, confirm fail
go test ./internal/tui/... -run TestBuiltinThemes -v
Expected: th.NvChadTheme undefined / undefined: ThemeByID.
- Step 3: Implement
In internal/tui/state.go, replace the Theme struct and BuiltinThemes list:
// Theme is a named colorscheme presented in the wizard. Each entry carries
// per-tool theme names; Plan 3b uses NvChadTheme, later plans add more.
type Theme struct {
ID string
Name string
NvChadTheme string
}
var BuiltinThemes = []Theme{
{ID: "gruvbox", Name: "Gruvbox Dark", NvChadTheme: "gruvbox"},
{ID: "catppuccin", Name: "Catppuccin Mocha", NvChadTheme: "catppuccin"},
{ID: "tokyonight", Name: "Tokyo Night", NvChadTheme: "tokyonight"},
{ID: "nord", Name: "Nord", NvChadTheme: "nord"},
{ID: "dracula", Name: "Dracula", NvChadTheme: "dracula"},
}
// ThemeByID returns the Theme with the given ID. The bool is false when no
// match is found; callers decide whether to fall back or error.
func ThemeByID(themes []Theme, id string) (Theme, bool) {
for _, t := range themes {
if t.ID == id { return t, true }
}
return Theme{}, false
}
- Step 4: Run tests, confirm pass
go test ./internal/tui/... -v
go test ./...
Both must be green — tui is imported by the wizard and cmd.
- Step 5: Commit
git add internal/tui/state.go internal/tui/state_test.go
git commit -m "feat(tui): add NvChadTheme field and ThemeByID helper"
Task 3: Extend config with NeovimConfig and RenodeConfig
Files:
-
Modify:
internal/config/config.go -
Modify:
internal/config/defaults.yaml -
Modify:
internal/config/config_test.go -
Step 1: Write the failing test
Add to internal/config/config_test.go:
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])
}
}
}
- Step 2: Run test, confirm fail
go test ./internal/config/... -run TestDefaultsCarryPlan3bInputs -v
Expected: cfg.Neovim undefined.
- Step 3: Extend
internal/config/config.go
Add new typed structs above Config:
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"`
}
Add the two fields to Config:
Neovim NeovimConfig `yaml:"neovim"`
Renode RenodeConfig `yaml:"renode"`
Extend merge with:
if over.Neovim.NvChadStarterRepo != "" { out.Neovim = over.Neovim }
if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 { out.Renode = over.Renode }
- Step 4: Extend
defaults.yaml
Append (after existing shell_config: block):
neovim:
nvchad_starter_repo: "https://github.com/NvChad/starter"
renode:
wokwi_version: "0.28.1"
wokwi_url: "https://github.com/wokwi/wokwi-cli/releases/download/v0.28.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"
- Step 5: Run tests, confirm pass
go test ./internal/config/... -v
go test ./...
- Step 6: Commit
git add internal/config/config.go internal/config/defaults.yaml internal/config/config_test.go
git commit -m "feat(config): add Neovim and Renode typed inputs"
Task 4: buildRegistry accepts a theme ID
Files:
- Modify:
cmd/archduino/main.go
This task doesn't register the new modules yet — just changes the signature and updates all call sites, so Task 7's wiring is a one-line-per-module addition.
- Step 1: Read current call sites
grep -n "buildRegistry(" cmd/archduino/main.go
Expected: two call sites (plain-output path and TUI path). Note the line numbers.
- Step 2: Change
buildRegistrysignature and add theme param
Replace the function signature:
func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) {
Leave the body unchanged for this task; themeID is unused until Task 5. Add a _ = themeID inside to avoid an "unused parameter" lint if any linter is strict (Go's stdlib tolerates unused parameters — no lint needed, but be explicit if the subagent's linter complains).
- Step 3: Update both call sites
First call site — plain-output and pre-wizard build. Change:
reg, err := buildRegistry(cfg)
to:
reg, err := buildRegistry(cfg, tui.BuiltinThemes[0].ID)
Add a TUI-path rebuild after the wizard returns. Locate the block (around line 175-200 of main.go) where the wizard's result is consumed before the dashboard runs. After the wizard returns state, confirmed, err (or equivalent), add:
// Rebuild the registry with the user-selected theme so modules like
// neovim see the chosen NvChad theme before Apply runs.
reg, err = buildRegistry(cfg, state.ThemeID)
if err != nil {
return fmt.Errorf("rebuild registry for dashboard: %w", err)
}
(Exact variable names — state, confirmed — come from the existing wizard call. Match them to the current code.)
- Step 4: Run tests
go build ./...
go test ./...
Expected: green. Signature change is backward-compatible with themeID unused.
- Step 5: Commit
git add cmd/archduino/main.go
git commit -m "refactor(cmd): thread theme ID through buildRegistry"
Task 5: Neovim module (scaffold, assets, backup, strip-lspconfig)
Files:
- Create:
internal/modules/neovim/neovim.go - Create:
internal/modules/neovim/neovim_test.go - Create:
internal/modules/neovim/assets/chadrc.lua.tmpl - Create:
internal/modules/neovim/assets/arduino.lua - Create:
internal/modules/neovim/assets/clangd.lua - Create:
internal/modules/neovim/assets/lspconfig.lua - Create:
internal/modules/neovim/assets/dap.lua - Create:
internal/modules/neovim/assets/mappings.lua
This is the biggest task. Complete it in order of the steps below — do not reorder.
- Step 1: Create the asset files
Copy these files verbatim into internal/modules/neovim/assets/. Content comes from the heredocs in lib/neovim.sh (main repo root — NOT inside the worktree).
chadrc.lua.tmpl:
---@type ChadrcConfig
local M = {}
M.base46 = {
theme = "{{.NvChadTheme}}",
}
return M
arduino.lua (copy lines 151-163 of lib/neovim.sh, i.e. the return { { "stevearc/vim-arduino", ... } } block):
return {
{
"stevearc/vim-arduino",
ft = { "arduino", "cpp", "c" },
config = function()
vim.g.arduino_use_cli = 1
vim.g.arduino_cli_path = "arduino-cli"
end,
},
}
clangd.lua:
return {
cmd = {
"clangd",
"--background-index",
"--clang-tidy",
"--header-insertion=iwyu",
"--completion-style=detailed",
},
filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" },
}
lspconfig.lua:
vim.lsp.enable("clangd")
return {}
dap.lua — copy the full dap plugin block from lib/neovim.sh (the return { { "mfussenegger/nvim-dap", ... }, { "rcarriga/nvim-dap-ui", ... }, { "nvim-neotest/nvim-nio", lazy = true } } structure).
mappings.lua — copy the full mappings block from lib/neovim.sh (starts with require "nvchad.mappings", includes all the <leader>a* and <leader>d* bindings).
(Substitute the exact bash-heredoc contents — do not paraphrase.)
- Step 2: Write the failing test
package neovim
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
)
type fakeRunner struct {
runCalls, outCalls [][]string
installed map[string]bool
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
if name == "git" && len(args) >= 1 && args[0] == "clone" {
// Simulate a successful clone by creating the dest dir + a starter init.lua.
dest := args[len(args)-1]
os.MkdirAll(filepath.Join(dest, "lua", "plugins"), 0o755)
os.WriteFile(filepath.Join(dest, "lua", "plugins", "init.lua"),
[]byte("return {\n \"neovim/nvim-lspconfig\",\n opts = {},\n},\n{\n \"other\",\n},\n"),
0o644)
os.MkdirAll(filepath.Join(dest, "lua", "configs"), 0o755)
os.WriteFile(filepath.Join(dest, "lua", "configs", "lspconfig.lua"), []byte("stub"), 0o644)
}
return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] { return []byte("ok"), nil }
return nil, errExit1
}
return nil, nil
}
type exitOne struct{}
func (exitOne) Error() string { return "exit 1" }
var errExit1 = exitOne{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectFreshSystem(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
st, err := m.Detect(context.Background()); if err != nil { t.Fatal(err) }
s := st.(state)
if s.NvimInstalled { t.Error("nvim should be absent") }
if s.ChadrcPresent { t.Error("chadrc should be absent") }
if s.BackupNeeded { t.Error("no backup on empty system") }
}
func TestDetectNonNvChadTriggersBackup(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
// Create a non-NvChad config: lua/ dir present, no chadrc.lua.
os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755)
os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
if !st.(state).BackupNeeded { t.Fatal("expected BackupNeeded=true") }
}
func TestPlanApplyInstallsEverything(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if len(p.Actions) == 0 { t.Fatal("empty plan") }
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) }
sawPacman, sawClone := false, false
for _, c := range r.runCalls {
if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" { sawPacman = true }
if c[0] == "git" && len(c) >= 2 && c[1] == "clone" { sawClone = true }
}
if !sawPacman || !sawClone { t.Fatalf("missing steps; calls=%v", r.runCalls) }
nvimCfg := filepath.Join(home, ".config", "nvim")
// chadrc rendered with selected theme
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) }
// lspconfig block stripped from starter init.lua
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)
}
// starter's lspconfig.lua removed
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "configs", "lspconfig.lua")); !os.IsNotExist(err) {
t.Fatal("starter lspconfig.lua should be removed")
}
// Static assets written
for _, name := range []string{"arduino.lua", "dap.lua", "mappings.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, "lsp", "clangd.lua")); err != nil {
t.Errorf("clangd.lua missing: %v", err)
}
}
func TestPlanAfterApplyIsEmpty(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
_ = m.Apply(context.Background(), p, nopReporter{})
st2, _ := m.Detect(context.Background())
p2, _ := m.Plan(context.Background(), st2)
if !p2.IsEmpty() { t.Fatalf("second plan not empty: %+v", p2.Actions) }
}
func TestUnknownThemeFallsBackToFirst(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) }
chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua"))
if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
t.Fatalf("fallback theme not applied: %s", chadrc)
}
}
var _ module.Module = Module{}
- Step 3: Run test, confirm fail
go test ./internal/modules/neovim/... -v
Expected: undefined: New.
- Step 4: Implement
neovim.go
// 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"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"
"time"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
)
//go:embed assets/*
var assets embed.FS
// assetMap lists each asset and its destination under ~/.config/nvim.
// Order matters: Detect iterates in this order and Apply writes in this order.
var assetMap = []struct {
SrcPath string // under assets/
DstRel string // under ~/.config/nvim/
}{
{"assets/arduino.lua", "lua/plugins/arduino.lua"},
{"assets/dap.lua", "lua/plugins/dap.lua"},
{"assets/lspconfig.lua", "lua/plugins/lspconfig.lua"},
{"assets/mappings.lua", "lua/mappings.lua"},
{"assets/clangd.lua", "lsp/clangd.lua"},
}
type Module struct {
cfg config.NeovimConfig
themeID string
themes []tui.Theme
pm platform.PackageManager
git platform.GitClient
}
func New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module {
return Module{cfg: cfg, themeID: themeID, themes: themes, pm: pm, git: git}
}
type state struct {
NvimInstalled bool
ConfigDirExists bool
ChadrcPresent bool
BackupNeeded bool
BackupSource string
StarterCloned bool // chadrc.lua OR lua/plugins/init.lua implies starter present
MissingAssets []string
ChadrcDrift bool
LspconfigBlock bool // true if starter init.lua still contains the nvim-lspconfig block
StarterLspFile bool // true if lua/configs/lspconfig.lua from starter still exists
ThemeName string
RenderedChadrc []byte
}
func (Module) Meta() module.Meta {
return module.Meta{ID: "neovim", Name: "Neovim + NvChad", Description: "Install Neovim and configure NvChad with Archduino plugins."}
}
func nvimCfg() (string, error) {
h, err := os.UserHomeDir(); if err != nil { return "", err }
return filepath.Join(h, ".config", "nvim"), nil
}
func (m Module) resolvedTheme() string {
if t, ok := tui.ThemeByID(m.themes, m.themeID); ok && t.NvChadTheme != "" {
return t.NvChadTheme
}
if len(m.themes) > 0 { return m.themes[0].NvChadTheme }
return "gruvbox"
}
func (m Module) renderChadrc() ([]byte, error) {
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl"); if err != nil { return nil, err }
tmpl, err := template.New("chadrc").Parse(string(tmplBytes)); if err != nil { return nil, err }
var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: m.resolvedTheme()}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
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
}
// Chadrc drift
if cur, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil {
if !bytes.Equal(cur, rendered) { s.ChadrcDrift = true }
}
// Missing static assets
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
}
var lspconfigBlockRE = regexp.MustCompile(`(?ms)\{[^{}]*"neovim/nvim-lspconfig"[^{}]*\},?\s*`)
func stripLspconfigBlock(src []byte) []byte {
return lspconfigBlockRE.ReplaceAll(src, []byte(""))
}
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"))
},
})
}
// These next two run after clone — they edit starter output.
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 {
return os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua"))
},
})
}
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)
},
})
}
// Theme cache clear (idempotent RemoveAll) — always safe on re-apply.
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
},
})
}
_ = fs.ErrNotExist // keep io/fs import live in case future additions need it
_ = strings.Join // ditto for strings
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.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
}
Note to the implementer: the _ = fs.ErrNotExist / _ = strings.Join lines exist to keep imports; delete them if the final implementation uses those packages naturally (it may, for path-resolving edge cases). If the build complains about unused imports, drop the ones not used.
- Step 5: Run tests, confirm pass
go test ./internal/modules/neovim/... -v
go test ./...
If TestPlanApplyInstallsEverything fails with "nvim-lspconfig not stripped", adjust lspconfigBlockRE — the starter's real init.lua wraps the block across multiple lines; the regex must be multiline and non-greedy. The provided regex (?ms)\{[^{}]*"neovim/nvim-lspconfig"[^{}]*\},?\s* handles a single-level block. If the real starter nests braces inside, switch to a line-range strategy: find the start line containing "neovim/nvim-lspconfig", scan backward to the opening {, scan forward to the matching },, delete that range.
- Step 6: Commit
git add internal/modules/neovim/
git commit -m "feat(modules): port neovim module (NvChad + Archduino Lua assets) to Go"
Task 6: Renode module (yay + pacman + arduino-sim + wokwi)
Files:
-
Create:
internal/modules/renode/renode.go -
Create:
internal/modules/renode/renode_test.go -
Create:
internal/modules/renode/assets/arduino-sim.tmpl -
Step 1: Create the asset template
internal/modules/renode/assets/arduino-sim.tmpl — the bash helper from lib/renode.sh with the case-statement generated from config. Contents:
#!/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"
- Step 2: Write the failing test
package renode
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
)
type fakeRunner struct {
runCalls, outCalls [][]string
installed map[string]bool
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...)); return nil
}
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.outCalls = append(f.outCalls, append([]string{name}, args...))
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
if f.installed[args[1]] { return []byte("ok"), nil }
return nil, errExit1
}
return nil, nil
}
type exitOne struct{}
func (exitOne) Error() string { return "exit 1" }
var errExit1 = exitOne{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func sampleCfg(url string) config.RenodeConfig {
return config.RenodeConfig{
WokwiVersion: "0.28.1",
WokwiURL: url,
Platforms: []config.RenodePlatform{
{Name: "stm32f4", ReplPath: "platforms/boards/stm32f4_discovery.repl", Machine: "STM32F4_Discovery"},
{Name: "rp2040", ReplPath: "platforms/cpus/rp2040.repl", Machine: "RP2040"},
},
}
}
func TestDetectNothingInstalled(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
st, err := m.Detect(context.Background()); if err != nil { t.Fatal(err) }
s := st.(state)
if s.RenodeInstalled || s.GdbInstalled || s.SimScriptCurrent || s.WokwiInstalled {
t.Fatalf("want all absent, got %+v", s)
}
}
func TestDetectAllInstalled(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("x"), 0o755)
r := &fakeRunner{installed: map[string]bool{"renode-bin": true, "arm-none-eabi-gdb": true}}
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
// Apply once to seed the sim script, then re-detect.
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
_ = m.Apply(context.Background(), p, nopReporter{})
st2, _ := m.Detect(context.Background())
p2, _ := m.Plan(context.Background(), st2)
if !p2.IsEmpty() { t.Fatalf("second plan not empty: %+v", p2.Actions) }
}
func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("wokwi-binary"))
}))
defer srv.Close()
r := &fakeRunner{installed: map[string]bool{"yay": true}}
m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) }
// arduino-sim rendered with both platforms
sim, err := os.ReadFile(filepath.Join(home, ".local", "bin", "arduino-sim"))
if err != nil { t.Fatalf("arduino-sim not written: %v", err) }
if !strings.Contains(string(sim), "stm32f4)") { t.Errorf("stm32f4 branch missing: %s", sim) }
if !strings.Contains(string(sim), "rp2040)") { t.Errorf("rp2040 branch missing: %s", sim) }
info, _ := os.Stat(filepath.Join(home, ".local", "bin", "arduino-sim"))
if info.Mode().Perm() != 0o755 { t.Errorf("arduino-sim mode %v", info.Mode().Perm()) }
// wokwi-cli downloaded
wokwi, err := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli"))
if err != nil { t.Fatalf("wokwi-cli missing: %v", err) }
if string(wokwi) != "wokwi-binary" { t.Errorf("wokwi-cli content %q", wokwi) }
// yay install renode-bin called
sawYay := false
for _, c := range r.runCalls {
if c[0] == "yay" && len(c) >= 2 && c[1] == "-S" { sawYay = true }
}
if !sawYay { t.Fatalf("yay install missing; calls=%v", r.runCalls) }
}
var _ module.Module = Module{}
- Step 3: Run test, confirm fail
go test ./internal/modules/renode/... -v
Expected: undefined: New.
- Step 4: Implement
renode.go
// Package renode installs the Renode emulator (AUR), arm-none-eabi-gdb,
// the arduino-sim helper script, and wokwi-cli.
package renode
import (
"bytes"
"context"
"embed"
"fmt"
"os"
"path/filepath"
"text/template"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
)
//go:embed assets/arduino-sim.tmpl
var assetsFS embed.FS
type Module struct {
cfg config.RenodeConfig
pm platform.PackageManager
aur platform.AURHelper
}
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
}
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
}
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
}
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
}
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.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
}
- Step 5: Run tests, confirm pass
go test ./internal/modules/renode/... -v
go test ./...
- Step 6: Commit
git add internal/modules/renode/
git commit -m "feat(modules): port renode module (AUR + arduino-sim + wokwi-cli) to Go"
Task 7: Register neovim + renode in buildRegistry
Files:
-
Modify:
cmd/archduino/main.go -
Step 1: Update
buildRegistry
Locate the mods := []module.Module{ ... } slice (currently ending with shellconfig.New(cfg.ShellConfig)). Append two entries:
neovim.New(cfg.Neovim, themeID, tui.BuiltinThemes, pm, git),
renode.New(cfg.Renode, pm, yay),
Add imports at the top:
"github.com/MDietermann/ArduinoArchSetup/internal/modules/neovim"
"github.com/MDietermann/ArduinoArchSetup/internal/modules/renode"
(tui is already imported.)
- Step 2: Run build + tests
go build ./...
go test ./...
Both green.
- Step 3: Manual smoke — dry Detect/Plan only
go run ./cmd/archduino install --dry-run --no-tui --only neovim,renode
Expected: both modules report their plan actions. No Apply runs in --dry-run.
If --dry-run / --only flags don't exist on the install subcommand yet, skip this step — the full suite passing is sufficient verification.
- Step 4: Commit
git add cmd/archduino/main.go
git commit -m "feat(cmd): register neovim and renode modules in install command"
Self-review notes
- Spec coverage: WriteUserFile (Task 1), theme extension (Task 2), config (Task 3), buildRegistry plumbing (Task 4), neovim (Task 5), renode (Task 6), registration (Task 7). All spec sections mapped.
- Type consistency:
NeovimConfig.NvChadStarterRepo,RenodeConfig.{WokwiVersion, WokwiURL, Platforms},RenodePlatform.{Name, ReplPath, Machine},tui.Theme.NvChadTheme,tui.ThemeByID. Modulestatetypes are module-private.neovim.New(cfg, themeID, themes, pm, git)signature matches Task 7's registration.renode.New(cfg, pm, aur)matches. - Known approximations the engineer should verify:
- Task 5 Step 1 asks the engineer to copy Lua heredocs verbatim from
lib/neovim.sh. Diff the result against the bash file before committing. - Task 5's
lspconfigBlockREregex is a best-effort single-level-brace match. If the NvChad starter's realinit.luauses nested tables inside the lspconfig plugin spec, the regex will under-match. Fallback strategy: parse line-by-line, find the line containing"neovim/nvim-lspconfig", walk backward to the opening{, walk forward with a brace counter to the matching},— adjust during Step 5 if tests fail. - Task 4's rebuild-after-wizard location needs to match the existing code's variable names for the wizard's return tuple. Read the install command carefully before inserting the rebuild.
- Task 5 Step 1 asks the engineer to copy Lua heredocs verbatim from