docs(plan): Plan 4b force-reapply implementation plan

This commit is contained in:
MDietermann
2026-04-14 21:42:52 +02:00
parent 592d332968
commit 1cd3886d56

View File

@@ -0,0 +1,726 @@
# Force Reapply Implementation Plan (Plan 4b)
> **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:** Add `archduino install --force` that reapplies every selected module unconditionally — new `PlanForce` method on `module.Module`, new `Reinstall` helpers on `platform.Pacman`/`platform.Yay`, engine + wizard + CLI wiring.
**Architecture:** Interface-level `PlanForce(ctx) (Plan, error)` gives each module an explicit "redo everything" path. Engine branches on `RunOptions.Force` to skip `Detect` and call `PlanForce`. Wizard mirrors that branch in its preview cache. Package-only modules reinstall without `--needed`; config-writing modules rewrite managed files.
**Tech Stack:** Go, existing cobra + bubbletea stack. No new dependencies.
**Spec:** `docs/superpowers/specs/2026-04-14-force-reapply-design.md`
---
## File Structure
**Modified (no new files):**
- `internal/platform/pacman.go` + `_test.go` — add `Reinstall`
- `internal/platform/aur.go` + `_test.go` — add `Reinstall`
- `internal/module/module.go` — add `PlanForce` to interface
- `internal/engine/engine.go` + `_test.go` — add `RunOptions.Force`
- `internal/tui/state.go` — add `WizardState.Force`
- `internal/tui/wizard/wizard.go` + `_test.go` — force-aware preview
- `cmd/archduino/main.go` — add `--force` flag
- 13 module files + their tests — implement `PlanForce`
---
## Task 1: Platform layer — `Reinstall` on Pacman and Yay
**Files:**
- Modify: `internal/platform/pacman.go`
- Modify: `internal/platform/pacman_test.go`
- Modify: `internal/platform/aur.go`
- Modify: `internal/platform/aur_test.go`
- [ ] **Step 1: Inspect existing Install methods**
Read the existing `Install` methods in `pacman.go` and `aur.go` to know their exact command construction. The new `Reinstall` must be identical except for the `--needed` flag being absent.
```bash
grep -n -A 15 "func.*Install" internal/platform/pacman.go internal/platform/aur.go
```
Read the test files to understand how commands are asserted (likely via `FakeRunner` recording executed commands).
- [ ] **Step 2: Write failing test for `Pacman.Reinstall`**
Add to `internal/platform/pacman_test.go`:
```go
func TestPacmanReinstall(t *testing.T) {
fr := &FakeRunner{}
p := NewPacman(fr)
if err := p.Reinstall(context.Background(), []string{"git", "curl"}); err != nil {
t.Fatalf("Reinstall: %v", err)
}
if len(fr.Calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(fr.Calls))
}
got := fr.Calls[0]
// Must include -S and --noconfirm but NOT --needed.
if !containsAll(got.Args, "-S", "--noconfirm") {
t.Errorf("expected -S and --noconfirm in args; got %v", got.Args)
}
for _, a := range got.Args {
if a == "--needed" {
t.Errorf("Reinstall must not pass --needed; got args %v", got.Args)
}
}
if !containsAll(got.Args, "git", "curl") {
t.Errorf("expected packages git, curl in args; got %v", got.Args)
}
}
```
Adjust `containsAll` / `FakeRunner` / `Calls` field names to match what the existing tests already use. Read `pacman_test.go` first to confirm the exact helper names — do NOT invent names.
- [ ] **Step 3: Run test, verify it fails**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
go test ./internal/platform/ -run TestPacmanReinstall -v
```
Expected: FAIL with `undefined: Pacman.Reinstall` or similar.
- [ ] **Step 4: Implement `Pacman.Reinstall`**
Add to `internal/platform/pacman.go`, mirroring the existing `Install`:
```go
// Reinstall runs `pacman -S --noconfirm pkg...` without --needed so pacman
// unconditionally reinstalls each package. Used by force-reapply.
func (p Pacman) Reinstall(ctx context.Context, pkgs []string) error {
if len(pkgs) == 0 {
return nil
}
args := append([]string{"pacman", "-S", "--noconfirm"}, pkgs...)
return p.runner.RunSudo(ctx, args[0], args[1:]...)
}
```
Adjust the runner method (`RunSudo` / `Run`) to whatever the existing `Install` uses. Read `Install` first.
- [ ] **Step 5: Run test, verify it passes**
```bash
go test ./internal/platform/ -run TestPacmanReinstall -v
```
Expected: PASS.
- [ ] **Step 6: Repeat Steps 2–5 for `Yay.Reinstall`**
Add `TestYayReinstall` to `internal/platform/aur_test.go` (parallel structure; `yay` command instead of `pacman`). Then implement `Yay.Reinstall` in `aur.go`. Run `go test ./internal/platform/ -run TestYayReinstall -v`, verify it passes.
- [ ] **Step 7: Commit**
```bash
git add internal/platform/pacman.go internal/platform/pacman_test.go \
internal/platform/aur.go internal/platform/aur_test.go
git commit -m "feat(platform): add Reinstall helpers to Pacman and Yay"
```
No Co-Authored-By trailer.
---
## Task 2: Extend Module interface + stub all 13 modules
Adding `PlanForce` to the interface breaks compilation until every module implements it. This task adds the interface method and a *stub* implementation on every module that returns `module.Plan{}, errors.New("PlanForce not implemented")`. Compilation stays green, engine is not wired yet, and each module's real implementation lands in a later task with its own test.
**Files:**
- Modify: `internal/module/module.go`
- Modify: 13 module files listed below
- [ ] **Step 1: Add `PlanForce` to the interface**
Edit `internal/module/module.go`. In the `Module` interface block:
```go
type Module interface {
Meta() Meta
Detect(ctx context.Context) (State, error)
Plan(ctx context.Context, current State) (Plan, error)
PlanForce(ctx context.Context) (Plan, error)
Apply(ctx context.Context, plan Plan, r Reporter) error
Verify(ctx context.Context) error
}
```
Add a doc comment above `PlanForce`:
```go
// PlanForce returns a plan that reapplies the module unconditionally,
// ignoring current detected state. Used by `archduino install --force`.
// Implementations should produce a plan equivalent to a from-scratch
// install — reinstall packages without pacman's --needed guard and
// rewrite managed config files.
```
- [ ] **Step 2: Verify compile fails**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
go build ./... 2>&1 | head -20
```
Expected: compile errors like `*starship.Module does not implement module.Module (missing method PlanForce)` for every module.
- [ ] **Step 3: Add stub `PlanForce` to every module**
For each of the 13 module files below, append this stub at the bottom of the file (replace `Module` receiver type if the file uses something different — read each file first to confirm):
```go
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
return module.Plan{}, errors.New("PlanForce not implemented")
}
```
Ensure `errors` is imported. The module files:
1. `internal/modules/packages/packages.go`
2. `internal/modules/gittools/gittools.go`
3. `internal/modules/fonts/fonts.go`
4. `internal/modules/renode/renode.go`
5. `internal/modules/hello/hello.go`
6. `internal/modules/starship/starship.go`
7. `internal/modules/yazi/yazi.go`
8. `internal/modules/zsh/zsh.go`
9. `internal/modules/zellij/zellij.go`
10. `internal/modules/shellconfig/shellconfig.go`
11. `internal/modules/themes/themes.go`
12. `internal/modules/neovim/neovim.go`
13. `internal/modules/arduinocli/arduinocli.go`
- [ ] **Step 4: Verify compile + existing tests pass**
```bash
go build ./...
go test ./...
```
Expected: compile clean, all existing tests green. The new stubs aren't exercised yet.
- [ ] **Step 5: Commit**
```bash
git add internal/module/module.go internal/modules/
git commit -m "feat(module): add PlanForce to Module interface with stubs"
```
---
## Task 3: Engine — `RunOptions.Force` + branch
**Files:**
- Modify: `internal/engine/engine.go`
- Modify: `internal/engine/engine_test.go`
- [ ] **Step 1: Write failing test**
In `internal/engine/engine_test.go`, add:
```go
func TestRunForceSkipsDetectAndCallsPlanForce(t *testing.T) {
fake := &fakeModule{
id: "fake",
planForce: module.Plan{Actions: []module.Action{{
Description: "forced action",
Exec: func(ctx context.Context) error { return nil },
}}},
}
reg := module.NewRegistry()
if err := reg.Register(fake); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
sum, err := engine.Run(context.Background(), reg, engine.RunOptions{
Selected: []string{"fake"},
Force: true,
Reporter: engine.NewPlainReporter(&buf),
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if fake.detectCalls != 0 {
t.Errorf("Detect should not be called under Force; got %d calls", fake.detectCalls)
}
if fake.planForceCalls != 1 {
t.Errorf("PlanForce should be called once; got %d", fake.planForceCalls)
}
if fake.planCalls != 0 {
t.Errorf("Plan should not be called under Force; got %d", fake.planCalls)
}
if sum.Applied != 1 {
t.Errorf("expected Applied=1, got %d", sum.Applied)
}
}
```
`fakeModule` already exists in `engine_test.go`; extend it with:
- `planForce module.Plan`
- `planForceCalls int`
- Implement `PlanForce(ctx) (module.Plan, error)` that increments the counter and returns `planForce`.
- If `detectCalls`/`planCalls` don't already exist on the fake, add them too (they may already be there — read the file first).
Read `engine_test.go` before editing to confirm the fake's field layout.
- [ ] **Step 2: Run test, verify it fails**
```bash
go test ./internal/engine/ -run TestRunForceSkipsDetectAndCallsPlanForce -v
```
Expected: FAIL — likely `unknown field Force in RunOptions` at compile time.
- [ ] **Step 3: Add `Force` to `RunOptions`**
In `internal/engine/engine.go`, find the `RunOptions` struct and add:
```go
// Force, when true, skips Detect and calls PlanForce on every module
// so each one produces a from-scratch plan regardless of current state.
Force bool
```
- [ ] **Step 4: Branch `Run` on `Force`**
Find the per-module loop inside `Run`. The current shape is roughly:
```go
cur, err := mod.Detect(ctx)
if err != nil { /* handle */ }
plan, err := mod.Plan(ctx, cur)
```
Replace with:
```go
var (
plan module.Plan
err error
)
if opts.Force {
plan, err = mod.PlanForce(ctx)
} else {
cur, derr := mod.Detect(ctx)
if derr != nil {
// preserve existing Detect error handling verbatim
err = derr
} else {
plan, err = mod.Plan(ctx, cur)
}
}
```
Read the current block first and preserve its error-handling shape (reporter calls, halt-on-error, summary bookkeeping). Do not inline a different handling style.
- [ ] **Step 5: Run test, verify it passes**
```bash
go test ./internal/engine/ -run TestRunForceSkipsDetectAndCallsPlanForce -v
go test ./internal/engine/ -v
```
Expected: both green.
- [ ] **Step 6: Commit**
```bash
git add internal/engine/engine.go internal/engine/engine_test.go
git commit -m "feat(engine): add RunOptions.Force to skip Detect and call PlanForce"
```
---
## Task 4: Package-only modules — real `PlanForce`
Package-only modules' `PlanForce` produces a plan that calls `Reinstall` on the full package list.
**Modules covered:** `packages`, `gittools`, `fonts`, `renode`, `hello`.
**Files:** for each module, both `<name>.go` and `<name>_test.go`.
For the four real modules (packages, gittools, fonts, renode), follow the same pattern. Do this **one module at a time** (5 cycles of write-test → verify-fail → implement → verify-pass → next). For `hello`, since it's a placeholder, make `PlanForce` return a fixed non-empty plan (see below).
### Pattern (use for each of packages / gittools / fonts / renode)
- [ ] **Step 1: Write failing `TestPlanForce` in the module's `_test.go`**
Template (adapt field names to the module under test — read the existing `TestPlan` test for each module to see how the fake pacman/yay is wired):
```go
func TestPlanForceReinstallsAllPackages(t *testing.T) {
fp := &FakePacman{installed: map[string]bool{"git": true, "curl": true}}
m := New([]string{"git", "curl"}, fp)
plan, err := m.PlanForce(context.Background())
if err != nil {
t.Fatalf("PlanForce: %v", err)
}
if plan.IsEmpty() {
t.Fatal("force plan must not be empty even when packages are installed")
}
// Execute to confirm it calls Reinstall, not Install.
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
if fp.ReinstallCalls != 1 {
t.Errorf("expected 1 Reinstall call, got %d", fp.ReinstallCalls)
}
}
```
If `FakePacman` doesn't already have a `ReinstallCalls` counter and a `Reinstall` method, add them to the fake first (read the fake in the same test file — most modules share a local fake or use a common one in `internal/platform`).
- [ ] **Step 2: Run test, verify it fails**
```bash
go test ./internal/modules/<name>/ -run TestPlanForce -v
```
Expected: FAIL — the stub returns an error.
- [ ] **Step 3: Replace stub with real `PlanForce`**
In `<name>.go`, replace the stub with (adjust receiver/field names):
```go
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
if len(m.pkgs) == 0 {
return module.Plan{}, nil
}
pkgs := append([]string{}, m.pkgs...)
return module.Plan{Actions: []module.Action{{
Description: "pacman -S (force) " + strings.Join(pkgs, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, pkgs) },
}}}, nil
}
```
Ensure the module's stored `pm` field (or equivalent) has the `Reinstall` method available — it will, because Task 1 added it to both `Pacman` and `Yay`, and the fake was extended in Step 1.
- [ ] **Step 4: Run test, verify it passes**
```bash
go test ./internal/modules/<name>/ -v
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add internal/modules/<name>/
git commit -m "feat(<name>): implement PlanForce to reinstall all packages"
```
### `hello` module
The `hello` module is a placeholder. Implement `PlanForce` as:
```go
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
return module.Plan{Actions: []module.Action{{
Description: "hello force reapply",
Exec: func(ctx context.Context) error { return nil },
}}}, nil
}
```
Add a test that just asserts the plan is non-empty. Same commit cadence as above.
- [ ] **Step 6: Complete all 5 modules before moving on**
After each of packages / gittools / fonts / renode / hello has its own commit, move to Task 5. Do NOT batch them into one commit.
---
## Task 5: Hybrid modules — `starship`, `yazi`
These modules install packages AND (optionally) write configs. Their existing `Plan` already produces a plan that may include both; `PlanForce` should produce both unconditionally.
For each of `starship` and `yazi`:
- [ ] **Step 1: Read the existing `Plan` to see what it does**
```bash
grep -n -A 30 "func.*Module.*Plan\b" internal/modules/starship/starship.go
grep -n -A 30 "func.*Module.*Plan\b" internal/modules/yazi/yazi.go
```
Identify the branches: package install vs. config write (if any).
- [ ] **Step 2: Write failing `TestPlanForce`**
The test must arrange a "fully satisfied" state (packages installed, any config files already present) and assert the force plan is non-empty AND its actions reinstall the packages (via Reinstall call counter) AND rewrite the config files (via a writefile call counter or file presence assertion).
Use the same pattern as Task 4 for the package side. For the config side, use whatever the module's existing tests use to assert config writes (likely `WriteFile` mock or a tempdir).
- [ ] **Step 3: Run test, verify it fails**
```bash
go test ./internal/modules/<name>/ -run TestPlanForce -v
```
- [ ] **Step 4: Replace stub with real `PlanForce`**
```go
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
var actions []module.Action
if len(m.pkgs) > 0 {
pkgs := append([]string{}, m.pkgs...)
actions = append(actions, module.Action{
Description: "pacman -S (force) " + strings.Join(pkgs, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, pkgs) },
})
}
// Any config-write actions the module normally conditions on Detect:
actions = append(actions, /* unconditional config-write actions */ ...)
return module.Plan{Actions: actions}, nil
}
```
Fill in the config-write actions by lifting them from `Plan` but stripping the "already exists" short-circuit. Reuse the same helper functions (e.g. `writeConfig`) that `Plan` uses.
- [ ] **Step 5: Run test, verify it passes, commit**
```bash
go test ./internal/modules/<name>/ -v
git add internal/modules/<name>/
git commit -m "feat(<name>): implement PlanForce (reinstall + rewrite config)"
```
Repeat for both modules, one commit each.
---
## Task 6: Config-writing simple modules — `zsh`, `zellij`, `shellconfig`, `themes`
Same pattern as Task 5 — read existing `Plan`, lift its actions, strip conditionals. Do one module per commit.
For each of `zsh`, `zellij`, `shellconfig`, `themes`:
- [ ] **Step 1: Write failing `TestPlanForce`** — satisfied-system fixture → assert non-empty plan with all the module's write/install actions.
- [ ] **Step 2: Run test, verify it fails.**
- [ ] **Step 3: Replace stub with real implementation.**
- [ ] **Step 4: Run test, verify it passes.**
- [ ] **Step 5: Commit** with message `feat(<name>): implement PlanForce`.
Module-specific notes:
- **`shellconfig`** has no packages; `PlanForce` only produces file-write actions.
- **`themes`** writes under `~/.config/archduino/overlay.yaml` — force rewrites the overlay's theme fields unconditionally.
- **`zsh`** clones plugins (oh-my-zsh, etc.) via `git`; `PlanForce` should clone even if the target already exists — add a pre-step that removes the directory, or use the `git` helper's `ForceClone` if one exists (grep `internal/platform/git.go`; if missing, note it as a concern and fall back to rm-then-clone via an action that uses `os.RemoveAll` before the clone).
---
## Task 7: Config-writing complex modules — `neovim`, `arduinocli`
Same pattern as Task 6, but these modules have multiple sub-steps (neovim: clone NvChad, write chadrc, run post-install; arduinocli: install binary, update index, install cores, install libraries).
For each module:
- [ ] **Step 1: Enumerate the existing `Plan`'s sub-actions.** Every conditional "already done" branch in `Plan` becomes an unconditional branch in `PlanForce`.
- [ ] **Step 2: Write failing `TestPlanForce`** — satisfied-system fixture → assert ALL sub-actions are present (count-based is fine if exact equality is fragile).
- [ ] **Step 3: Run test, verify it fails.**
- [ ] **Step 4: Replace stub with real implementation.**
- [ ] **Step 5: Run test, verify it passes.**
- [ ] **Step 6: Commit** with message `feat(<name>): implement PlanForce`.
**Neovim specifics:**
- If `NvChad` is already cloned, `PlanForce` should either `git pull` + rewrite `chadrc.lua` OR `rm -rf && git clone`. Pick the simpler: rewrite `chadrc.lua` unconditionally, do NOT delete the existing clone. Users' local NvChad modifications shouldn't be wiped by `--force`. Document this in a code comment.
**arduinocli specifics:**
- `PlanForce` should re-download the binary tarball, re-run `arduino-cli core update-index`, and re-install every configured core/library regardless of current installation. Cores install is idempotent at the arduino-cli layer; no issue.
---
## Task 8: TUI wizard — force-aware preview
**Files:**
- Modify: `internal/tui/state.go`
- Modify: `internal/tui/wizard/wizard.go`
- Modify: `internal/tui/wizard/wizard_test.go`
- [ ] **Step 1: Add `Force bool` to `WizardState`**
In `internal/tui/state.go`, find the `WizardState` struct (or wherever the wizard's global state lives) and add a `Force bool` field with a doc comment:
```go
// Force mirrors the --force CLI flag: when true, the wizard's per-module
// plan preview uses PlanForce instead of Detect + Plan, and the header
// shows a "Force mode" banner.
Force bool
```
If `WizardState` is constructed in a factory (e.g., `NewWizardState(cfg, reg)`), leave the constructor signature alone — the flag is set by the CLI after construction.
- [ ] **Step 2: Write failing test for wizard preview under force**
In `internal/tui/wizard/wizard_test.go`, add a test that dispatches the wizard's plan-preview code path with `Force: true` on a fake module whose `Detect` panics (proving it's not called) and whose `PlanForce` returns a non-empty plan.
The exact test shape depends on how the wizard currently tests previews — read the existing preview tests before writing. Bare minimum:
```go
func TestWizardPreviewUsesPlanForceWhenForceSet(t *testing.T) {
mod := &fakeModule{
planForce: module.Plan{Actions: []module.Action{{Description: "forced"}}},
detect: func() (module.State, error) { t.Fatal("Detect should not be called"); return nil, nil },
}
// ... drive the preview path and assert the displayed plan is the PlanForce output
}
```
- [ ] **Step 3: Run test, verify it fails**
```bash
go test ./internal/tui/wizard/ -run TestWizardPreviewUsesPlanForceWhenForceSet -v
```
- [ ] **Step 4: Branch the preview cache on `Force`**
In `internal/tui/wizard/wizard.go`, find the `planCache` / preview logic (Plan 3a Task 1 added this — search for `planCache` or `Detect` call sites inside the wizard). Add a branch:
```go
if state.Force {
plan, err = mod.PlanForce(ctx)
} else {
cur, derr := mod.Detect(ctx)
if derr != nil { /* existing error path */ }
plan, err = mod.Plan(ctx, cur)
}
```
Also: the cache key must include `force` so a force run doesn't reuse a non-force cached plan. If the cache is keyed by module ID only today, extend it to `(moduleID, force)`.
- [ ] **Step 5: Add a header banner**
Find the wizard's view/render code (likely a `View()` method). When `state.Force`, prepend a banner line to the rendered output:
```go
if state.Force {
sb.WriteString(styles.Warning.Render("⚠ Force mode: all selected modules will be reapplied"))
sb.WriteString("\n\n")
}
```
Use whatever Lipgloss style the codebase already has for warnings (grep for `Warning`, `warnStyle`, or similar). If none exists, use a plain bold-red or bold string — do NOT create a new style module just for this.
- [ ] **Step 6: Run test, verify it passes + rest of wizard tests**
```bash
go test ./internal/tui/wizard/ -v
go test ./internal/tui/ -v
```
Expected: all green.
- [ ] **Step 7: Commit**
```bash
git add internal/tui/state.go internal/tui/wizard/
git commit -m "feat(tui): wizard uses PlanForce preview when Force is set"
```
---
## Task 9: CLI — `--force` flag
**Files:**
- Modify: `cmd/archduino/main.go`
- [ ] **Step 1: Add the flag**
In `newInstallCmd`, alongside the other flag declarations (`noTUI`, `dryRun`, `halt`, `noSudo`, `only`, `skip`, `overlayPath`), add:
```go
var force bool
// ...
cmd.Flags().BoolVar(&force, "force", false,
"Reapply every selected module, ignoring current state")
```
- [ ] **Step 2: Propagate to engine and wizard**
In the `RunE` body:
- Where `engine.RunOptions{…}` is constructed (there are currently two call sites: no-TUI path and TUI path — both must set `Force: force`).
- Where `tui.NewWizardState(cfg, reg)` is called: after construction, set `initial.Force = force`.
- [ ] **Step 3: Smoke-test**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
go build ./cmd/archduino
./archduino install --help | grep -A 2 force
./archduino install --force --dry-run --no-tui --no-sudo 2>&1 | head -20
```
Expected: `--help` shows the new flag; a dry-run with `--force` completes without error and prints per-module force plans.
- [ ] **Step 4: Commit**
```bash
git add cmd/archduino/main.go
git commit -m "feat(cmd): add --force flag to install command"
```
---
## Task 10: End-to-end validation
**Files:** none (validation only)
- [ ] **Step 1: Full test suite with race detector**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
go test -race ./...
```
Expected: every package `ok`. Failures here = STOP and fix.
- [ ] **Step 2: Full build**
```bash
go build ./...
```
Expected: exit 0.
- [ ] **Step 3: gofmt + vet**
```bash
diff=$(gofmt -l .); [ -z "$diff" ] && echo "gofmt clean" || { echo "$diff"; exit 1; }
go vet ./...
```
Expected: both clean.
- [ ] **Step 4: Dry-run smoke (without and with force)**
```bash
./archduino install --dry-run --no-tui --no-sudo 2>&1 | tail -10
./archduino install --dry-run --no-tui --no-sudo --force 2>&1 | tail -10
```
Expected: second output's per-module plan is non-empty for every selected module (even ones the first output reports as satisfied).
- [ ] **Step 5: No commit — validation is informational**
---
## Notes for the executing agent
- **Interface change order matters.** Task 2's stubs MUST land before any per-module `PlanForce` test runs (Task 3 onward), otherwise the whole package is red.
- **Read each module's existing `Plan` before writing `PlanForce`.** The force version is almost always "strip the conditionals from Plan". Don't invent new actions.
- **Do not delete users' cloned repos during force.** Especially neovim's NvChad clone. Rewriting `chadrc.lua` is enough; use `git pull` if the user wants updates, not `rm -rf && git clone`.
- **If a module's test file doesn't already have a fake pacman/yay with a `Reinstall` method,** extend the fake in that file (or, if it lives in `internal/platform/testing.go` or similar, extend it once there). Do NOT duplicate fakes — reuse.
- **Conventional Commits:** every module's force implementation gets its own `feat(<name>):` commit. This keeps the changelog readable and svu happy.
- **CI is now live** (Plan 4d merged into this branch). Expect the workflow to run on every push of this plan — if it goes red, the task isn't done.