Files
Archduino/docs/superpowers/plans/2026-04-14-plan3b-install-modules.md

1371 lines
46 KiB
Markdown

# 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:
```go
// 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):
```go
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**
```go
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`**
```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**
```bash
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`:
```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:
```go
// 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**
```bash
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`:
```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`:
```go
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`:
```go
Neovim NeovimConfig `yaml:"neovim"`
Renode RenodeConfig `yaml:"renode"`
```
Extend `merge` with:
```go
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):
```yaml
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**
```bash
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 `buildRegistry` signature and add theme param**
Replace the function signature:
```go
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:
```go
reg, err := buildRegistry(cfg)
```
to:
```go
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:
```go
// 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**
```bash
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**
```go
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`**
```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**
```bash
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**
```go
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`**
```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**
```bash
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:
```go
neovim.New(cfg.Neovim, themeID, tui.BuiltinThemes, pm, git),
renode.New(cfg.Renode, pm, yay),
```
Add imports at the top:
```go
"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**
```bash
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`. Module `state` types 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 `lspconfigBlockRE` regex is a best-effort single-level-brace match. If the NvChad starter's real `init.lua` uses 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.