# 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//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 `a*` and `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 [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" <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.