1371 lines
46 KiB
Markdown
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.
|