124 Commits

Author SHA1 Message Date
MDietermann
26d5ec8367 refactor(zellij): improve session/layout handling
Some checks failed
ci / test (push) Has been cancelled
2026-06-25 20:12:28 +02:00
MDietermann
712f09a824 feat(cmd): add --force flag to install command
Some checks failed
ci / test (push) Has been cancelled
2026-04-14 22:31:46 +02:00
MDietermann
ee3bb1d67c feat(tui): wizard uses PlanForce preview when Force is set 2026-04-14 22:24:49 +02:00
MDietermann
48d50efe19 feat(arduinocli): implement PlanForce 2026-04-14 22:16:18 +02:00
MDietermann
25b4eefecd feat(neovim): implement PlanForce 2026-04-14 22:15:32 +02:00
MDietermann
77b767aa0e feat(themes): implement PlanForce 2026-04-14 22:07:48 +02:00
MDietermann
b2cbd4f640 feat(shellconfig): implement PlanForce 2026-04-14 22:06:57 +02:00
MDietermann
4ed257bbd1 feat(zellij): implement PlanForce 2026-04-14 22:06:10 +02:00
MDietermann
47064338c8 feat(zsh): implement PlanForce 2026-04-14 22:05:21 +02:00
MDietermann
abd0a617e2 feat(yazi): implement PlanForce to reinstall all packages 2026-04-14 22:00:42 +02:00
MDietermann
edfff22bac feat(starship): implement PlanForce to reinstall all packages 2026-04-14 22:00:00 +02:00
MDietermann
bd5c34f293 feat(hello): implement PlanForce placeholder to rewrite marker 2026-04-14 21:57:51 +02:00
MDietermann
372a6598fe feat(renode): implement PlanForce to reinstall packages and rewrite assets 2026-04-14 21:57:17 +02:00
MDietermann
c056697721 feat(fonts): implement PlanForce to reinstall all fonts via yay 2026-04-14 21:56:23 +02:00
MDietermann
c75afade8c feat(gittools): implement PlanForce to reinstall all packages 2026-04-14 21:55:43 +02:00
MDietermann
58bd532dba feat(packages): implement PlanForce to reinstall all packages
Adds Reinstall method to PackageManager and AURHelper interfaces
so modules can invoke the concrete Reinstall helpers behind the
interface. *Pacman and *Yay already satisfy it.
2026-04-14 21:55:08 +02:00
MDietermann
613363e993 feat(engine): add RunOptions.Force to skip Detect and call PlanForce 2026-04-14 21:51:29 +02:00
MDietermann
d525646f3a feat(module): add PlanForce to Module interface with stubs 2026-04-14 21:49:09 +02:00
MDietermann
e6e12ab5fd feat(platform): add Reinstall helpers to Pacman and Yay 2026-04-14 21:44:47 +02:00
MDietermann
1cd3886d56 docs(plan): Plan 4b force-reapply implementation plan 2026-04-14 21:42:52 +02:00
MDietermann
592d332968 docs(spec): Plan 4b force-reapply design 2026-04-14 21:39:32 +02:00
MDietermann
f6b39fbdbc ci: add Gitea Actions workflow for test, vet, fmt, build, shellcheck, goreleaser check 2026-04-14 21:31:35 +02:00
MDietermann
c579375c83 style: gofmt 7 test files 2026-04-14 21:30:50 +02:00
MDietermann
137a9b62a5 docs(plan): Plan 4d CI workflow implementation plan 2026-04-14 21:30:01 +02:00
MDietermann
b30471bacb docs(spec): Plan 4d CI workflow design 2026-04-14 21:28:44 +02:00
MDietermann
9d08c635ce docs: update README for bootstrap.sh install; retire install.sh 2026-04-14 21:20:19 +02:00
MDietermann
e0a1628ca5 ci: add Gitea Actions release workflow 2026-04-14 21:19:05 +02:00
MDietermann
5b3ab9154f feat: add bootstrap.sh curl-piped installer 2026-04-14 21:18:30 +02:00
MDietermann
947533a279 build: add Makefile with release, snapshot, completions targets 2026-04-14 21:16:25 +02:00
MDietermann
702638b29c build: add goreleaser config for Gitea releases 2026-04-14 21:12:46 +02:00
MDietermann
d346ca4241 chore: add LICENSE and ignore dist/ and completions/ 2026-04-14 21:06:10 +02:00
MDietermann
ebae3fe8a4 feat(cmd): add version subcommand with ldflag-injected build info 2026-04-14 21:05:30 +02:00
MDietermann
5fe8cb2767 docs(plan): Plan 4c release pipeline implementation plan 2026-04-14 20:58:02 +02:00
MDietermann
79f2bcd230 docs(spec): Plan 4c release pipeline design 2026-04-14 20:54:37 +02:00
MDietermann
929cee1643 chore: rename module to gitlab.hive-brain.de/MDietermann/Archduino 2026-04-14 14:30:14 +02:00
MDietermann
fd87135f62 Merge Plan 4a: theme/new/doctor subcommands + theming package 2026-04-14 14:25:48 +02:00
MDietermann
a203cdfe89 feat(cmd): add theme/new/doctor subcommands; install applies theme 2026-04-14 14:22:33 +02:00
MDietermann
c710ed81df feat(doctor): add archduino doctor subcommand 2026-04-14 14:19:12 +02:00
MDietermann
2464e70cd2 feat(newproject): add archduino new project scaffolder 2026-04-14 14:16:07 +02:00
MDietermann
bbaa21a6bf feat(config): add Scaffolding.Boards typed input with 6 defaults 2026-04-14 14:08:54 +02:00
MDietermann
698271a235 fix(theming): restore gruvbox as default via Default() helper 2026-04-14 13:42:58 +02:00
MDietermann
4f7411ca95 refactor: retire tui.BuiltinThemes in favor of theming.List
Make internal/theming.List the single source of truth for themes:

- Drop tui.BuiltinThemes, tui.ThemeByID, and the NvChadTheme field on
  tui.Theme. tui.Theme is now a thin {ID, Name} display value.
- Wizard populates its dropdown from theming.List() and uses
  DisplayName for the label.
- neovim.resolvedTheme uses theming.Get (with theming.List()[0] as a
  fallback) instead of a caller-supplied []tui.Theme slice.
- neovim.New signature loses the themes []tui.Theme parameter; update
  cmd/archduino/main.go and all neovim tests to match.

Break the resulting neovim→theming import cycle (theming previously
imported neovim for WriteChadrc) by inverting the dependency:
theming exposes a ChadrcWriter function hook, and neovim registers
its WriteChadrc into it via package init. The theming test stubs
ChadrcWriter directly to exercise the chadrc-rewrite branch without
pulling neovim in and re-creating the cycle.

Delete the now-obsolete TestBuiltinThemesCarryNvChadName / TestThemeByID*
tests in internal/tui/state_test.go — their coverage lives in the
theming package's own tests.
2026-04-14 13:35:10 +02:00
MDietermann
12c502d48a feat(theming): add catppuccin, dracula, nord, tokyonight themes 2026-04-14 13:25:58 +02:00
MDietermann
770bdcf3a2 feat(theming): integrate NvChad chadrc rewrite into Apply 2026-04-14 13:14:47 +02:00
MDietermann
4fa580f226 feat(theming): apply updates overlay theme_overrides.__active 2026-04-14 13:11:34 +02:00
MDietermann
b422402ffc feat(theming): apply writes four tool configs to $HOME 2026-04-14 13:07:08 +02:00
MDietermann
b628898c6d feat(theming): add theme discovery with gruvbox assets 2026-04-14 12:56:09 +02:00
MDietermann
465d10a5ef docs(plans): add Plan 4a user-facing subcommands implementation plan 2026-04-14 12:50:09 +02:00
MDietermann
e000e8d22c docs(specs): add Plan 4a user-facing subcommands design 2026-04-14 12:42:53 +02:00
MDietermann
fcbd2201b3 fix(renode): pin wokwi-cli to v0.26.1 (v0.28.1 does not exist) 2026-04-14 12:19:32 +02:00
MDietermann
f177d86255 Merge Plan 3b: neovim + renode modules + theme plumbing 2026-04-14 12:17:02 +02:00
MDietermann
bf2d8de82c feat(cmd): register neovim and renode modules in install command 2026-04-14 12:16:05 +02:00
MDietermann
66f02edfb2 feat(modules): port renode module (AUR + arduino-sim + wokwi-cli) to Go 2026-04-14 12:13:42 +02:00
MDietermann
daa20b1dca fix(neovim): write mappings.lua to lua/mappings.lua per NvChad convention 2026-04-14 10:09:26 +02:00
MDietermann
8677a1b950 feat(modules): port neovim module (NvChad + Archduino Lua assets) to Go 2026-04-14 10:08:06 +02:00
MDietermann
e0c1fc3c5d feat(cmd): thread themeID through buildRegistry, rebuild post-wizard
Adds a themeID parameter to buildRegistry (unused in the body until
Task 7 wires neovim.New) and rebuilds the registry after the wizard
returns with finalState.ThemeID, so the dashboard runs against a
registry configured with the user's theme selection. The pre-wizard
call passes "" since only the dashboard path consumes theme-aware
modules.
2026-04-14 10:03:19 +02:00
MDietermann
e2637bc093 feat(config): add Neovim and Renode typed inputs 2026-04-14 10:01:40 +02:00
MDietermann
e830e4dfda feat(tui): add NvChadTheme field and ThemeByID helper 2026-04-14 09:58:04 +02:00
MDietermann
adfc9232ac feat(platform): add WriteUserFile atomic user-space writer 2026-04-14 09:53:03 +02:00
MDietermann
b9f9f0306d docs(plans): add Plan 3b install-modules implementation plan 2026-04-14 09:48:35 +02:00
MDietermann
027b7a88bc docs(specs): add Plan 3b install-modules (neovim, renode) design 2026-04-14 09:41:15 +02:00
MDietermann
7914b7ef51 fix(installer): replace orphaned neofetch with fastfetch, surface stderr on Run failure
- neofetch was removed from Arch's official repos; fastfetch is the maintained replacement.
- ExecRunner.Run previously discarded stderr, so any 'sudo pacman' failure surfaced only as 'exit status 1'. Now the returned error includes the captured stderr (matching what Output already did).
2026-04-14 09:27:09 +02:00
MDietermann
5587d793df Merge Plan 3a: install modules + platform helpers + wizard cache fix 2026-04-14 09:20:19 +02:00
MDietermann
fa946e9403 feat(cmd): register all Plan 3a modules in install command
Replace the init()-time hello toy-module registration with a
buildRegistry(cfg) helper called from the install RunE that wires the
ten Plan 3a modules (packages, gittools, starship, yazi, zsh, fonts,
zellij, themes, arduinocli, shellconfig) against a shared ExecRunner,
Pacman, Yay, and GitClient.
2026-04-14 09:15:54 +02:00
MDietermann
7de9d6c72c feat(modules): port shellconfig module to Go 2026-04-14 09:10:16 +02:00
MDietermann
99db34d70f feat(modules): port arduino-cli module to Go 2026-04-14 09:07:44 +02:00
MDietermann
9650cd0c82 feat(modules): port themes module to Go 2026-04-14 09:03:49 +02:00
MDietermann
78ad784c7e feat(modules): port zellij module to Go 2026-04-14 08:59:28 +02:00
MDietermann
4b3ce22491 feat(modules): port fonts module (AUR) to Go 2026-04-14 08:56:22 +02:00
MDietermann
1735f66bf0 feat(modules): port zsh module (zsh + oh-my-zsh + plugins) to Go 2026-04-14 08:52:27 +02:00
MDietermann
6dbfc41ce2 feat(modules): port yazi module to Go 2026-04-14 08:47:00 +02:00
MDietermann
f5a45c4be6 feat(modules): port starship module to Go 2026-04-14 08:47:00 +02:00
MDietermann
2b30b371e4 feat(modules): port gittools (lazygit) module to Go 2026-04-14 08:46:49 +02:00
MDietermann
cba883376a feat(modules): port packages module to Go 2026-04-14 08:43:22 +02:00
MDietermann
ffd18c5382 feat(config): add typed module-input fields with defaults 2026-04-14 08:40:29 +02:00
MDietermann
fcc24b99b2 feat(platform): add AURHelper with yay bootstrap and install 2026-04-14 08:35:46 +02:00
MDietermann
1903217b5f feat(platform): add GitClient helper with idempotent EnsureRepo 2026-04-14 08:32:04 +02:00
MDietermann
3f4223879b feat(platform): add tar.gz and zip extract helpers with traversal guard 2026-04-14 08:29:42 +02:00
MDietermann
2d73114b5f feat(platform): add atomic HTTP download helper 2026-04-14 08:25:22 +02:00
MDietermann
afed8ab600 feat(platform): add textblock helper for idempotent config-file blocks 2026-04-14 08:21:38 +02:00
MDietermann
83bb622232 perf(tui): cache Detect+Plan results in wizard renderPlan 2026-04-14 08:18:38 +02:00
MDietermann
05dc8aa7f5 chore: ignore .worktrees 2026-04-14 08:13:47 +02:00
MDietermann
42dabf85a5 docs(plans): add Plan 3a install-modules implementation plan 2026-04-14 08:11:03 +02:00
MDietermann
6318362e99 docs(specs): add Plan 3a install-modules design 2026-04-14 08:03:17 +02:00
MDietermann
2ebaa6b5c6 fix: address Plan 2 final-review findings
- tui/doc.go: remove teatest blank import (test harness in prod binary)
- cmd: honor wizard HaltOnError toggle in addition to --halt-on-error flag
- cmd: wire SIGINT/SIGTERM to root context via signal.NotifyContext
- dashboard/run: derive engine context and cancel on tea.Program exit
  so the engine goroutine does not outlive Run
2026-04-13 22:26:54 +02:00
MDietermann
ea5667e040 test(tui): integration smoke — dashboard runs hello module end-to-end 2026-04-13 22:20:08 +02:00
MDietermann
4f3d736bd7 refactor(cmd): drop err=runErr reassignment, return runErr directly 2026-04-13 22:19:09 +02:00
MDietermann
341250d8f4 feat(cmd): wire TUI wizard and dashboard into install subcommand 2026-04-13 22:13:52 +02:00
MDietermann
7baf0fceaa feat(tui): add dashboard.Run bridging engine to Bubble Tea 2026-04-13 22:11:54 +02:00
MDietermann
5c5076f1f1 fix(tui): guard against late Progress/Log after DoneMsg; drop dead quitting field 2026-04-13 22:09:08 +02:00
MDietermann
feb01242f2 feat(tui): add Bubble Tea dashboard model for Apply phase 2026-04-13 22:02:38 +02:00
MDietermann
e7aa75735d refactor(tui): use strconv.Atoi in parseIntClamp, drop spurious error return 2026-04-13 22:00:49 +02:00
MDietermann
394fb33d0c fix(tui): short-circuit wizard.Run on cancelled context before opening TTY 2026-04-13 21:52:50 +02:00
MDietermann
d21ab3fd30 feat(tui): add six-screen Huh wizard for installer input 2026-04-13 21:52:17 +02:00
MDietermann
c0983284b3 feat(tui): add TeaReporter forwarding engine events to bubbletea 2026-04-13 21:49:14 +02:00
MDietermann
258b53b6a0 feat(tui): add shared WizardState seeded from config and registry 2026-04-13 21:48:12 +02:00
MDietermann
777a3fe49b chore(tui): add Charm dependencies and package skeleton 2026-04-13 21:47:02 +02:00
MDietermann
07c13551ce docs: add Plan 2 (TUI) implementation plan 2026-04-13 21:40:44 +02:00
MDietermann
8b43a0f273 fix: address final-review findings before closing Plan 1
- privilege: wire sudo -v to real stdin/stdout/stderr so it can prompt
- cmd: validate --only/--skip IDs against registry (prevents silent
  run-all on typo); gate install to Arch-only (spec requirement);
  mark --only and --skip mutually exclusive
- platform: Pacman.Update now runs -Syu --noconfirm, not -Sy (avoids
  Arch partial-upgrade anti-pattern)
- test/integration: trap-clean the named docker volume on any exit
- go.mod: tidy — yaml.v3 is a direct dep
2026-04-13 21:35:14 +02:00
MDietermann
40ac730719 test(integration): run archduino install end-to-end on archlinux:base 2026-04-13 21:22:29 +02:00
MDietermann
3a4855b92a feat(modules): add hello toy module as engine smoke test 2026-04-13 20:31:43 +02:00
MDietermann
7a3ebbb146 docs(cmd): clarify why !noTUI branch prints the fallback notice 2026-04-13 20:18:07 +02:00
MDietermann
7a382c9bdd feat(cmd): wire install subcommand to engine with plain reporter 2026-04-13 20:13:50 +02:00
MDietermann
e4f945146b feat(state): persist per-run logs with rotation and last-run pointer 2026-04-13 20:02:48 +02:00
MDietermann
8f9e34174c feat(privilege): acquire sudo and keep timestamp fresh via goroutine 2026-04-13 20:01:34 +02:00
MDietermann
83cf7f0e72 feat(platform): add PackageManager interface and Pacman implementation 2026-04-13 20:00:11 +02:00
MDietermann
3b666615d7 feat(platform): detect distro, WSL, and architecture 2026-04-13 19:59:04 +02:00
MDietermann
ea0d793461 feat(config): load embedded defaults and merge optional overlay 2026-04-13 19:56:53 +02:00
MDietermann
0f0ea7e925 refactor(engine): remove dead failed map from markFailed 2026-04-13 19:55:41 +02:00
MDietermann
94c7084ec3 feat(engine): run modules through Detect/Plan/Apply/Verify with DAG + skip-on-failure 2026-04-13 19:49:52 +02:00
MDietermann
37761e017e feat(engine): add topological sort and descendants for module DAG 2026-04-13 19:39:35 +02:00
MDietermann
30643e385d feat(engine): add Reporter with buffered and plain implementations 2026-04-13 19:37:51 +02:00
MDietermann
eb54225ad3 feat(module): add four-phase Module interface and registry 2026-04-13 19:29:18 +02:00
MDietermann
5b5e677749 chore: go mod tidy, pin go 1.22, clarify module path in plan 2026-04-13 19:21:13 +02:00
MDietermann
94dbea001c docs: add Plan 1 (Foundation) implementation plan 2026-04-13 18:44:47 +02:00
MDietermann
b952704be5 docs: add installer architecture redesign spec
Design for Go rewrite of the Bash installer: single static binary with
Bubble Tea + Huh TUI, four-phase module contract, DAG execution engine,
embedded config + optional overlay, and a minimal Bash pre-install shim.
Scoped to installer architecture only; toolset overhaul, CLI browser,
and TTY Nerd Font support are queued as follow-up specs.
2026-04-13 18:44:47 +02:00
MDietermann
5b8de40b4c chore: bootstrap Go project with cobra skeleton 2026-04-13 18:24:37 +02:00
MDietermann
897dc424dc Updated Gitignore 2026-04-13 17:40:22 +02:00
MDietermann
9c3cdbc90f Updated Gitignore 2026-04-13 17:39:18 +02:00
MDietermann
f53663298e fix(wokwi): add serial monitor config and USB_SERIAL_JTAG for ESP32-S3
- Add serialMonitor display config to all diagram templates
- Set USB_SERIAL_JTAG attr on ESP32-S3 for built-in USB serial
- Add TX/RX → $serialMonitor wiring for ESP32 (UART-based serial)
- Remove disconnected breadboard parts (wired directly to MCU pins)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:40:42 +02:00
MDietermann
33f1112bf8 fix(renode): wokwi-cli releases are plain binaries, not tarballs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:27:07 +02:00
MDietermann
9bec4660f6 feat: add Wokwi CLI for visual breadboard simulation
- Install wokwi-cli binary from GitHub releases
- Generate wokwi.toml and board-specific diagram.json in arduino-new
  with a button + LED breadboard layout for each supported board
- Add <leader>aw keybinding in nvim to run Wokwi simulation
- Diagram templates for: uno, mega, nano, esp32, esp32s3 (+ default)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:05:24 +02:00
MDietermann
9c38a8414e fix(neovim): only remove lspconfig config, keep conform and other starter plugins
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 15:27:06 +02:00
MDietermann
b512d41dc8 feat: add Renode emulator, nvim-dap debugging, and backup collision fix
- Add Renode installation (AUR/deb) with arduino-sim helper script
  supporting STM32F4, RP2040, and nRF52840 (USB HID) platforms
- Add nvim-dap + nvim-dap-ui with GDB remote adapter for Renode debugging
- Add debug keybindings under <leader>d prefix
- Fix backup_config() path collisions when multiple sources share a
  basename (e.g. ~/.config/nvim and ~/.local/share/nvim)
- Strip NvChad starter's nvim-lspconfig block that conflicts with
  native vim.lsp.config, preserving configs/lazy.lua
- Update step counts from 11 to 12 (TOTAL_STEPS 12→13, 13→14 on WSL)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 15:23:57 +02:00
159 changed files with 23212 additions and 49 deletions

52
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,52 @@
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Install container prereqs
run: pacman -Sy --needed --noconfirm base-devel git shellcheck
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: go vet
run: go vet ./...
- name: gofmt check
run: |
diff=$(gofmt -l .)
if [ -n "$diff" ]; then
echo "files need gofmt:"
echo "$diff"
exit 1
fi
- name: go build
run: go build ./...
- name: go test -race
run: go test -race ./...
- name: shellcheck bootstrap.sh
run: shellcheck bootstrap.sh
- name: Install goreleaser
run: go install github.com/goreleaser/goreleaser/v2@latest
- name: goreleaser check
run: $(go env GOPATH)/bin/goreleaser check

View File

@@ -0,0 +1,30 @@
name: release
on:
push:
tags:
- 'v*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Run goreleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}

17
.gitignore vendored
View File

@@ -1 +1,16 @@
.idea
.idea
WokwiKey.txt
# Go
/archduino
/dist/
*.test
*.out
coverage.*
.worktrees/
# goreleaser output
dist/
# generated shell completions
completions/

76
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,76 @@
version: 2
project_name: archduino
before:
hooks:
- mkdir -p completions
- sh -c "go run ./cmd/archduino completion bash > completions/archduino.bash"
- sh -c "go run ./cmd/archduino completion zsh > completions/_archduino"
- sh -c "go run ./cmd/archduino completion fish > completions/archduino.fish"
builds:
- id: archduino
main: ./cmd/archduino
binary: archduino
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.version={{ .Version }}
- -X main.commit={{ .Commit }}
- -X main.date={{ .Date }}
archives:
- id: default
name_template: "archduino_{{ .Version }}_linux_{{ .Arch }}"
formats: [tar.gz]
files:
- README.md
- LICENSE
- src: completions/archduino.bash
dst: completions/archduino.bash
- src: completions/_archduino
dst: completions/_archduino
- src: completions/archduino.fish
dst: completions/archduino.fish
checksum:
name_template: "checksums.txt"
algorithm: sha256
changelog:
use: git
sort: asc
filters:
exclude:
- '^chore:'
- '^docs:'
- '^test:'
- '^ci:'
- '^style:'
- '^refactor:'
- 'Merge pull request'
- 'Merge branch'
groups:
- title: Features
regexp: '^feat(\(.*\))?:'
order: 0
- title: Bug fixes
regexp: '^(fix|perf)(\(.*\))?:'
order: 1
- title: Others
order: 999
gitea_urls:
api: https://gitlab.hive-brain.de/api/v1
download: https://gitlab.hive-brain.de
release:
draft: false
prerelease: auto

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 MDietermann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

39
Makefile Normal file
View File

@@ -0,0 +1,39 @@
.PHONY: build snapshot release completions clean help
GO ?= go
BIN := archduino
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
build: ## Local dev build into ./archduino
$(GO) build -o $(BIN) ./cmd/archduino
completions: ## Regenerate shell completions into completions/
@mkdir -p completions
$(GO) run ./cmd/archduino completion bash > completions/archduino.bash
$(GO) run ./cmd/archduino completion zsh > completions/_archduino
$(GO) run ./cmd/archduino completion fish > completions/archduino.fish
snapshot: ## Local goreleaser dry run into dist/
goreleaser release --snapshot --clean
clean: ## Remove build artifacts
rm -rf dist/ completions/ $(BIN)
release: ## Compute next SemVer from commits, tag, and push
@command -v svu >/dev/null || { echo "svu not found. Run: go install github.com/caarlos0/svu@latest"; exit 1; }
@test -z "$$(git status --porcelain)" || { echo "working tree is dirty; commit or stash first"; exit 1; }
@branch=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$branch" != "master" ]; then echo "must be on master (currently on $$branch)"; exit 1; fi
@current=$$(svu current 2>/dev/null || echo "none"); \
next=$$(svu next); \
echo "Current: $$current"; \
echo "Next: $$next"; \
echo; \
echo "Commits since $$current:"; \
if [ "$$current" = "none" ]; then git log --oneline; else git log --oneline $$current..HEAD; fi; \
echo; \
printf "Tag %s and push? [y/N] " "$$next"; read ans; \
if [ "$$ans" != "y" ] && [ "$$ans" != "Y" ]; then echo "aborted"; exit 1; fi; \
git tag "$$next" && git push origin "$$next"

View File

@@ -73,22 +73,35 @@ This updates Starship, Zellij, Lazygit, Yazi, and NvChad in one step.
## 🚀 Quickstart
## Installation
One-line install on Arch Linux:
```bash
git clone --depth=1 https://github.com/<your-username>/ArduinoArch.git
cd ArduinoArch
chmod +x install.sh
./install.sh
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
```
The installer will:
This fetches the latest release, verifies its checksum, installs `archduino`
to `/usr/local/bin`, installs shell completions, and launches
`archduino install`.
1. Show a banner with detected OS and package manager
2. Let you pick your **colorscheme** (applied to all tools)
3. Let you choose between **progress bar** or **verbose** output
4. Ask for your sudo password **once** (cached for the entire install)
5. Run through all 11 setup steps automatically
### Development
After it finishes, start a new shell or run `source ~/.zshrc`.
Local build:
```bash
make build
./archduino --help
```
Releasing (maintainers only):
```bash
go install github.com/caarlos0/svu@latest
make release # prompts with computed next version, tags, pushes
```
After install, start a new shell or run `source ~/.zshrc`.
---
@@ -96,7 +109,10 @@ After it finishes, start a new shell or run `source ~/.zshrc`.
```
ArduinoArch/
├── install.sh # Main entry point
├── bootstrap.sh # Curl-piped installer shim (fetches latest release)
├── cmd/archduino/ # Go CLI entry point
├── internal/ # Go packages (install steps, theming, detect, etc.)
├── install.sh.legacy # Deprecated legacy Bash installer (kept for reference)
└── lib/
├── common.sh # Colors, helpers, environment detection, progress bar
├── themes.sh # Colorscheme selection menu

65
bootstrap.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
REPO="MDietermann/Archduino"
BASE_URL="${ARCHDUINO_RELEASE_BASE:-https://gitlab.hive-brain.de}"
API_URL="${BASE_URL}/api/v1"
err() { echo "bootstrap: error: $*" >&2; exit 1; }
msg() { echo "==> $*"; }
# Arch gate
[ -f /etc/arch-release ] || err "Archduino v1 supports Arch Linux only."
# Arch detect
case "$(uname -m)" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
*) err "unsupported architecture: $(uname -m)" ;;
esac
# Prereqs
msg "Installing prerequisites (sudo, git, curl, ca-certificates)"
sudo pacman -Sy --needed --noconfirm sudo git curl ca-certificates
# Discover latest release
msg "Querying latest release from Gitea"
API_ENDPOINT="${API_URL}/repos/${REPO}/releases/latest"
RELEASE_JSON=$(curl -fsSL "$API_ENDPOINT") || err "failed to fetch ${API_ENDPOINT}"
# Extract tag_name without requiring jq
TAG=$(printf '%s' "$RELEASE_JSON" | grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
[ -n "$TAG" ] || err "could not parse tag_name from release metadata"
VERSION="${TAG#v}"
msg "Latest release: ${TAG}"
# Download
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
TARBALL="archduino_${VERSION}_linux_${ARCH}.tar.gz"
CHECKSUMS="checksums.txt"
DL_BASE="${BASE_URL}/${REPO}/releases/download/${TAG}"
msg "Downloading ${TARBALL}"
curl -fsSL -o "${TMP}/${TARBALL}" "${DL_BASE}/${TARBALL}" || err "tarball download failed"
curl -fsSL -o "${TMP}/${CHECKSUMS}" "${DL_BASE}/${CHECKSUMS}" || err "checksums download failed"
# Verify
msg "Verifying checksum"
(cd "$TMP" && sha256sum --check --ignore-missing "$CHECKSUMS") || err "checksum verification failed"
# Extract
msg "Extracting"
tar -xzf "${TMP}/${TARBALL}" -C "$TMP"
# Install
msg "Installing to /usr/local/bin (sudo)"
sudo install -m 0755 "${TMP}/archduino" /usr/local/bin/archduino
msg "Installing shell completions"
sudo install -Dm 0644 "${TMP}/completions/archduino.bash" /usr/share/bash-completion/completions/archduino
sudo install -Dm 0644 "${TMP}/completions/_archduino" /usr/share/zsh/site-functions/_archduino
sudo install -Dm 0644 "${TMP}/completions/archduino.fish" /usr/share/fish/vendor_completions.d/archduino.fish
msg "Launching: archduino install $*"
exec /usr/local/bin/archduino install "$@"

443
cmd/archduino/main.go Normal file
View File

@@ -0,0 +1,443 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/arduinocli"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/fonts"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/gittools"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/neovim"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/packages"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/renode"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/shellconfig"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/starship"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/themes"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/yazi"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zellij"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zsh"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/privilege"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/state"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/doctor"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/newproject"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/dashboard"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/wizard"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
// buildRegistry constructs a module registry populated with all Plan 3a
// modules, wired against the provided config and platform dependencies.
// It replaces the earlier init()-time hello-module registration now that
// the real modules need runtime config to be instantiated.
func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) {
reg := module.NewRegistry()
runner := platform.ExecRunner{}
pm := platform.NewPacman(runner)
yay := platform.NewYay(runner)
git := platform.GitClient{Runner: runner}
mods := []module.Module{
packages.New(cfg.Packages.Base, pm),
gittools.New([]string{"lazygit"}, pm),
starship.New([]string{"starship"}, pm),
yazi.New([]string{"yazi"}, pm),
zsh.New(cfg.Zsh, pm, git),
fonts.New(cfg.Fonts, pm, yay),
zellij.New(cfg.Zellij, pm),
themes.New(cfg.Themes),
arduinocli.New(cfg.ArduinoCLI, runner),
shellconfig.New(cfg.ShellConfig),
neovim.New(cfg.Neovim, themeID, pm, git),
renode.New(cfg.Renode, pm, yay),
}
for _, m := range mods {
if err := reg.Register(m); err != nil {
return nil, err
}
}
return reg, nil
}
func main() {
root := &cobra.Command{
Use: "archduino",
Short: "Archduino installer and toolbox",
SilenceUsage: true,
SilenceErrors: true,
Version: version,
}
root.AddCommand(newInstallCmd())
root.AddCommand(newThemeCmd())
root.AddCommand(newNewCmd())
root.AddCommand(newDoctorCmd())
root.AddCommand(newVersionCmd())
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases
// (engine.Run, sudo keepalive) observe the cancellation promptly.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func newInstallCmd() *cobra.Command {
var (
noTUI bool
dryRun bool
halt bool
noSudo bool
force bool
only []string
skip []string
overlayPath string
)
cmd := &cobra.Command{
Use: "install",
Short: "Install or reconcile the Archduino toolset",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
// Gate: v1 supports Arch only. Non-Arch hosts would silently run
// the hello module today but fail on Plan 3's pacman-dependent
// modules — fail fast with a clear message instead.
if !noSudo { // --no-sudo implies a test/container context; skip the gate
p, derr := platform.Detect()
if derr != nil {
return fmt.Errorf("platform detect: %w", derr)
}
if p.Distro != platform.DistroArch {
return fmt.Errorf("archduino requires Arch Linux (detected distro: %s)", p.Distro)
}
}
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
reg, err := buildRegistry(cfg, "")
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
selected, err := resolveSelection(reg, cfg, only, skip)
if err != nil {
return err
}
// Acquire sudo unless dry-run or explicitly skipped.
if !dryRun && !noSudo {
k, err := privilege.Acquire(ctx, privilege.ExecSudo{}, 50*time.Second)
if err != nil {
return err
}
defer k.Stop()
}
started := time.Now().UTC()
var (
sum engine.Summary
runErr error
selectedTheme = cfg.ThemeOverrides["__active"]
)
if noTUI || dryRun {
// Plain-output path: preserve the Plan 1 behaviour. Dry-run
// always uses plain output because the dashboard has nothing
// to animate when no Apply happens.
reporter := engine.NewPlainReporter(os.Stdout)
sum, runErr = engine.Run(ctx, reg, engine.RunOptions{
Selected: selected,
DryRun: dryRun,
HaltOnError: halt,
Force: force,
Reporter: reporter,
})
} else {
// TUI path: wizard → dashboard. If the user does not confirm,
// we exit cleanly with a zero-summary and no error.
initial := tui.NewWizardState(cfg, reg)
initial.Force = force
// Pre-filter the wizard's module list by --only/--skip so the
// user cannot accidentally widen selection in the TUI beyond
// what they asked for on the CLI.
if len(selected) < len(initial.Modules) {
wanted := map[string]bool{}
for _, id := range selected {
wanted[id] = true
}
for i := range initial.Modules {
initial.Modules[i].Selected = wanted[initial.Modules[i].ID]
}
}
finalState, confirmed, werr := wizard.Run(ctx, reg, initial)
if werr != nil {
return werr
}
if !confirmed {
fmt.Fprintln(os.Stdout, "installation cancelled")
return nil
}
// Rebuild the registry with the theme the user chose in the
// wizard so theme-aware modules (e.g. neovim in Task 7) pick
// up the selection instead of the empty pre-wizard default.
reg, err = buildRegistry(cfg, finalState.ThemeID)
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
if finalState.ThemeID != "" {
selectedTheme = finalState.ThemeID
}
// Either the CLI flag or the wizard toggle can force halt-on-error.
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
}
ended := time.Now().UTC()
if runErr != nil {
return runErr
}
// Apply the wizard-selected theme (no-op if user chose a theme
// whose configs are already on disk). Skipped on dry-run.
if !dryRun && selectedTheme != "" {
if err := theming.Apply(cfg, selectedTheme); err != nil {
fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
}
}
ok, fail, skipped := sum.Counts()
fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped)
for _, r := range sum.Results {
line := fmt.Sprintf(" %-16s %s", r.ID, r.Status)
if r.Err != nil {
line += " — " + r.Err.Error()
}
fmt.Fprintln(os.Stdout, line)
}
if !dryRun {
if err := writeState(started, ended, sum); err != nil {
fmt.Fprintln(os.Stderr, "warning: state write failed:", err)
}
}
if fail > 0 {
return fmt.Errorf("%d module(s) failed", fail)
}
return nil
},
}
cmd.Flags().BoolVar(&noTUI, "no-tui", false, "plain-text output, no interactive UI")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned actions without applying")
cmd.Flags().BoolVar(&halt, "halt-on-error", false, "stop at first failure instead of continuing siblings")
cmd.Flags().BoolVar(&noSudo, "no-sudo", false, "skip upfront sudo -v (for tests / containers with passwordless sudo)")
cmd.Flags().BoolVar(&force, "force", false, "reapply every selected module, ignoring current state")
cmd.Flags().StringSliceVar(&only, "only", nil, "comma-separated module IDs to run (exclusive with --skip)")
cmd.Flags().StringSliceVar(&skip, "skip", nil, "comma-separated module IDs to skip")
cmd.Flags().StringVar(&overlayPath, "config", "", "path to overlay.yaml (default: ~/.config/archduino/overlay.yaml)")
cmd.MarkFlagsMutuallyExclusive("only", "skip")
return cmd
}
func resolveSelection(reg *module.Registry, cfg config.Config, only, skip []string) ([]string, error) {
all := reg.All()
known := map[string]bool{}
for _, m := range all {
known[m.Meta().ID] = true
}
// Validate every explicit ID the user passed so a typo in --only does not
// silently fall through to "run all modules".
for _, id := range only {
if !known[id] {
return nil, fmt.Errorf("unknown module in --only: %q", id)
}
}
for _, id := range skip {
if !known[id] {
return nil, fmt.Errorf("unknown module in --skip: %q", id)
}
}
disabled := map[string]bool{}
for _, d := range cfg.DisabledModules {
disabled[d] = true
}
for _, d := range skip {
disabled[d] = true
}
onlySet := map[string]bool{}
for _, o := range only {
onlySet[o] = true
}
var out []string
for _, m := range all {
id := m.Meta().ID
if disabled[id] {
continue
}
if len(onlySet) > 0 && !onlySet[id] {
continue
}
out = append(out, id)
}
return out, nil
}
func writeState(started, ended time.Time, sum engine.Summary) error {
s, err := state.DefaultStore()
if err != nil {
return err
}
rec := state.RunRecord{StartedAt: started, EndedAt: ended}
for _, r := range sum.Results {
mr := state.ModuleRecord{ID: r.ID, Status: string(r.Status), At: ended}
if r.Err != nil {
mr.Error = r.Err.Error()
}
rec.Modules = append(rec.Modules, mr)
}
return s.Save(rec, 10)
}
func newThemeCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "theme [id]",
Short: "Switch the active colorscheme across tools",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
// "list" short-circuit.
if len(args) == 1 && args[0] == "list" {
for _, t := range theming.List() {
fmt.Printf("%s — %s\n", t.ID, t.DisplayName)
}
return nil
}
var id string
if len(args) == 1 {
id = args[0]
} else {
themes := theming.List()
opts := make([]huh.Option[string], 0, len(themes))
for _, t := range themes {
opts = append(opts, huh.NewOption(t.DisplayName, t.ID))
}
id = cfg.ThemeOverrides["__active"]
if id == "" && len(themes) > 0 {
id = themes[0].ID
}
form := huh.NewForm(huh.NewGroup(
huh.NewSelect[string]().Title("Choose a theme").Options(opts...).Value(&id),
))
if err := form.RunWithContext(cmd.Context()); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil
}
return err
}
}
if err := theming.Apply(cfg, id); err != nil {
return err
}
fmt.Printf("Applied theme: %s\n", id)
fmt.Println("Restart zellij and neovim to see the new colors.")
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml (defaults to ~/.config/archduino/overlay.yaml)")
return cmd
}
func newNewCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "new <project> <board>",
Short: "Scaffold a new Arduino sketch directory",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
project, board := args[0], args[1]
if err := newproject.Run(cfg, project, board); err != nil {
return err
}
fmt.Printf("Created %s/ (board: %s)\n", project, board)
fmt.Printf("Next: cd %s && nvim %s.ino\n", project, project)
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd
}
func newDoctorCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "doctor",
Short: "Report module convergence and pinned-tool version drift",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
reg, err := buildRegistry(cfg, cfg.ThemeOverrides["__active"])
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
code := doctor.Run(cmd.Context(), cfg, reg, platform.ExecRunner{}, os.Stdout)
if code != 0 {
os.Exit(code)
}
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd
}
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version, commit, and build date",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("archduino %s\ncommit: %s\ndate: %s\n", version, commit, date)
},
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
# CI Workflow Implementation Plan (Plan 4d)
> **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 a single Gitea Actions CI workflow that runs `go vet` + `gofmt` check + `go build` + `go test -race` + `shellcheck` + `goreleaser check` inside an `archlinux:latest` container on every push and PR.
**Architecture:** One workflow file (`.gitea/workflows/ci.yml`), one job with sequential steps. The Arch container gives environmental fidelity; `actions/setup-go@v5` pins Go via `go-version-file: go.mod` for reproducibility.
**Tech Stack:** Gitea Actions, archlinux:latest container, Go (version pinned in go.mod), shellcheck, goreleaser v2.
**Spec:** `docs/superpowers/specs/2026-04-14-ci-workflow-design.md`
---
## File Structure
**New files:**
- `.gitea/workflows/ci.yml` — single-job CI workflow
**No other files change.**
---
## Task 1: Pre-flight — verify all CI steps pass locally
Before writing the workflow, run every command it will execute locally to confirm the repo is in a CI-passing state. If any command fails, either the plan's step is wrong or the repo has a latent issue — either way, CI will fail without this check.
**Files:** none (validation only)
- [ ] **Step 1: `go vet ./...`**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
go vet ./...
```
Expected: exit 0, no output.
If warnings appear, STOP and report — fixing them is out of scope for this plan but must happen before CI can land.
- [ ] **Step 2: `gofmt` check**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
diff=$(gofmt -l .)
if [ -n "$diff" ]; then echo "FILES NEEDING FMT:"; echo "$diff"; exit 1; fi
echo "gofmt clean"
```
Expected: `gofmt clean`.
If any files are listed, STOP and report. Fixing formatting is out of scope — do NOT auto-fix in this plan. The user should decide whether to format in a separate commit first.
- [ ] **Step 3: `go build ./...`**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
go build ./...
```
Expected: exit 0, no output.
- [ ] **Step 4: `go test -race ./...`**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
go test -race ./...
```
Expected: all packages report `ok` or `no test files`. Failures = STOP and report. Race detector findings = STOP and report (they indicate real concurrency bugs).
- [ ] **Step 5: `shellcheck bootstrap.sh`**
```bash
command -v shellcheck || { echo "shellcheck not installed; install via: sudo pacman -S shellcheck"; exit 1; }
shellcheck /home/marvin/git-clones/ArduinoArchSetup/bootstrap.sh
```
Expected: exit 0, no output.
If shellcheck isn't installed locally, install it (`sudo pacman -S shellcheck`) and retry. If findings appear, STOP and report — bootstrap.sh was validated by `bash -n` only, so this is the first shellcheck run.
- [ ] **Step 6: `goreleaser check`**
```bash
command -v goreleaser || command -v /home/marvin/go/bin/goreleaser || { echo "goreleaser missing"; exit 1; }
(command -v goreleaser && goreleaser check) || /home/marvin/go/bin/goreleaser check
```
Expected: `• 1 configuration file(s) validated` or equivalent success message.
- [ ] **Step 7: No commit**
This task is informational. If all six steps passed, proceed to Task 2. If any failed, report findings to the controller and HALT — the CI workflow cannot land until the repo is in a passing state.
---
## Task 2: Author `.gitea/workflows/ci.yml`
**Files:**
- Create: `.gitea/workflows/ci.yml`
- [ ] **Step 1: Write the workflow file**
Create `/home/marvin/git-clones/ArduinoArchSetup/.gitea/workflows/ci.yml`:
```yaml
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Install container prereqs
run: pacman -Sy --needed --noconfirm base-devel git shellcheck
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: go vet
run: go vet ./...
- name: gofmt check
run: |
diff=$(gofmt -l .)
if [ -n "$diff" ]; then
echo "files need gofmt:"
echo "$diff"
exit 1
fi
- name: go build
run: go build ./...
- name: go test -race
run: go test -race ./...
- name: shellcheck bootstrap.sh
run: shellcheck bootstrap.sh
- name: Install goreleaser
run: go install github.com/goreleaser/goreleaser/v2@latest
- name: goreleaser check
run: $(go env GOPATH)/bin/goreleaser check
```
- [ ] **Step 2: Validate YAML syntax**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/ci.yml'))" && echo "yaml ok"
```
Expected: `yaml ok`.
- [ ] **Step 3: Sanity-check step ordering against spec**
Open the file and confirm the step order matches the spec's Section "Job steps":
1. Install container prereqs
2. Checkout
3. Setup Go
4. go vet
5. gofmt check
6. go build
7. go test -race
8. shellcheck bootstrap.sh
9. Install goreleaser
10. goreleaser check
No grouping or reordering. This is only a human-verified check (no command).
- [ ] **Step 4: Commit**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
git add .gitea/workflows/ci.yml
git commit -m "ci: add Gitea Actions workflow for test, vet, fmt, build, shellcheck, goreleaser check"
```
Do NOT add any Co-Authored-By trailer — repo convention forbids it.
---
## Notes for the executing agent
- **One file, one job.** Don't split into multiple jobs or multiple workflows. The spec is explicit.
- **Do not try to run the workflow itself** — that requires a Gitea Actions runner (Plan 4c Task 9, currently deferred). The workflow will queue when pushed until a runner is online; that's fine.
- **If Task 1 reveals failures** in `go vet`, `gofmt`, `go test -race`, or `shellcheck`, stop and escalate. Those are pre-existing issues the controller needs to address in a separate commit/plan; this plan only adds the workflow that detects them.
- **Why `$(go env GOPATH)/bin/goreleaser` explicitly** in step 10: `go install` puts binaries in `GOPATH/bin`, which isn't on `$PATH` inside the stock Arch container. Using the absolute path is simpler than mutating PATH.
- **YAML indentation is significant.** Use 2-space indents matching the example above. Do not substitute tabs.

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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,789 @@
# Release Pipeline Implementation Plan (Plan 4c)
> **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:** Ship versioned Archduino releases to self-hosted Gitea with one-command `make release`, and let users install via `curl | bash`.
**Architecture:** `bootstrap.sh` (curl-piped installer) + `.goreleaser.yaml` (cross-compile/archive/publish) + `Makefile` (svu-driven bump) + `.gitea/workflows/release.yml` (tag-triggered CI). Binary version injected via `-ldflags` into existing `main.version` var.
**Tech Stack:** Go 1.x, goreleaser v2, svu, Gitea Actions, cobra (already in tree).
**Spec:** `docs/superpowers/specs/2026-04-14-release-pipeline-design.md`
---
## File Structure
**New files:**
- `bootstrap.sh` — curl-piped installer
- `.goreleaser.yaml` — goreleaser config
- `Makefile` — release/snapshot/completions/build targets
- `.gitea/workflows/release.yml` — tag-triggered release workflow
- `LICENSE` — repo license (MIT; confirm with user during Task 7)
- `.gitignore` additions: `dist/`, `completions/`
**Modified files:**
- `cmd/archduino/main.go` — add `commit`/`date` ldflag-injected vars; ensure `version` cobra subcommand prints them; ensure `completion` subcommand registered
- `README.md` — install-via-bootstrap section, `svu` dev note
**Removed/archived files:**
- `install.sh` → `install.sh.legacy` (kept for reference, renamed so it's not confused with entry point)
---
## Task 1: Build-info ldflag wiring + version subcommand
**Files:**
- Modify: `cmd/archduino/main.go`
- Test: manual smoke (no Go test — version output is a trivial glue)
- [ ] **Step 1: Add commit/date vars and `version` subcommand**
Edit `cmd/archduino/main.go`. Change the `version` declaration and add two new package-level vars just below it:
```go
var (
version = "dev"
commit = "none"
date = "unknown"
)
```
Add a `newVersionCmd` function at the bottom of the file:
```go
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version, commit, and build date",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("archduino %s\ncommit: %s\ndate: %s\n", version, commit, date)
},
}
}
```
Register it in `main()` alongside the other `root.AddCommand` calls:
```go
root.AddCommand(newVersionCmd())
```
- [ ] **Step 2: Verify `completion` subcommand is available**
Cobra registers a `completion` command automatically unless disabled. Run:
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
go run ./cmd/archduino completion bash | head -5
```
Expected: bash completion script output (starts with `# bash completion V2 for archduino`).
If the command is missing, it means `CompletionOptions.DisableDefaultCmd` was set somewhere — grep for it and remove.
- [ ] **Step 3: Smoke-test the version command**
```bash
go run ./cmd/archduino version
```
Expected output:
```
archduino dev
commit: none
date: unknown
```
Then verify ldflag injection works:
```bash
go run -ldflags "-X main.version=v0.9.9 -X main.commit=abc123 -X main.date=2026-04-14" ./cmd/archduino version
```
Expected:
```
archduino v0.9.9
commit: abc123
date: 2026-04-14
```
- [ ] **Step 4: Commit**
```bash
git add cmd/archduino/main.go
git commit -m "feat(cmd): add version subcommand with ldflag-injected build info"
```
---
## Task 2: .gitignore + LICENSE + completions dir
**Files:**
- Modify: `.gitignore`
- Create: `LICENSE`
- Create: `completions/.gitkeep`
- [ ] **Step 1: Update .gitignore**
Append to `.gitignore` (create the file if missing):
```
# goreleaser output
dist/
# generated shell completions
completions/
```
- [ ] **Step 2: Ask user for LICENSE choice**
Before writing `LICENSE`, pause and ask the user which license (MIT, Apache-2.0, GPL-3.0, etc.). Default suggestion: MIT. Do not proceed with a guess.
Once confirmed, write `LICENSE` with the standard text for the chosen license. For MIT:
```
MIT License
Copyright (c) 2026 MDietermann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
- [ ] **Step 3: Commit**
```bash
git add .gitignore LICENSE
git commit -m "chore: add LICENSE and ignore dist/ and completions/"
```
---
## Task 3: .goreleaser.yaml
**Files:**
- Create: `.goreleaser.yaml`
- [ ] **Step 1: Write the goreleaser config**
Create `.goreleaser.yaml`:
```yaml
version: 2
project_name: archduino
before:
hooks:
- mkdir -p completions
- sh -c "go run ./cmd/archduino completion bash > completions/archduino.bash"
- sh -c "go run ./cmd/archduino completion zsh > completions/_archduino"
- sh -c "go run ./cmd/archduino completion fish > completions/archduino.fish"
builds:
- id: archduino
main: ./cmd/archduino
binary: archduino
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.version={{ .Version }}
- -X main.commit={{ .Commit }}
- -X main.date={{ .Date }}
archives:
- id: default
name_template: "archduino_{{ .Version }}_linux_{{ .Arch }}"
formats: [tar.gz]
files:
- README.md
- LICENSE
- src: completions/archduino.bash
dst: completions/archduino.bash
- src: completions/_archduino
dst: completions/_archduino
- src: completions/archduino.fish
dst: completions/archduino.fish
checksum:
name_template: "checksums.txt"
algorithm: sha256
changelog:
use: git
sort: asc
filters:
exclude:
- '^chore:'
- '^docs:'
- '^test:'
- '^ci:'
- '^style:'
- '^refactor:'
- 'Merge pull request'
- 'Merge branch'
groups:
- title: Features
regexp: '^feat(\(.*\))?:'
order: 0
- title: Bug fixes
regexp: '^(fix|perf)(\(.*\))?:'
order: 1
- title: Others
order: 999
release:
gitea_urls:
api: https://gitlab.hive-brain.de/api/v1
download: https://gitlab.hive-brain.de
draft: false
prerelease: auto
```
- [ ] **Step 2: Install goreleaser locally if not present**
```bash
command -v goreleaser || go install github.com/goreleaser/goreleaser/v2@latest
goreleaser --version
```
Expected: prints a version ≥ v2.
- [ ] **Step 3: Validate the config**
```bash
cd /home/marvin/git-clones/ArduinoArchSetup
goreleaser check
```
Expected: `• config is valid`. If errors, fix them before proceeding.
- [ ] **Step 4: Run a snapshot build**
```bash
goreleaser release --snapshot --clean
```
Expected: `dist/` populated with two tar.gz archives (`archduino_*_linux_amd64.tar.gz`, `archduino_*_linux_arm64.tar.gz`), `checksums.txt`, and extracted binary subdirs. No errors.
- [ ] **Step 5: Inspect archive contents**
```bash
tar -tzf dist/archduino_*_linux_amd64.tar.gz
```
Expected file list: `archduino`, `README.md`, `LICENSE`, `completions/archduino.bash`, `completions/_archduino`, `completions/archduino.fish`.
- [ ] **Step 6: Verify binary has injected version**
```bash
tar -xzf dist/archduino_*_linux_amd64.tar.gz -C /tmp/archduino-smoke --one-top-level
/tmp/archduino-smoke/archduino_*_linux_amd64/archduino version
```
Expected: prints a snapshot version (looks like `0.0.0-next-<commit>` or similar) and a real commit + date.
- [ ] **Step 7: Commit**
```bash
git add .goreleaser.yaml
git commit -m "build: add goreleaser config for Gitea releases"
```
---
## Task 4: Makefile
**Files:**
- Create: `Makefile`
- [ ] **Step 1: Install svu locally**
```bash
command -v svu || go install github.com/caarlos0/svu@latest
svu --version
```
Expected: prints a version.
- [ ] **Step 2: Write the Makefile**
Create `Makefile`:
```makefile
.PHONY: build snapshot release completions clean help
GO ?= go
BIN := archduino
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
build: ## Local dev build into ./archduino
$(GO) build -o $(BIN) ./cmd/archduino
completions: ## Regenerate shell completions into completions/
@mkdir -p completions
$(GO) run ./cmd/archduino completion bash > completions/archduino.bash
$(GO) run ./cmd/archduino completion zsh > completions/_archduino
$(GO) run ./cmd/archduino completion fish > completions/archduino.fish
snapshot: ## Local goreleaser dry run into dist/
goreleaser release --snapshot --clean
clean: ## Remove build artifacts
rm -rf dist/ completions/ $(BIN)
release: ## Compute next SemVer from commits, tag, and push
@command -v svu >/dev/null || { echo "svu not found. Run: go install github.com/caarlos0/svu@latest"; exit 1; }
@test -z "$$(git status --porcelain)" || { echo "working tree is dirty; commit or stash first"; exit 1; }
@branch=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$branch" != "master" ]; then echo "must be on master (currently on $$branch)"; exit 1; fi
@current=$$(svu current 2>/dev/null || echo "none"); \
next=$$(svu next); \
echo "Current: $$current"; \
echo "Next: $$next"; \
echo; \
echo "Commits since $$current:"; \
if [ "$$current" = "none" ]; then git log --oneline; else git log --oneline $$current..HEAD; fi; \
echo; \
printf "Tag %s and push? [y/N] " "$$next"; read ans; \
if [ "$$ans" != "y" ] && [ "$$ans" != "Y" ]; then echo "aborted"; exit 1; fi; \
git tag "$$next" && git push origin "$$next"
```
- [ ] **Step 3: Smoke-test non-destructive targets**
```bash
make help
make build && ./archduino version
make completions && ls completions/
```
Expected: `help` lists targets; `build` produces `./archduino`; `completions` generates three files.
- [ ] **Step 4: Dry-run the release guard**
```bash
# With dirty tree (touch a file first if tree is clean)
touch /tmp/dirt && make release || echo "guard worked as expected"
```
Expected: aborts with "working tree is dirty". Do NOT actually run `make release` to completion yet — that creates a real tag.
- [ ] **Step 5: Commit**
```bash
git add Makefile
git commit -m "build: add Makefile with release, snapshot, completions targets"
```
---
## Task 5: bootstrap.sh
**Files:**
- Create: `bootstrap.sh`
- [ ] **Step 1: Write bootstrap.sh**
Create `bootstrap.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
REPO="MDietermann/Archduino"
BASE_URL="${ARCHDUINO_RELEASE_BASE:-https://gitlab.hive-brain.de}"
API_URL="${BASE_URL}/api/v1"
err() { echo "bootstrap: error: $*" >&2; exit 1; }
msg() { echo "==> $*"; }
# Arch gate
[ -f /etc/arch-release ] || err "Archduino v1 supports Arch Linux only."
# Arch detect
case "$(uname -m)" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
*) err "unsupported architecture: $(uname -m)" ;;
esac
# Prereqs
msg "Installing prerequisites (sudo, git, curl, ca-certificates)"
sudo pacman -Sy --needed --noconfirm sudo git curl ca-certificates
# Discover latest release
msg "Querying latest release from Gitea"
API_ENDPOINT="${API_URL}/repos/${REPO}/releases/latest"
RELEASE_JSON=$(curl -fsSL "$API_ENDPOINT") || err "failed to fetch ${API_ENDPOINT}"
# Extract tag_name without requiring jq
TAG=$(printf '%s' "$RELEASE_JSON" | grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
[ -n "$TAG" ] || err "could not parse tag_name from release metadata"
VERSION="${TAG#v}"
msg "Latest release: ${TAG}"
# Download
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
TARBALL="archduino_${VERSION}_linux_${ARCH}.tar.gz"
CHECKSUMS="checksums.txt"
DL_BASE="${BASE_URL}/${REPO}/releases/download/${TAG}"
msg "Downloading ${TARBALL}"
curl -fsSL -o "${TMP}/${TARBALL}" "${DL_BASE}/${TARBALL}" || err "tarball download failed"
curl -fsSL -o "${TMP}/${CHECKSUMS}" "${DL_BASE}/${CHECKSUMS}" || err "checksums download failed"
# Verify
msg "Verifying checksum"
(cd "$TMP" && sha256sum --check --ignore-missing "$CHECKSUMS") || err "checksum verification failed"
# Extract
msg "Extracting"
tar -xzf "${TMP}/${TARBALL}" -C "$TMP"
# Install
msg "Installing to /usr/local/bin (sudo)"
sudo install -m 0755 "${TMP}/archduino" /usr/local/bin/archduino
msg "Installing shell completions"
sudo install -Dm 0644 "${TMP}/completions/archduino.bash" /usr/share/bash-completion/completions/archduino
sudo install -Dm 0644 "${TMP}/completions/_archduino" /usr/share/zsh/site-functions/_archduino
sudo install -Dm 0644 "${TMP}/completions/archduino.fish" /usr/share/fish/vendor_completions.d/archduino.fish
msg "Launching: archduino install $*"
exec /usr/local/bin/archduino install "$@"
```
- [ ] **Step 2: Make it executable and shellcheck it**
```bash
chmod +x bootstrap.sh
command -v shellcheck && shellcheck bootstrap.sh || echo "(shellcheck not installed — skipping)"
```
Expected: `shellcheck` passes with no warnings. If warnings appear, fix them (common ones: quote expansions, etc.).
- [ ] **Step 3: Smoke-test the non-root path in a container**
This step requires a snapshot tarball. Skip if Docker is unavailable — the workflow will validate end-to-end.
```bash
make snapshot
# Serve dist/ locally
(cd dist && python3 -m http.server 8000 &) ; SERVER_PID=$!
sleep 1
# In a throwaway Arch container, verify the prereq + download logic.
# Since Gitea API isn't reachable in the container, we only eyeball the
# script behavior up to the API call:
docker run --rm -v "$PWD/bootstrap.sh:/bootstrap.sh:ro" archlinux bash -c \
'head -40 /bootstrap.sh && echo "--- script is syntactically valid shell ---" && bash -n /bootstrap.sh'
kill $SERVER_PID 2>/dev/null || true
```
Expected: `bash -n` reports no syntax errors. (Full e2e test is gated on Task 8.)
- [ ] **Step 4: Commit**
```bash
git add bootstrap.sh
git commit -m "feat: add bootstrap.sh curl-piped installer"
```
---
## Task 6: .gitea/workflows/release.yml
**Files:**
- Create: `.gitea/workflows/release.yml`
- [ ] **Step 1: Write the workflow**
Create `.gitea/workflows/release.yml`:
```yaml
name: release
on:
push:
tags:
- 'v*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Run goreleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
```
- [ ] **Step 2: Lint the YAML**
```bash
command -v yamllint && yamllint .gitea/workflows/release.yml || python3 -c "import yaml,sys; yaml.safe_load(open('.gitea/workflows/release.yml'))" && echo "yaml ok"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/release.yml
git commit -m "ci: add Gitea Actions release workflow"
```
---
## Task 7: Retire install.sh and update README
**Files:**
- Rename: `install.sh` → `install.sh.legacy`
- Modify: `README.md`
- [ ] **Step 1: Rename install.sh**
```bash
git mv install.sh install.sh.legacy
```
Add a one-line header to the top of `install.sh.legacy` explaining the rename. Use Edit with these exact lines (the first original line is `#!/bin/bash` or similar — verify and preserve):
Read the first 3 lines first to know exact content:
```bash
head -3 install.sh.legacy
```
Then use Edit to prepend:
```bash
# DEPRECATED: This is the legacy Bash installer. The active installer is
# bootstrap.sh (curl-piped shim) + the archduino Go binary. Kept for reference.
```
Inserted as the second line (below the shebang).
- [ ] **Step 2: Update README install section**
Read the current install section:
```bash
grep -n -A 20 -i "install" README.md | head -40
```
Replace whatever install instructions currently exist with a block that contains:
```markdown
## Installation
One-line install on Arch Linux:
```bash
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
```
This fetches the latest release, verifies its checksum, installs `archduino`
to `/usr/local/bin`, installs shell completions, and launches
`archduino install`.
### Development
Local build:
```bash
make build
./archduino --help
```
Releasing (maintainers only):
```bash
go install github.com/caarlos0/svu@latest
make release # prompts with computed next version, tags, pushes
```
```
Use Edit to splice this in at the appropriate location. If the README has no install section, append it after the title/intro.
- [ ] **Step 3: Commit**
```bash
git add install.sh.legacy README.md
git rm install.sh 2>/dev/null || true # git mv already handles this
git commit -m "docs: update README for bootstrap.sh install; retire install.sh"
```
---
## Task 8: End-to-end snapshot verification
**Files:** none (validation only)
- [ ] **Step 1: Clean snapshot**
```bash
make clean
make snapshot
ls dist/
```
Expected: `checksums.txt`, two `.tar.gz` archives, build dirs.
- [ ] **Step 2: Verify tarball contents match bootstrap.sh expectations**
```bash
tar -tzf dist/archduino_*_linux_amd64.tar.gz | sort
```
Expected (order may vary):
```
LICENSE
README.md
archduino
completions/_archduino
completions/archduino.bash
completions/archduino.fish
```
The paths must match what `bootstrap.sh` extracts and installs. If the archive layout differs (e.g. everything under a top-level dir), adjust `.goreleaser.yaml` `archives.wrap_in_directory: false` or update `bootstrap.sh`'s install paths.
- [ ] **Step 3: Verify checksums file format**
```bash
cat dist/checksums.txt
```
Expected: one line per archive, `<sha256> archduino_<version>_linux_<arch>.tar.gz`. This is what `sha256sum --check` consumes.
- [ ] **Step 4: Bash-syntax-check bootstrap.sh against a snapshot in a container**
```bash
docker run --rm -v "$PWD:/work:ro" archlinux bash -c '
set -e
pacman -Sy --needed --noconfirm bash curl >/dev/null
bash -n /work/bootstrap.sh
echo "syntax ok"
'
```
Expected: `syntax ok`. (Full network-dependent e2e is gated on the first real release.)
- [ ] **Step 5: Commit nothing; verification is informational**
No commit. If any step failed, go back to the relevant task and fix.
---
## Task 9: Register Gitea Actions runner and first release
**Files:** none (operational)
This task is operational, performed once. Coordinate with the user before executing.
- [ ] **Step 1: Confirm with the user**
Before running any of the steps below, confirm with the user that they want to proceed with a real release. Gitea Actions runner registration and the first tag are both irreversible-ish actions that affect the remote repo.
- [ ] **Step 2: Register a Gitea Actions runner**
Runner registration is a one-time step and is performed on a host the user controls (likely the Gitea server or a persistent machine). High-level steps (user runs these — agent does not execute on remote hosts):
1. In Gitea UI → Repo Settings → Actions → Runners → generate a registration token.
2. On the runner host, install `act_runner` (https://gitea.com/gitea/act_runner).
3. `act_runner register --instance https://gitlab.hive-brain.de --token <TOKEN>`.
4. `act_runner daemon &` (or set up a systemd unit).
Verify in the Gitea UI that the runner shows as online.
- [ ] **Step 3: Create a repo-scoped release token**
1. Gitea UI → User Settings → Applications → Generate Token.
2. Scope: `write:repository` (and `write:package` if future-proofing).
3. Copy the token.
4. Repo → Settings → Actions → Secrets → New secret named `RELEASE_TOKEN`, paste the token value.
- [ ] **Step 4: Seed the first tag**
```bash
# Confirm branch/working-tree
git checkout master
git pull
git status
# Seed v0.1.0 manually so svu has a baseline
git tag v0.1.0
git push origin v0.1.0
```
Watch the Gitea Actions tab — the `release` workflow should pick up the tag and run goreleaser. Expected outcome: a new Release page at `https://gitlab.hive-brain.de/MDietermann/Archduino/releases/tag/v0.1.0` with two tarballs and `checksums.txt` attached.
- [ ] **Step 5: Smoke-test bootstrap.sh against the real release**
In a throwaway Arch container:
```bash
docker run --rm -it archlinux bash -c '
pacman -Sy --noconfirm curl >/dev/null
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash -s -- --dry-run
'
```
Expected: prereqs install, release detected, tarball downloaded, checksum verified, `archduino install --dry-run` runs and exits cleanly.
- [ ] **Step 6: Merge to master**
Once the first real release is verified, merge the worktree branch back to `master`:
```bash
git checkout master
git merge --no-ff feat/plan4c-release
git push origin master
```
(Branch name may differ — use whichever worktree was created.)
---
## Notes for the executing agent
- **No Go-side tests** are created in this plan. The deliverables are configs, scripts, and a CI workflow — validation is via `goreleaser check`, `make snapshot`, `bash -n`, and `shellcheck`.
- **Conventional Commits are required from here on.** `svu` and the goreleaser changelog both depend on them. Commit messages in this plan use that format as a model.
- **Do not run `make release` during implementation** — it creates real git tags that would pollute the release history. The first real release happens in Task 9 after user confirmation.
- **If Task 3 snapshot fails** because `completion` isn't registered, revisit Task 1 Step 2 — cobra may have had its default subcommand disabled.
- **If Task 8 reveals archive layout mismatches**, the fix is in `.goreleaser.yaml` (files stanza or `wrap_in_directory`) rather than `bootstrap.sh`, because goreleaser's layout is the source of truth.

View File

@@ -0,0 +1,228 @@
# Installer Architecture Redesign
**Status:** Design approved, pending implementation plan
**Date:** 2026-04-13
**Scope:** Installer architecture only. Toolset overhaul, CLI browser, and TTY-font story are queued as follow-up specs.
## Context
The current Archduino installer is a Bash entry point (`install.sh`) that sources 13 files under `lib/`. It has grown beyond what Bash handles comfortably:
- **Monolithic / ordering-fragile.** `install.sh` sources lib files in a fixed order at top level; hard to run a single step and hard to re-run safely.
- **Not idempotent / no resume.** Re-running either duplicates work or silently skips things.
- **Mixed concerns per file.** Detection, templating, package install, and theme application are intertwined in every lib.
- **Thin UX.** Only choices are theme + verbose; no dry-run, no module selection, no preview.
- **Platform coupling.** Arch vs Ubuntu-WSL branches are scattered throughout.
The goal is a principled rebuild across all five axes.
## Decision summary
| Area | Choice |
|---|---|
| Language / runtime | Go, single static binary |
| UI | Bubble Tea + Huh (wizard for input, dashboard for execution) |
| Pre-install shim | Minimal Bash script: ensures `sudo`, `git`, `curl`, `ca-certificates`, fetches binary |
| Module contract | Four-phase: `Detect → Plan → Apply → Verify` |
| Configuration | Embedded defaults + optional `~/.config/archduino/overlay.yaml` |
| Privilege model | User-mode binary; `sudo` per privileged command; upfront `sudo -v` + keepalive goroutine |
| State / logging | `Detect()` is authoritative; run log is advisory UX only |
| Failure policy | DAG execution; failed node fails its descendants; siblings proceed; `--halt-on-error` flag available |
| Subcommands | One binary: `install`, `theme`, `new`, `doctor` |
| Platform scope (v1) | Arch-only native + WSL2; `PackageManager` interface ready for Debian/Fedora later |
## Architecture
### Module contract
Every installable unit implements:
```go
type Module interface {
Meta() ModuleMeta // id, human name, description, deps []string
Detect(ctx) (State, error) // read current system
Plan(ctx, State) (Plan, error) // diff vs desired; []Action with descriptions
Apply(ctx, Plan, *Reporter) error // execute; stream progress to Reporter
Verify(ctx) error // confirm desired state reached
}
```
- `State` is module-defined.
- `Plan` is a flat list of `Action{Description, Kind, Exec}` so the engine can render plans without knowing modules.
- `Reporter` decouples modules from the TUI.
- **`Detect()` is authoritative.** No cached state overrides what the system reports.
Initial module set (1:1 with today's `lib/*.sh`):
`packages`, `fonts`, `zsh`, `starship`, `zellij`, `neovim`, `yazi`, `arduino-cli`, `renode`, `git` (lazygit), `scaffold`, `shell-config`, `themes`.
### Execution engine
- **DAG-scheduled.** Modules declare `deps []string`. Engine topologically sorts and runs independent branches concurrently, default parallelism 4.
- **Failure policy.** Failed node marks its descendants `skipped-due-to-dep`; siblings proceed. End-of-run summary: `X succeeded, Y failed, Z skipped`.
- **Dry-run** (`--dry-run`, or wizard "Review plan" step): runs Detect + Plan for every selected module, renders the combined plan, exits.
- **Halt mode** (`--halt-on-error`): opts into fail-fast for scripted use.
- **Resume hint.** If a prior run log exists, TUI offers "last run: `starship` failed — resume? / start over? / pick modules?" Hint only; Detect decides what actually runs.
### Configuration
- **Embedded defaults** via `//go:embed`: package lists, theme files, Zellij layouts, config templates.
- **Optional overlay** at `~/.config/archduino/overlay.yaml`:
```yaml
version: 1
extra_packages: [htop, ncdu]
disabled_modules: [renode]
theme_overrides:
starship: ~/my-starship.toml
```
- Overlay is merged at load time. Schema is versioned; a migration path exists when it grows.
### Privilege model
- Binary runs as the invoking user.
- On `install` start, one `sudo -v` call; a keepalive goroutine refreshes the timestamp every 50 seconds while the installer is live.
- Privileged operations shell out to `sudo pacman …`, `sudo usermod …`. If sudo needs to re-prompt (timestamp expired), the TUI is suspended via `tea.ExecProcess`.
- Config files, git clones, and anything under `$HOME` run as the user — no root-owned files in home.
### Platform abstraction
```go
type PackageManager interface {
Install(pkgs []string) error
IsInstalled(pkg string) (bool, error)
Update() error
Name() string
}
```
- V1 ships `Pacman` only. Debian/Ubuntu/Fedora impls are stubbed; wiring them is a later spec.
- `Platform{Distro, IsWSL, Arch}` detection is centralized. WSL path sets `/etc/wsl.conf` hints and adds the user to `dialout`/`uucp`.
### State and logs
- `~/.local/state/archduino/runs/<timestamp>.json` — per-run structured log: `{module, phase, status, duration, error}`.
- `~/.local/state/archduino/last.json` — most recent run; read by TUI for the resume hint.
- Log rotation: keep the last 10 runs.
- Safe to delete at any time; never read for correctness decisions.
## User-facing surface
### Pre-install shim (`scripts/bootstrap.sh`)
~30 lines of Bash, served from a stable URL. Invocation:
```bash
curl -fsSL https://archduino.dev/install.sh | bash
```
Responsibilities in order:
1. Refuse to run as root.
2. Detect distro; abort with a clear message if not Arch (v1 scope).
3. Ensure `sudo`, `git`, `curl`, `ca-certificates` are installed (`pacman -S --needed`).
4. Detect architecture, fetch the matching release binary from GitHub Releases, verify its SHA256 against the published checksum file, install to `~/.local/bin/archduino`, `chmod +x`.
5. `exec archduino install`.
Everything further (base-devel, multilib, user groups, toolset) is handled by modules inside the binary.
### Subcommands
| Command | Purpose |
|---|---|
| `archduino install` | Default; wizard + dashboard flow |
| `archduino theme [name]` | Switch colorscheme across all tools; no arg = TUI picker |
| `archduino new <name> <board>` | Scaffold a project (current `arduino-new` behavior) |
| `archduino doctor` | Run Detect across all modules; report drift in a read-only dashboard |
| `archduino --version` / `completion <shell>` | Standard |
Global flags: `--dry-run`, `--halt-on-error`, `--verbose`, `--no-tui`, `--only <mods>`, `--skip <mods>`.
Shell aliases `theme` and `arduino-new` are preserved via thin wrappers dropped in `~/.local/bin` by the `shell-config` module.
### TUI flow (`install`)
**Wizard phase (Huh forms):**
1. Welcome — detected platform, WSL status, binary version.
2. Theme picker — 5 options, palette preview.
3. Module selector — checkbox list; pre-ticked from overlay or "all" by default; disabled modules grayed with reason.
4. Advanced (collapsible) — parallelism, halt-on-error toggle, overlay path override.
5. Review plan — rendered `Plan()` grouped by module, expandable. Back-navigable.
6. Confirm — `[y/N]` gate into Apply.
**Apply phase (Bubble Tea dashboard):**
```
┌─ archduino install ──────────────────────────────────┐
│ ✓ packages 12/12 2.4s │
│ ✓ fonts 1/1 0.8s │
│ ⠋ neovim 3/7 running: :MasonInstall… │
│ ⠋ arduino-cli 2/4 running: core install… │
│ · zellij pending (waits: packages) │
│ ✗ starship failed — config template parse │
│ ⊘ shell-config skipped (dep: starship) │
├──────────────────────────────────────────────────────┤
│ [tab] focus module [enter] expand log [q] abort │
└──────────────────────────────────────────────────────┘
```
- Concurrent modules visible live.
- `enter` on a row expands a scrollable log tail.
- On completion: summary view; footer offers `[r]etry failed / [v]iew full log / [q]uit`.
## Project layout
```
archduino/
├── cmd/archduino/ # main.go, cobra wiring
├── internal/
│ ├── engine/ # DAG scheduler, Reporter, dry-run, resume
│ ├── module/ # Module interface, Plan/Action types, registry
│ ├── modules/ # one package per module
│ │ ├── packages/
│ │ ├── zsh/
│ │ └── ...
│ ├── platform/ # detection; PackageManager interface + pacman impl
│ ├── privilege/ # sudo -v + keepalive
│ ├── config/ # embedded defaults + overlay merge
│ ├── state/ # run log, last-run tracking
│ ├── theme/ # theme model + switcher
│ └── tui/
│ ├── wizard/ # Huh forms
│ └── dashboard/ # Bubble Tea Apply view
├── assets/ # embedded configs, themes, zellij layout, templates
│ ├── themes/
│ ├── templates/
│ └── layouts/
├── scripts/bootstrap.sh
├── .github/workflows/
│ ├── ci.yml
│ └── release.yml # goreleaser
└── docs/
```
## Testing
- **Unit** — every module's `Detect` and `Plan` against an injected `System` fake; table-driven cases for clean / already-installed / partial / drifted.
- **Engine** — DAG scheduling, failure propagation, dry-run, resume; pure Go, fully covered.
- **Integration** — Dockerfile on `archlinux:base` runs `archduino install --no-tui --yes` end-to-end in CI; asserts key files/packages/services. One matrix entry for native Arch. WSL smoke test remains manual for v1.
- **TUI** — `teatest` for wizard navigation and dashboard state transitions. No screenshot tests.
## Distribution
- **GitHub Releases** via `goreleaser`: `linux/amd64`, `linux/arm64` static binaries; `SHA256SUMS` file.
- No AUR package for v1; the shim is the distribution channel. AUR can follow later.
- Semver; breaking changes bump major.
## Migration
- Existing `install.sh` + `lib/*.sh` stay on `master` at a tagged `v0-bash-final` so users can still run the old installer if they want.
- `feat/renode-dap` merges first; a new `feat/go-rewrite` branch hosts the rewrite.
- First Go release (`v1.0.0`) lands when feature parity with the current Bash installer is verified on a clean Arch VM.
## Out of scope (queued for follow-up specs)
- Toolset redesign (re-evaluating which CLI tools are curated).
- CLI browser integration.
- Nerd Font rendering on bare TTY (constrained by the Linux console's 512-glyph PSF limit; needs a separate design covering `fbterm`/`kmscon` or the "SSH-in-from-a-real-terminal" fallback).
- Non-Arch platform support (Debian/Ubuntu/Fedora).
- AUR packaging.

View File

@@ -0,0 +1,99 @@
# CI Workflow Design (Plan 4d)
**Date:** 2026-04-14
**Scope:** Plan 4d of the Archduino Go rewrite — validate Go code quality, build health, and release config on every change.
## Goal
A single Gitea Actions workflow that gives fast, reliable feedback on every push and pull request. It runs unit tests with the race detector, static checks, a build sanity pass, shell-script linting, and validates the goreleaser config — all inside an Arch Linux container for environmental fidelity with the target platform.
## Non-goals
- No integration tests that invoke a real `archduino install` against live pacman/AUR mirrors. That requires a privileged container and mirror access; it lives in a future plan if ever needed.
- No `golangci-lint`. The `go vet` + `gofmt` baseline is enough for this codebase; revisit only if linting gaps surface.
- No coverage reporting or codecov upload.
- No scheduled/nightly runs. Trigger is push + PR only.
## Trigger
```yaml
on:
push:
pull_request:
```
Fires on every push to every branch and every pull request. Matches a single-developer workflow where noisy branches aren't a real concern; immediate feedback is worth the runner minutes.
## Runner
- `runs-on: ubuntu-latest` — same runner label used by the Plan 4c release workflow.
- `container: archlinux:latest` — tests execute inside a fresh Arch userspace each run, matching Archduino's sole supported OS.
**Runner infrastructure:** depends on the same Gitea Actions runner registered for Plan 4c. No new infra introduced by this plan. If the runner is offline, CI queues rather than failing; this is acceptable behavior.
## Job steps (single job, sequential)
1. **Install container prereqs.** Before `actions/checkout` can run, the Arch image needs git; the later steps also need `base-devel` (for the C toolchain Go occasionally pulls in for cgo-adjacent deps) and `shellcheck`. Run once up front:
```
pacman -Sy --needed --noconfirm base-devel git shellcheck
```
Note: `go` is intentionally NOT installed here — `actions/setup-go` installs the pinned version.
2. **Checkout code.** `actions/checkout@v4` with `fetch-depth: 1` (CI doesn't need history).
3. **Set up Go.** `actions/setup-go@v5` with:
- `go-version-file: go.mod` (pinned, deterministic)
- `cache: true` (built-in cache of `$GOMODCACHE` + `$GOCACHE`, keyed on `go.sum`)
4. **`go vet ./...`** — catches obvious correctness issues the compiler misses.
5. **`gofmt` check:**
```
diff=$(gofmt -l .)
if [ -n "$diff" ]; then
echo "files need gofmt:"; echo "$diff"; exit 1
fi
```
Fails the build if any Go file isn't canonically formatted.
6. **`go build ./...`** — compiles every package. Catches deadcode paths and untested packages that `go test` would otherwise skip.
7. **`go test -race ./...`** — full unit test suite with the race detector. The engine (DAG scheduler) and `privilege.Acquire` (sudo keepalive goroutine) are the concurrency hotspots this covers.
8. **`shellcheck bootstrap.sh`** — lints the user-facing shell installer. `install.sh.legacy` is NOT linted; it's archived code.
9. **Install goreleaser + `goreleaser check`:**
```
go install github.com/goreleaser/goreleaser/v2@latest
"$(go env GOPATH)/bin/goreleaser" check
```
Validates `.goreleaser.yaml` on every change. Cheap insurance against config drift.
## Runtime expectations
- Arch image pull: ~15–30s (cached on the runner after first run).
- `pacman -Sy … base-devel git shellcheck`: ~10–20s.
- `actions/setup-go` + cache restore: ~5–10s warm, ~30s cold.
- `go build ./...` + `go test -race ./...`: ~30–60s given 37 test files with mostly mocked I/O.
- `go vet`, `gofmt`, `shellcheck`, `goreleaser check`: <5s combined.
- **Total per run:** ~1–2 min warm cache, ~3 min cold.
## Error handling
- Sequential steps; the first failure fails the workflow. No `continue-on-error` anywhere.
- `gofmt` check uses an explicit guard (not just `-l`) so a non-empty output is a clear failure.
- `setup-go` cache misses silently fall back to a clean install — no workflow-breaking behavior.
## Caching
`actions/setup-go@v5` handles Go build/module caching natively via `cache: true`. No extra cache step. The `pacman` packages are re-installed every run; caching them is not worth the complexity for ~15s of savings on a 90s run.
## Files touched
- Create: `.gitea/workflows/ci.yml`
No other repo changes; the existing tests, `go.mod`, `.goreleaser.yaml`, and `bootstrap.sh` all stay as-is.
## Interaction with Plan 4c
The release workflow (`.gitea/workflows/release.yml`) is unchanged. Both workflows share the Gitea Actions runner but never run together: CI fires on push/PR, release fires on tag push. Independent files, independent triggers, zero coupling.

View File

@@ -0,0 +1,161 @@
# Force Reapply Design (Plan 4b)
**Date:** 2026-04-14
**Scope:** Plan 4b of the Archduino Go rewrite — add `archduino install --force` that re-runs every selected module's Apply unconditionally.
## Goal
Let the user bypass the four-phase contract's idempotency guard when they explicitly want to reapply everything — typically after a manual edit, corruption, or "I'm not sure what's broken, redo it" scenario. Scope is narrow: force a full reapply of currently-selected modules. No uninstall, no revert, no selective force-by-category.
## Non-goals
- No uninstall / reset / revert behavior. Modules have no `Revert` method today; adding one is a separate, much larger plan.
- No selective force (`--force-configs`, `--force-packages`). The user picked "full re-everything" during brainstorming; single flag only.
- No new subcommand (`archduino reinstall`). A flag on `install` is enough.
- No changes to `Verify`; force affects only what `Apply` does, not how we confirm it.
## Interface change
Add one method to `module.Module`:
```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 "install from
// scratch" — reinstall all packages (without pacman's --needed guard)
// and rewrite all managed config files.
PlanForce(ctx context.Context) (Plan, error)
```
Every existing module implements this. Two categories:
### Config-writing modules
`zsh`, `neovim`, `themes`, `starship`, `shellconfig`, `zellij`, `yazi` (config bits), `arduinocli` (sketch dirs, indexes).
`PlanForce` produces a plan that:
1. Reinstalls packages via `platform.Pacman.Reinstall` / `platform.Yay.Reinstall` (no `--needed`).
2. Rewrites every managed config file, overwriting existing content.
3. Re-runs any post-install steps (e.g., arduino-cli core update, zsh plugin clone).
### Package-only modules
`packages`, `gittools`, `fonts`, `renode`, and the package-only portions of `starship` / `yazi`.
`PlanForce` produces a plan that calls `platform.Pacman.Reinstall(ctx, pkgs)` — identical to the install step but without `--needed`, so pacman physically re-fetches and reinstalls the package even if already present.
## Platform layer additions
`internal/platform/pacman.go`:
```go
// Reinstall runs `pacman -S --noconfirm pkg...` without the --needed flag
// so pacman unconditionally reinstalls each package.
func (p Pacman) Reinstall(ctx context.Context, pkgs []string) error
```
`internal/platform/aur.go`:
```go
// Reinstall runs `yay -S --noconfirm pkg...` without the --needed flag.
func (y Yay) Reinstall(ctx context.Context, pkgs []string) error
```
Both are straight copies of the existing `Install` methods with `--needed` omitted from the command-line args.
## Engine changes
`internal/engine/engine.go`:
- `RunOptions` gains a field:
```go
Force bool
```
- `Run` per-module path becomes:
```go
var plan module.Plan
if opts.Force {
plan, err = mod.PlanForce(ctx)
} else {
cur, derr := mod.Detect(ctx)
if derr != nil { /* existing error handling */ }
plan, err = mod.Plan(ctx, cur)
}
```
When `Force` is true, `Detect` is not called.
The Apply and Verify phases are unchanged. Force does not affect DAG ordering, reporter output, or halt-on-error behavior.
## CLI changes
`cmd/archduino/main.go::newInstallCmd()`:
```go
var force bool
cmd.Flags().BoolVar(&force, "force", false,
"Reapply every selected module, ignoring current state")
```
In `RunE`, propagate to both engine and wizard:
```go
runOpts.Force = force
initialState.Force = force
```
## Wizard / TUI integration
`internal/tui/state.go` — `WizardState` gains:
```go
Force bool
```
`internal/tui/wizard/wizard.go` — the `planCache` path branches:
- When `state.Force` is true, call `PlanForce(ctx)` instead of `Detect` + `Plan`.
- When a user toggles a module's selection, the cache key includes `force` so caches from non-force runs are not reused.
- Header gains a persistent banner: `⚠ Force mode: all selected modules will be reapplied`.
## Data flow
```
user: archduino install --force
→ main.go: force=true → RunOptions.Force = true, WizardState.Force = true
→ wizard.Run: per-module preview uses PlanForce (skips Detect)
→ user confirms
→ engine.Run: per-module PlanForce → Apply → Verify
```
## Error handling
No new failure modes. `PlanForce` errors are handled identically to `Plan` errors: reported via the reporter, counted in the engine summary, respected by `--halt-on-error`.
## Testing
**Per-module tests** — each existing `*_test.go` gains a `TestPlanForce` case. The test arranges a "satisfied" system state (mocked platform says everything is installed, configs exist), calls `PlanForce(ctx)`, and asserts the returned plan is non-empty and contains the expected reinstall/rewrite actions. This is the core behavioral contract — without this test, a module could silently "forget" to implement force.
**Engine test** — `engine_test.go` gets a case using a fake module that records whether `Detect`, `Plan`, and `PlanForce` were called. Runs with `RunOptions.Force: true` and asserts `Detect` was NOT called and `PlanForce` WAS.
**Platform tests** — `pacman_test.go` and `aur_test.go` each gain a `TestReinstall` that verifies the command-line args include `-S` and `--noconfirm` but do NOT include `--needed`.
**No TUI test for the banner** — visual-only, covered by the existing wizard tests for preview-cache behavior which will get a new "force preview" case.
## Interaction with existing features
- `--dry-run --force` prints the force plan and exits. Existing dry-run code path is unchanged.
- `--only`/`--skip` respect force: force applies only to the selected set.
- `--halt-on-error` respects force identically.
- Theme application at end of install (Plan 4a feature) runs unchanged; force doesn't skip it.
## File touch list
**Modified:**
- `internal/module/module.go` — add `PlanForce` to `Module` interface
- `internal/engine/engine.go` — add `RunOptions.Force`, branch in `Run`
- `internal/engine/engine_test.go` — new force test
- `internal/tui/state.go` — add `WizardState.Force`
- `internal/tui/wizard/wizard.go` — force-aware preview cache + banner
- `internal/tui/wizard/wizard_test.go` — new preview test with force
- `internal/platform/pacman.go` — add `Reinstall` method
- `internal/platform/pacman_test.go` — new test
- `internal/platform/aur.go` — add `Reinstall` method
- `internal/platform/aur_test.go` — new test
- `cmd/archduino/main.go` — add `--force` flag, wire through
- 13 module files (`internal/modules/*/*.go`) — implement `PlanForce`
- 13 module test files — `TestPlanForce` each
**New:** none.

View File

@@ -0,0 +1,138 @@
# Plan 3a — Install Modules (Simple/Medium Tier) Design
**Status:** Approved 2026-04-14
**Scope:** Port 10 of the 13 Bash install modules to Go. Plan 3b (neovim, renode, scaffold) is a separate brainstorm.
## Goal
Port the 10 simple/medium `lib/*.sh` installer modules into Go packages under `internal/modules/`, each implementing the `module.Module` contract (Detect / Plan / Apply / Verify). Every module is unit-tested against an injected fake `platform.Runner`. Along the way, add the platform helpers these modules need (AUR, download, extract, git-clone, text-block writer) and fix the wizard `renderPlan` UI-blocking watchpoint flagged at the end of Plan 2.
## Architecture
Foundation tasks land first, then modules in simplest-first order:
1. **Wizard cache fix** — cache Detect+Plan results per module; invalidate on selection toggle. One small change, but it unblocks every downstream module from stalling the UI.
2. **Platform helpers** — `AURHelper` (yay), `Download`, `Extract`, `gitclient`, `textblock`. Each gets its own file, each gets TDD against a fake Runner (except `extract` which uses real tempdirs with fixture archives).
3. **Ten modules** in this order: packages → gittools(lazygit) → starship → yazi → zsh → fonts → zellij → themes → arduinocli → shellconfig.
All module inputs (package lists, versions, URLs, plugin repos, shell-config blocks) are typed fields on `config.Config` populated from the embedded `defaults.yaml`. Overlay override plumbing already exists from Plan 1. Modules take their inputs as struct fields at construction time — no globals.
## Tech Stack
Go, existing `internal/platform` + `internal/module` + `internal/engine` + `internal/tui` packages. No new external dependencies expected beyond what Plan 1/2 pulled in.
## Files
### New — platform helpers
- `internal/platform/aur.go` + `aur_test.go`
- `AURHelper` interface, `Yay` implementation.
- `EnsureInstalled(ctx) error` — bootstraps yay from AUR via `git clone https://aur.archlinux.org/yay.git /tmp/... && makepkg -si --noconfirm` when missing.
- `Install(ctx, pkgs []string) error` — `yay -S --needed --noconfirm <pkgs...>`.
- Takes a `Runner` and a `PackageManager` (to check if `yay` itself is installed).
- `internal/platform/download.go` + `download_test.go`
- `Download(ctx, url, destPath string, mode os.FileMode) error`.
- Atomic-ish: write to `destPath+".tmp"` then rename. Clean up on error.
- Tested with `httptest.Server`.
- `internal/platform/extract.go` + `extract_test.go`
- `ExtractTarGz(ctx, archivePath, destDir string) error`.
- `ExtractZip(ctx, archivePath, destDir string) error`.
- Tested against real fixture archives in `testdata/`.
- `internal/platform/git.go` + `git_test.go`
- `GitClient` struct taking a `Runner`.
- `Clone(ctx, url, dest string) error`.
- `EnsureRepo(ctx, url, dest string) error` — clone if `dest/.git` missing, otherwise noop.
- `internal/platform/textblock.go` + `textblock_test.go`
- `EnsureBlock(path, marker, content string) error` — writes or replaces a block delimited by `# >>> marker >>>` / `# <<< marker <<<` markers. Creates the file if missing.
- `RemoveBlock(path, marker string) error`.
- `HasBlock(path, marker string) (bool, error)`.
- Table-driven tests: insert, replace, remove, preserve surrounding content, missing-file, empty-file.
### New — modules
Each lives in its own package under `internal/modules/<name>/` with `<name>.go` + `<name>_test.go`.
- `packages` — installs base pacman package list from `cfg.Packages.Base`.
- `gittools` — installs lazygit (pacman). Named `gittools` to avoid collision with Go stdlib / pacman `git`.
- `starship` — installs starship (pacman).
- `yazi` — installs yazi (pacman).
- `zsh` — installs zsh (pacman) + clones oh-my-zsh + configured plugins via `gitclient`.
- `fonts` — installs AUR fonts (e.g. `ttf-jetbrains-mono-nerd`) via `AURHelper`. First action is `EnsureYayInstalled` if needed.
- `zellij` — installs zellij (pacman) + downloads config.
- `themes` — copies theme files from source directory (bundled under `lib/themes/` or overridable via `cfg.Themes.Source`).
- `arduinocli` — downloads arduino-cli tarball, extracts to `~/.local/bin`, runs `arduino-cli core install <cores...>` from `cfg.ArduinoCLI.Cores`.
- `shellconfig` — writes marker-delimited blocks into `~/.zshrc`, `~/.bashrc`, etc., using `textblock.EnsureBlock`. Blocks defined in `cfg.ShellConfig.Blocks`.
### Modified
- `internal/config/config.go` + `internal/config/defaults.yaml` — add typed fields:
- `Packages.Base []string`
- `Fonts []string`
- `ArduinoCLI{Version string, URL string, Cores []string}`
- `Starship{Version string}` (reserved; pacman install doesn't need it but overlay may)
- `Zsh{OhMyZshRepo string, Plugins []struct{Name, Repo string}}`
- `Zellij{ConfigURL string}`
- `Themes{Source string}` (defaults to embedded `lib/themes/`)
- `ShellConfig{Blocks []struct{Name, File, Content string}}`
- `internal/tui/wizard/wizard.go` — cache Detect+Plan per module; invalidate on selection toggle.
- Module registry (wherever `cmd/archduino/install.go` currently registers `hello`) — register the 10 new modules instead of / alongside `hello`.
## Module Contract Details
All modules follow the same shape:
- **Detect** — pure read. `PackageManager.IsInstalled` per pkg, `os.Stat` per file, `textblock.HasBlock` per marker. Returns a module-private `state` struct. Must be fast (wizard caches results, but Detect still shouldn't shell out more than necessary).
- **Plan** — given current state, returns `Plan{Actions: []Action}`. Empty actions = already converged (idempotent no-op). No side effects.
- **Apply** — executes each action sequentially, reporting progress via `Reporter.Progress(id, i, total, description)`.
- **Verify** — re-runs Detect and asserts convergence. Returns a descriptive error on mismatch.
Error semantics from the engine (`--halt-on-error` flag, sibling-on-failure continuation) apply unchanged.
### Module archetypes
1. **Pacman-only** (packages, gittools, starship, yazi, zellij base, themes deps if any): Detect = `IsInstalled` per pkg; Plan = install missing; Apply = `Pacman.Install`; Verify = all installed.
2. **AUR** (fonts): same as pacman-only but via `AURHelper`. Plan prepends an `EnsureYay` action if yay is not installed.
3. **Download + extract** (arduinocli): Detect = probe binary + `--version`; Plan = download + extract + symlink + `arduino-cli core install`; Verify = version string matches.
4. **Git-clone configs** (zsh oh-my-zsh + plugins, zellij config fetch if URL-based, themes if sourced from repo): Detect = check `.git` or file presence; Plan = `gitclient.EnsureRepo` or `Download`; Verify = path exists.
5. **Text-block writers** (shellconfig): Detect = `textblock.HasBlock` per block; Plan = `textblock.EnsureBlock`; Verify = re-scan.
## Testing Strategy
**Per-module tests (all unit, all fake-Runner-based):**
1. Detect with nothing installed → absent state.
2. Detect with everything installed → present state.
3. Plan(absent) → non-empty actions matching config inputs.
4. Plan(present) → empty actions (idempotent no-op).
5. Apply → records correct shell commands against fake Runner.
6. Verify after Apply → passes when fake reports success.
**Platform helper tests:**
- `aur_test.go` — yay bootstrap path (fake Runner sees `git clone` + `makepkg -si`), install path, already-installed short-circuit.
- `download_test.go` — `httptest.Server` for happy path + 404 + mid-download error cleanup.
- `extract_test.go` — real tar/zip fixtures in `testdata/` extracted into `t.TempDir()`.
- `git_test.go` — fake Runner for clone-if-missing, skip-if-present.
- `textblock_test.go` — table-driven: insert new, replace existing, remove, preserve surrounding content, missing-file, empty-file.
**Wizard caching test:** assert `Detect` is called once per module regardless of redraw count.
**No integration tests** that touch real pacman, real AUR, or the real network. That's Plan 4 (CI with Arch container). Manual smoke after plan completes.
## Non-Goals
- Plan 3b modules (neovim, renode, scaffold) — separate brainstorm.
- Subcommands beyond `install` (theme/new/doctor) — Plan 4.
- `bootstrap.sh`, goreleaser, CI — Plan 4.
- Debian/Fedora PackageManager implementations — Plan 4+.
- Integration tests against real package managers — Plan 4.
## Open Risks / Watchpoints
- **AUR bootstrap fragility** — if `makepkg -si --noconfirm` prompts despite `--noconfirm` (sudo password mid-build), the fake-Runner tests won't catch it. Manual smoke on fonts module is essential.
- **arduino-cli URL drift** — hardcoded version in `defaults.yaml` will go stale. Acceptable for v1; doctor subcommand in Plan 4 can warn.
- **Theme file source** — if themes ship bundled via Go `embed`, we need to decide that at module time. Default assumption: copy from `lib/themes/` on disk (same behavior as bash); switch to `embed` only if it's a clear win.

View File

@@ -0,0 +1,114 @@
# Plan 3b — Install Modules (Heavy Tier) Design
**Status:** Approved 2026-04-14
**Scope:** Port 2 of the 3 remaining Bash install modules (`neovim`, `renode`). The third (`scaffold`) is deferred to Plan 4, where its functionality is absorbed natively into the `archduino new` and `archduino theme` subcommands rather than shipped as bash helper scripts.
## Goal
Port the `neovim` and `renode` Bash modules to Go, each implementing the `module.Module` four-phase contract, unit-tested against a fake `platform.Runner`. Add one small platform helper (`WriteUserFile`), extend `config.Config` with the new modules' inputs, extend `tui.Theme` so the wizard's theme selection flows into NvChad's `chadrc.lua`, and register the two modules in the install command.
## Architecture
- **neovim**: pacman-install neovim (Arch ships 0.11.x, no upstream-tarball fallback); clone the NvChad starter to `~/.config/nvim` if no existing config; if a non-NvChad config is detected, back it up to `~/.config/nvim.bak.<RFC3339>` and install fresh (no LazyVim-specific salvage — deliberate scope cut); strip the starter's `nvim-lspconfig` block (we use native `vim.lsp`); render `chadrc.lua` from an embedded template with the selected theme's `NvChadTheme`; write five static Lua asset files (`arduino.lua`, `clangd.lua`, `lspconfig.lua`, `dap.lua`, `mappings.lua`); clear NvChad's base46 theme cache.
- **renode**: AUR-install `renode-bin` (via `AURHelper`); pacman-install `arm-none-eabi-gdb`; render and write `~/.local/bin/arduino-sim` from an embedded bash template driven by a platform map in config; download `wokwi-cli` from a version-pinned URL into `~/.local/bin/wokwi-cli` (mode 0755).
- Both modules' artifacts (Lua files, bash helper) live as real files under `internal/modules/<name>/assets/` and are bundled via `go:embed`. No sudo required at runtime; everything lands in user-owned paths.
- One new platform helper, `platform.WriteUserFile(path, data, mode)`, consolidates the repeated `os.MkdirAll + os.WriteFile` pattern and does an atomic tmp+rename write. Plan 3a modules are not retrofitted — they stay as-is.
## Tech Stack
Go stdlib only for the new helper and modules (`embed`, `text/template`, `os`, `path/filepath`). Relies on all Plan 3a platform helpers (pacman, AUR, download, extract, git) and on `internal/module` / `internal/engine` / `internal/tui` from Plans 1-2.
## Files
### New — platform helper
- `internal/platform/writefile.go` + `writefile_test.go`
- `WriteUserFile(path string, data []byte, mode os.FileMode) error`
- Creates parent directory (0755) if missing, writes to `path+".tmp"`, renames to `path`.
- Tests: happy path (content + mode), missing parent dir (creates it), overwrite preserves atomicity (crash-proof against partial writes is out of scope; atomic rename is sufficient).
### New — modules
- `internal/modules/neovim/neovim.go` + `neovim_test.go` + `assets/`
- `assets/chadrc.lua.tmpl` — `text/template` with `{{.NvChadTheme}}`.
- `assets/arduino.lua`, `assets/clangd.lua`, `assets/lspconfig.lua`, `assets/dap.lua`, `assets/mappings.lua` — static, copied verbatim from the bash heredocs in `lib/neovim.sh`.
- Constructor: `New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module`.
- State: `NvimInstalled, StarterCloned, ChadrcPresent, BackupNeeded bool`, `MissingAssets []string`, `ThemeName string` (resolved at Detect time).
- `internal/modules/renode/renode.go` + `renode_test.go` + `assets/`
- `assets/arduino-sim.tmpl` — `text/template` with `{{range .Platforms}}...{{end}}` rendering the case-statement platform map from config.
- Constructor: `New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module`.
- State: `RenodeInstalled, GdbInstalled, SimScriptCurrent, WokwiInstalled bool`.
### Modified
- `internal/config/config.go` + `internal/config/defaults.yaml`
- `type NeovimConfig struct { NvChadStarterRepo string }`
- `type RenodePlatform struct { Name, ReplPath, Machine string }`
- `type RenodeConfig struct { WokwiVersion, WokwiURL string; Platforms []RenodePlatform }`
- `Config` gains `Neovim NeovimConfig` and `Renode RenodeConfig`.
- `merge` gains overlay-replace clauses for both (matching Plan 3a's pattern).
- `defaults.yaml` populated with NvChad starter URL, pinned wokwi version/URL, and the three platform entries from `lib/renode.sh`.
- `internal/tui` (wherever `BuiltinThemes` is declared — likely `internal/tui/state.go` or a themes file)
- `Theme` gains `NvChadTheme string`. Populate for each builtin entry (one-line edits per theme).
- Helper `Theme(themes []Theme, id string) (Theme, bool)` for the neovim module to resolve its themeID. (If an equivalent helper already exists, reuse it.)
- `cmd/archduino/main.go` — `buildRegistry` gains a `selectedThemeID string` parameter; registers neovim and renode. TUI path: call `buildRegistry(cfg, tui.DefaultThemeID())` before the wizard, then after the wizard returns with a theme selection, call it again with the selected ID before invoking the dashboard. Plain-output path: pass `cfg`'s default theme directly.
## Module Contract Details
Both modules use the Plan 3a archetype pattern:
- **Detect** — pure reads: `PackageManager.IsInstalled`, `os.Stat`, byte-compare of rendered/static asset files against their destination paths. For neovim, `~/.config/nvim/lua/chadrc.lua` acts as the NvChad-marker; any `lua/` dir without chadrc triggers `BackupNeeded`.
- **Plan** — emit only missing actions; empty plan = already converged (idempotent).
- **Apply** — sequential actions with `Reporter.Progress`; no action is retried.
- **Verify** — re-run Detect; every boolean must be true and `MissingAssets`/related slices must be empty.
Error semantics unchanged from Plan 3a — engine's `--halt-on-error` and sibling-continuation rules apply.
## Theme Propagation
The wizard's `ThemeID` is captured into `tui.WizardState.ThemeID` as before. `buildRegistry` takes the themeID as a second parameter; for the TUI install path, the registry is rebuilt after the wizard confirms selection and before the dashboard runs. For the plain-output path (`--no-tui` / `--dry-run`), the default theme ID is used. The `neovim` module resolves the ID to a `tui.Theme` at construction and stores the `NvChadTheme` for Detect/Plan. If an unknown ID is passed, neovim falls back to the first builtin theme's NvChad name (no hard error — wizards shouldn't crash the installer on a config bug).
## Testing Strategy
### neovim (all unit, tempdir-based)
- `TestDetectFreshSystem` — no nvim, no config → plans pacman install + clone starter + strip lspconfig + write assets.
- `TestDetectExistingNvChad` — chadrc.lua present → no backup planned; missing-assets test still exercises Plan.
- `TestDetectNonNvChadTriggersBackup` — `lua/` with no chadrc → `BackupNeeded=true`; Plan includes a rename-to-bak action.
- `TestPlanApplyInstallsEverything` — fake Runner + fake GitClient + tempdir HOME; asserts pacman call, git clone call, backup done, assets written with expected content, chadrc contains the theme name.
- `TestPlanAfterApplyIsEmpty` — idempotency.
- `TestUnknownThemeFallsBackToFirst` — construction with a bogus ID still produces a valid chadrc.
### renode (all unit)
- `TestDetectNothingInstalled` — plans AUR install + pacman install + sim-script write + wokwi download.
- `TestDetectAllInstalled` — empty plan.
- `TestApplyWritesSimScript` — rendered script contains each configured platform's case-branch.
- `TestApplyDownloadsWokwi` — httptest server serves a short payload; verify file written to `~/.local/bin/wokwi-cli` with mode 0755.
### platform.WriteUserFile
- `TestWriteUserFileCreatesParent` — target under a non-existent dir → parent created.
- `TestWriteUserFileAtomicity` — two sequential writes of different content → final content matches the second; no `.tmp` leaked after success.
- `TestWriteUserFileErrorCleanup` — a failed write leaves no `.tmp` turd (simulate via read-only parent or unwritable tmp; accept either form if OS makes it awkward).
No integration tests against real pacman, yay, neovim, renode, or GitHub; that's Plan 4's CI.
## Non-Goals
- `scaffold` module — deferred to Plan 4 as native Go subcommands (`archduino new`, `archduino theme`).
- Upstream Neovim tarball fallback (`/opt/nvim-linux-x86_64`) — pacman is sufficient; version-drift concerns go to Plan 4's `doctor`.
- LazyVim → NvChad plugin salvage — existing non-NvChad configs get a full backup; user re-adds plugins manually.
- Wokwi GitHub-API "latest" lookup — version is pinned in config, overrideable via overlay.
- Retrofitting Plan 3a modules to use `WriteUserFile` — opportunistic cleanup only, not a Plan 3b deliverable.
- Retroactive theme-sync across every tool (starship, zellij, yazi, lazygit) — Plan 4's `archduino theme` subcommand.
## Open Risks / Watchpoints
- **NvChad starter drift** — if upstream changes `lua/plugins/init.lua`'s `nvim-lspconfig` block format, our sed-equivalent strip-logic in Go will miss it. Mitigation: implement as "find the `{` / `},` block containing the string `neovim/nvim-lspconfig` and delete it", unit-test against a fixture copy of the current starter's `init.lua`.
- **arduino-sim pinned to Renode's REPL paths** — if Renode renames `platforms/cpus/rp2040.repl`, the sim script breaks. Acceptable for v1; surface via Plan 4 doctor.
- **Theme propagation race** — rebuilding the registry twice around the wizard is ugly. Alternative considered (module-level `SetTheme` after construction) was rejected as un-idiomatic. Acceptable for 2 modules; revisit if more theme-aware modules appear.
- **Asset byte-equality idempotency** — if a user edits their `mappings.lua` by hand, every Apply will overwrite it. Documented behavior; `--force-assets` flag is a Plan 4 concern.

View File

@@ -0,0 +1,200 @@
# Plan 4a — User-Facing Subcommands Design
**Status:** Approved 2026-04-14
**Scope:** Ship three new subcommands — `archduino theme`, `archduino new`, `archduino doctor` — plus the shared `theming` package they all build on. This plan absorbs the functionality of `lib/scaffold.sh` (originally deferred from Plan 3b) and `lib/themes/*.sh` natively into Go, without shipping bash wrappers.
Plan 4b (reinstall/reset mode), 4c (bootstrap + goreleaser), and 4d (CI) are separate plans with independent specs.
## Goal
Complete user-facing feature parity with the Bash installer by adding three subcommands: a theme switcher that updates starship/zellij/yazi/lazygit/NvChad configs in place; a project scaffolder for Arduino sketches; and a doctor command that reports module convergence and pinned-tool version drift.
## Architecture
One new shared package and two subcommand packages:
- **`internal/theming/`** — reusable package that embeds per-theme config assets (5 themes × 4 tool configs + a manifest each) and exposes `List() []Theme`, `Get(id) (Theme, bool)`, and `Apply(cfg, themeID) error`. Called from both the `install` command (applies the wizard's selected theme at the end of an install run) and the new `theme` subcommand.
- **`internal/subcommands/newproject/`** — owns `archduino new <project> <board>`. Board list lives in `config.Config.Scaffolding.Boards`; `.ino` starter templates embedded.
- **`internal/subcommands/doctor/`** — owns `archduino doctor`. Iterates the module registry, calls `Detect` + `Plan` on each, reports per-module status and pinned-tool version drift.
`tui.BuiltinThemes` and the `tui.Theme.NvChadTheme` field retire: the canonical theme list is now discovered from `theming`'s embedded manifests. The wizard calls `theming.List()`. `tui.Theme` stays as a thin display value (`ID`, `Name`) used by the wizard's dropdown.
The existing `install` command gains a final step: after `engine.Run` succeeds, call `theming.Apply(cfg, finalState.ThemeID)` so the initial install leaves tools themed, not just installed. This is an improvement over the Bash behavior, where the theme was never applied during install.
## Tech Stack
Go stdlib + existing deps. `go:embed` for all assets. `gopkg.in/yaml.v3` for overlay round-trip updates. `huh` (already a dep) for the interactive theme picker. `text/template` used only where needed (neovim chadrc already uses it; theming's tool configs are static and don't).
## Files
### New — `internal/theming/`
- `theming.go`
- Exported: `type Theme struct { ID, DisplayName, NvChadTheme string }`
- `func List() []Theme` — returns themes in manifest-discovery order (sort by ID for determinism).
- `func Get(id string) (Theme, bool)`
- `func Apply(cfg config.Config, themeID string) error`
- Private: `discoverThemes()` run once at package init; reads `assets/*/theme.yaml`.
- `theming_test.go`
- `assets/<theme>/theme.yaml` — five files (`gruvbox`, `catppuccin`, `tokyonight`, `nord`, `dracula`), each containing:
```yaml
display_name: "Gruvbox Dark"
nvchad: "gruvbox"
```
- `assets/<theme>/starship.toml`, `zellij.kdl`, `yazi.toml`, `lazygit.yml` — 20 static config files lifted verbatim from the corresponding `write_*_config` heredocs in `lib/themes/<theme>.sh`. Tool config paths on disk are:
- starship.toml → `~/.config/starship.toml`
- zellij.kdl → `~/.config/zellij/config.kdl`
- yazi.toml → `~/.config/yazi/theme.toml`
- lazygit.yml → `~/.config/lazygit/config.yml`
### New — `internal/subcommands/newproject/`
- `newproject.go`
- `func Run(cfg config.Config, project, board string) error`.
- `newproject_test.go`
- `assets/<board>.ino` — six starter sketches (`uno`, `mega`, `nano`, `esp32`, `esp32s3`, `esp8266`) plus `default.ino`. Content lifted from `lib/scaffold.sh` heredocs.
### New — `internal/subcommands/doctor/`
- `doctor.go`
- `func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) (exitCode int)`
- `doctor_test.go`
### Modified
- `internal/config/config.go` + `internal/config/defaults.yaml`
- Add:
```go
type Board struct { Name, FQBN, Template string `yaml:"name,fqbn,template"` }
type Scaffolding struct { Boards []Board `yaml:"boards"` }
```
(adjust tag syntax — yaml tags per field as with existing types).
- `Config.Scaffolding Scaffolding`
- `merge` clause: `if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }`.
- Defaults: the 6 boards from `lib/scaffold.sh` FQBN_MAP with `template` set to the board name (so `template: "uno"` maps to `internal/subcommands/newproject/assets/uno.ino`).
- `internal/modules/neovim/neovim.go`
- Extract the chadrc render+write logic into an exported helper the theming package can call. Suggested signature: `func RewriteChadrc(themeID string, themes []Theme) error` (takes themes so the module doesn't depend on the theming package — avoid import cycle). Or simpler: `func WriteChadrc(nvChadThemeName string) error` — takes the already-resolved NvChad name, no theme-registry knowledge needed. The helper no-ops if `~/.config/nvim/lua/chadrc.lua` doesn't exist.
- `internal/tui/state.go`
- Remove `BuiltinThemes`, remove `NvChadTheme` field from `Theme`, remove `ThemeByID`. Wizard takes a `[]Theme` parameter or reads from `theming.List()` directly.
- Option: delete `tui.Theme` entirely and have the wizard use `theming.Theme` directly. Weigh the import coupling: `internal/tui` already imports `internal/config`, and `theming` imports `config` too, so `tui → theming` is fine.
- `internal/tui/wizard/wizard.go`
- Populate the theme dropdown from `theming.List()`.
- `cmd/archduino/main.go`
- Add three `cobra.Command` constructors: `newThemeCmd()`, `newProjectCmd()`, `newDoctorCmd()`.
- Register them on the root command alongside `install`.
- In the `install` RunE, after `engine.Run` returns success, call `theming.Apply(cfg, finalState.ThemeID)` and surface any error.
- `buildRegistry` no longer needs to receive `themeID` for neovim's chadrc — neovim's install path writes chadrc based on the ID threaded through `neovim.New`, and `theming.Apply` overwrites it afterward. Keeping the parameter is fine; it remains useful for the initial chadrc write.
## Command surfaces
- `archduino theme` → interactive huh-based picker listing themes; pre-selects `cfg.ThemeOverrides["__active"]` (or the first theme if unset). On confirm, calls `theming.Apply`.
- `archduino theme <id>` → non-interactive; calls `theming.Apply(cfg, id)`. Errors if unknown.
- `archduino theme list` → prints `<id> — <display_name>` one per line to stdout.
- `archduino new <project> <board>` → see data-flow below.
- `archduino doctor` → see data-flow below.
## Data Flow
### theming.Apply
1. Look up theme by ID; return error if unknown (message lists valid IDs).
2. For each of the four tool configs under `assets/<theme>/`, read via `embed.FS` and write to its target path via `platform.WriteUserFile` with mode 0o644.
3. If `~/.config/nvim/lua/chadrc.lua` exists, call `neovim.WriteChadrc(theme.NvChadTheme)` to re-render it. If it doesn't exist, skip silently (user hasn't run install yet, or removed nvim config).
4. Idempotently clear NvChad's theme cache: `os.RemoveAll(~/.cache/nvim/base46)` and `os.RemoveAll(~/.local/share/nvim/lazy/base46/lua/base46/themes)`. Errors ignored — cache may not exist.
5. Update the user's overlay file at `~/.config/archduino/overlay.yaml`: load existing YAML (or empty), set `theme_overrides.__active: <id>`, write back via a YAML round-trip using `gopkg.in/yaml.v3` decode-into-map-then-encode. First-run creates the file with the single key.
### newproject.Run
1. Validate `project`: non-empty, no `/` or `\\`, doesn't start with `.`.
2. Look up board in `cfg.Scaffolding.Boards`. If absent, return an error whose message lists all valid board names, one per line.
3. `os.Stat(project)`; if it exists, return "project directory already exists". Do not overwrite.
4. `os.MkdirAll(project, 0o755)`.
5. Determine `.ino` asset path: `<board.Template>.ino`, falling back to `default.ino` if the board-specific asset is missing. Write to `<project>/<project>.ino` via `platform.WriteUserFile` mode 0o644.
6. Write `<project>/sketch.yaml`:
```yaml
default_fqbn: <FQBN>
default_port: auto
```
7. Write `<project>/.clangd`:
```yaml
CompileFlags:
Add:
- -xc++
- -std=c++17
```
8. Print a success line to stdout: `Created <project>/ (fqbn: <FQBN>). Next: cd <project> && nvim <project>.ino`.
### doctor.Run
1. For each module in `reg.All()`: call `Detect(ctx)`, then `Plan(ctx, state)`. Format output one row per module:
- `[✓] <id>` — empty plan (converged).
- `[✗] <id> (N actions pending)` — non-empty plan.
- `[!] <id> — detect failed: <err>` — Detect/Plan returned error. Never propagate the error up; record and continue.
2. Version drift checks (only if `arduino-cli` / `wokwi-cli` are found on PATH via the injected `Runner`):
- `arduino-cli version --format json` → parse `VersionString`, compare to `cfg.ArduinoCLI.Version`. Print `[✓] arduino-cli <ver>` or `[!] arduino-cli drift: got <X>, want <Y>`.
- `wokwi-cli --version` → parse first line's version-like token, compare to `cfg.Renode.WokwiVersion`. Same output format.
3. Summary line: `<converged>/<total> converged, <pending> need-install, <drift> drift, <errors> errors`.
4. Exit code: 0 if all modules converged AND no drift AND no errors; else 1. Return value from `Run` is the exit code; the cobra RunE wrapper calls `os.Exit(code)`.
No network calls. No writes. Fast enough to run interactively (<5s on a typical install).
## Error Handling
- `theming.Apply`: first write error aborts. No rollback. User re-runs after fixing (partial state is a benign overlap of two themes' configs on disk until re-run). Overlay update happens last; if a config write fails, the overlay is not touched, so `__active` still reflects the previously-applied theme.
- `newproject.Run`: refuses to overwrite an existing project directory. All other failures (mkdir, write) bubble up to the cobra layer and print via `RunE`'s default error handling.
- `doctor.Run`: never returns a Go error. Module failures are reported inline; only a programmer error (e.g., nil registry) would panic.
## Testing
### theming_test.go (all unit, tempdir-based $HOME)
- `TestListReadsManifests` — asserts 5 themes discovered, each with non-empty DisplayName and NvChadTheme.
- `TestGetKnownAndUnknown` — happy path + false return for bogus ID.
- `TestApplyWritesAllFourConfigs` — `t.Setenv("HOME", …)`; `Apply(cfg, "dracula")`; assert each of the 4 target files exists with non-empty content and the first line matches the asset's first line (robust to copy-paste drift in tests).
- `TestApplyUpdatesOverlayActive` — after Apply, `~/.config/archduino/overlay.yaml` contains `theme_overrides.__active: dracula` (round-trip through `gopkg.in/yaml.v3`).
- `TestApplyPreservesOtherOverlayKeys` — seed overlay with `extra_packages: [foo]`, run Apply, assert `extra_packages` still present after write.
- `TestApplyRewritesChadrcIfPresent` — seed `~/.config/nvim/lua/chadrc.lua` with gruvbox; Apply dracula; assert file now contains `theme = "dracula"`.
- `TestApplyNoChadrcIsFine` — no chadrc on disk; Apply succeeds.
- `TestApplyUnknownThemeErrors`.
### newproject_test.go
- `TestNewCreatesProject` — config with `uno` board; project dir doesn't exist; `Run(cfg, "blinky", "uno")` in a tempdir (chdir); assert `blinky/blinky.ino` + `sketch.yaml` (contains `arduino:avr:uno`) + `.clangd` exist.
- `TestNewRefusesExistingDir` — pre-create the target dir; `Run` returns error; dir untouched.
- `TestNewFallsBackToDefaultTemplate` — config board entry whose `.template` points at an asset that doesn't exist (use a made-up board with `template: "xyz"`); Run succeeds and the generated `.ino` matches `default.ino` content.
- `TestNewUnknownBoardErrors` — error message lists all valid board names.
- `TestNewRejectsSlashInProjectName`.
### doctor_test.go
- `TestDoctorAllConvergedExitsZero` — single stub module returning empty Plan; runner that makes version commands not-found (`exec: "arduino-cli": executable file not found in $PATH`-style error — skip version checks cleanly). Exit code 0.
- `TestDoctorDivergedExitsOne` — single stub module returning a Plan with actions; exit code 1.
- `TestDoctorDetectErrorIsReported` — module whose Detect returns an error; doctor prints `[!]` and exits 1.
- `TestDoctorArduinoCLIDriftIsReported` — injected Runner returns a different version for `arduino-cli version --format json`; exit code 1; output contains "drift".
- `TestDoctorMissingBinariesSkipVersionCheck` — runner returns "not found"; no false drift report; exit reflects module status only.
### install-command integration (smoke-level)
- Existing `install` tests continue to pass; add a unit test that stubs `theming.Apply` and asserts `install` calls it after a successful `engine.Run` with the wizard's ThemeID.
No integration tests against real starship/zellij/arduino-cli/wokwi binaries — Plan 4d (CI) introduces those.
## Non-Goals
- Per-user theme creation / custom palettes — pinned to the five built-in themes.
- Live reload: users must restart their shell, zellij session, etc. manually (bash also required this).
- `archduino doctor --fix` mode — reserved for a future plan, possibly merged with Plan 4b's reinstall/reset.
- Version-drift remediation — doctor only reports.
- `archduino new` support for non-Arduino-CLI ecosystems (PlatformIO, etc.).
## Open Risks / Watchpoints
- **NvChad chadrc extraction:** pulling the render-and-write out of `neovim.Module.Plan`'s closures into a free function requires care — currently that closure is built inside `Plan`. Acceptable fix: factor a `writeChadrc(cfg string, nvChadTheme string) error` free function in the neovim package, have both the Plan action and the new `WriteChadrc` export use it.
- **tui.Theme retirement:** wizard test coverage is decent but `BuiltinThemes` is referenced in `internal/tui/state_test.go`; removing the symbol requires updating those tests. Grep before deleting.
- **Overlay YAML round-trip:** `gopkg.in/yaml.v3`'s node-based decoder preserves comments and order; using `map[string]any` decode/encode will lose both. Acceptable for v1 (the user's overlay is config, not hand-commented prose); document the behavior in the `theming.Apply` godoc.
- **Doctor version parsing:** `arduino-cli version --format json` shape is stable, but `wokwi-cli --version`'s output format is upstream-controlled. A tiny regex `v?\d+\.\d+\.\d+` covers the common case; if it grows fragile, fall back to byte-for-byte compare with `cfg.Renode.WokwiVersion`.
- **Theme-applied-during-install is a behavior change** vs. Bash. It means a fresh install writes tool configs. If the user had pre-existing starship/zellij configs they liked, they get overwritten. Mitigation: `install` preserves a `.bak.<timestamp>` copy before the theme apply step. Document clearly in the install command's help text.

View File

@@ -0,0 +1,156 @@
# Release Pipeline Design (Plan 4c)
**Date:** 2026-04-14
**Scope:** Plan 4c of the Archduino Go rewrite — ship versioned releases and enable one-line install.
## Goal
Let a user install Archduino with a single `curl | bash`, and let the maintainer ship versioned releases with a single `make release`, hosted entirely on the self-hosted Gitea instance at `gitlab.hive-brain.de:2222`.
## Deliverables
1. `bootstrap.sh` — thin curl-piped shim that installs prereqs, downloads the latest release, and execs `archduino install`.
2. `.goreleaser.yaml` — cross-compile + archive + checksum + changelog + publish to Gitea Releases.
3. `Makefile` with `release` and `snapshot` targets — driven by `svu` for SemVer bumps.
4. `.gitea/workflows/release.yml` — tag-triggered workflow that runs goreleaser.
## Components
### 1. `bootstrap.sh` (repo root)
Replaces the legacy `install.sh` (which is Bash-era and will be removed/archived).
Behavior:
- `set -euo pipefail` throughout.
- Detect Arch via `/etc/arch-release`; exit 1 with clear message otherwise.
- `sudo pacman -Sy --needed --noconfirm sudo git curl ca-certificates`.
- Detect arch: `uname -m` → `x86_64` → `amd64`, `aarch64` → `arm64`. Unknown arch → exit 1.
- Query Gitea API for latest release:
`GET https://gitlab.hive-brain.de/api/v1/repos/MDietermann/Archduino/releases/latest`
Extract `tag_name` (e.g. `v0.1.0`) and use it to locate assets.
- Download `archduino_<version-without-v>_linux_<arch>.tar.gz` and `checksums.txt` into a `mktemp -d`.
- Verify SHA256 via `sha256sum --check --ignore-missing checksums.txt`; mismatch → exit 1.
- Extract tarball.
- Install with sudo:
- Binary → `/usr/local/bin/archduino` (0755), overwriting.
- `completions/archduino.bash` → `/usr/share/bash-completion/completions/archduino`.
- `completions/_archduino` → `/usr/share/zsh/site-functions/_archduino`.
- `completions/archduino.fish` → `/usr/share/fish/vendor_completions.d/archduino.fish`.
- `exec /usr/local/bin/archduino install "$@"` so flags pass through (e.g. `--halt-on-error`).
Usage:
```
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
```
### 2. `.goreleaser.yaml` (repo root)
- `project_name: archduino`
- `before.hooks:` regenerate shell completions via the cobra `completion` subcommand:
- `go run ./cmd/archduino completion bash > completions/archduino.bash`
- `go run ./cmd/archduino completion zsh > completions/_archduino`
- `go run ./cmd/archduino completion fish > completions/archduino.fish`
- Prerequisite: the `completion` subcommand must be registered on the root cobra command. If not already present, add it as part of the implementation.
- `builds:`
- One entry, `main: ./cmd/archduino`, `binary: archduino`.
- `env: [CGO_ENABLED=0]`.
- `goos: [linux]`, `goarch: [amd64, arm64]`.
- `ldflags: -s -w -X gitlab.hive-brain.de/MDietermann/Archduino/internal/buildinfo.Version={{.Version}} -X ...Commit={{.Commit}} -X ...Date={{.Date}}` (add a small `internal/buildinfo` package to hold these; surface via `archduino version`).
- `archives:`
- Format tar.gz.
- Name template `archduino_{{.Version}}_linux_{{.Arch}}`.
- Files: binary + `completions/*` + `README.md` + `LICENSE` (add if missing).
- `checksum: name_template: 'checksums.txt'` (SHA256).
- `changelog:`
- `use: git`
- Filters: exclude `^chore:`, `^docs:`, `^test:`, `^ci:`, `^style:`, `Merge pull request`, `Merge branch`.
- Groups: `Features` (`^feat`), `Bug fixes` (`^fix|^perf`), `Others` (everything else).
- `release:`
- `gitea_urls.api: https://gitlab.hive-brain.de/api/v1`
- `gitea_urls.download: https://gitlab.hive-brain.de`
### 3. `Makefile` (repo root)
Targets:
- `snapshot` — `goreleaser release --snapshot --clean`. No tagging, no publish. For local build-matrix validation.
- `release` — SemVer bump flow:
1. Assert working tree clean and current branch is `master`; abort otherwise.
2. `CURRENT=$$(svu current)` and `NEXT=$$(svu next)`. Display both plus the commit log since `CURRENT`.
3. Prompt `Tag $NEXT and push? [y/N]`. Abort on anything but `y`.
4. `git tag $$NEXT && git push origin $$NEXT`.
The actual release is then produced by CI.
- `completions` — regenerate completions into `completions/` locally (useful for dev).
- `build` — local `go build ./cmd/archduino` for quick smoke.
`svu` is a dev dependency; README gains a one-line note: `go install github.com/caarlos0/svu@latest`.
### 4. `.gitea/workflows/release.yml`
- Trigger: `on: push: tags: ['v*']`.
- `runs-on: ubuntu-latest` (Gitea Actions convention; matches runner labels).
- Steps:
1. `actions/checkout@v4` with `fetch-depth: 0` (goreleaser needs full history for changelog).
2. `actions/setup-go@v5` with the Go version pinned in `go.mod`.
3. `goreleaser/goreleaser-action@v6` with `args: release --clean`.
- Env: `GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}`. Token is created manually in Gitea UI (repo-scoped, `write:package`/`write:repository` as needed) and stored as a repo secret before first release.
- Prerequisite (documented, not executed by the plan): a Gitea Actions runner must be registered on the instance. One-time setup outside the scope of this spec.
## Data flow
```
dev: git commit -m "feat: ..." (conventional commits)
dev: make release → svu computes vX.Y.Z → git tag + push
Gitea Actions: tag push → goreleaser --clean → Gitea Release published
(tarballs + checksums.txt + changelog)
user: curl … | bash → bootstrap.sh → Gitea API (latest release) → tarball →
sha256 verify → /usr/local/bin + completions → exec archduino install
```
## Versioning
SemVer driven by Conventional Commits via `svu`:
- `feat:` → minor
- `fix:` / `perf:` → patch
- `feat!:` or `BREAKING CHANGE:` footer → major
- `chore:` / `docs:` / `refactor:` / `test:` / `ci:` / `style:` → no release
First release: `v0.1.0` (seed tag so `svu` has a baseline; manual one-time `git tag v0.1.0`).
## Error handling
- `bootstrap.sh`: checksum mismatch, non-Arch host, arch detection failure, non-200 from API → exit 1 with a specific message; `set -euo pipefail` covers the rest.
- goreleaser is transactional — the Gitea Release is created after artifacts upload successfully, so a mid-run failure leaves no partial release.
- `make release` aborts before tagging if the tree is dirty or not on `master`.
## Testing
- `make snapshot` locally verifies the build matrix and archive contents without publishing.
- `bootstrap.sh` tested against a snapshot tarball in a throwaway Arch container:
`docker run --rm -it archlinux bash` → serve the tarball locally → run bootstrap with an overridden base URL (env var hook, e.g. `ARCHDUINO_RELEASE_BASE`) for test runs.
- No Go test changes; this is all out-of-band release machinery.
## Out of scope (deferred)
- GPG-signed checksums (revisit if/when distribution widens).
- Debian/Fedora/macOS builds (waiting on multi-distro support elsewhere in the roadmap).
- Homebrew tap and AUR PKGBUILD (AUR is a natural follow-up but tracked separately).
- Automated "release PR" bots (release-please / semantic-release) — `svu` + manual `make release` is the chosen trigger.
## File touch list
New:
- `bootstrap.sh`
- `.goreleaser.yaml`
- `Makefile`
- `.gitea/workflows/release.yml`
- `internal/buildinfo/buildinfo.go` (tiny — holds `Version`, `Commit`, `Date` vars)
- `completions/` (gitignored; generated)
- `LICENSE` (if missing)
Modified:
- `cmd/archduino/main.go` (register cobra `completion` subcommand if absent; wire a `version` subcommand that prints `buildinfo`).
- `README.md` (install-via-bootstrap instructions, `svu` note).
- `.gitignore` (add `dist/`, `completions/`).
Removed/archived:
- `install.sh` (legacy Bash installer — superseded by `bootstrap.sh` + Go binary).

42
go.mod Normal file
View File

@@ -0,0 +1,42 @@
module gitlab.hive-brain.de/MDietermann/Archduino
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/colorprofile v0.3.2 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

88
go.sum Normal file
View File

@@ -0,0 +1,88 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913 h1:MvJqk9htSLLxNlNQRqfRMhObcytCT4+xfO4830kNlVs=
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,4 +1,6 @@
#!/bin/bash
# DEPRECATED: This is the legacy Bash installer. The active installer is
# bootstrap.sh (curl-piped shim) + the archduino Go binary. Kept for reference.
#
# Archduino Install — TUI Arduino Development Environment
# Works on both native Arch Linux and Arch/Ubuntu WSL2
@@ -82,6 +84,7 @@ source "$SCRIPT_DIR/lib/zellij.sh"
source "$SCRIPT_DIR/lib/neovim.sh"
source "$SCRIPT_DIR/lib/yazi.sh"
source "$SCRIPT_DIR/lib/arduino.sh"
source "$SCRIPT_DIR/lib/renode.sh"
source "$SCRIPT_DIR/lib/git.sh"
source "$SCRIPT_DIR/lib/scaffold.sh"
source "$SCRIPT_DIR/lib/shell-config.sh"
@@ -104,6 +107,9 @@ echo " arduino-new my-project uno # scaffold a project"
echo " adev # launch Arduino workspace"
echo " <leader>av # compile in Neovim"
echo " <leader>au # upload in Neovim"
echo " wokwi-cli . # simulate with Wokwi (visual)"
echo " arduino-sim build/fw.elf # simulate with Renode (GDB)"
echo " <leader>ds # attach debugger to Renode"
echo " theme # switch colorscheme"
echo ""
warn "A reboot is recommended to apply all changes (shell, groups, PATH)."

179
internal/config/config.go Normal file
View File

@@ -0,0 +1,179 @@
// Package config loads embedded defaults and optionally merges a user
// overlay from ~/.config/archduino/overlay.yaml.
package config
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
//go:embed defaults.yaml
var defaultsYAML []byte
type Packages struct {
Base []string `yaml:"base"`
}
type ArduinoCLIConfig struct {
Version string `yaml:"version"`
URL string `yaml:"url"`
Cores []string `yaml:"cores"`
}
type ZshPlugin struct {
Name string `yaml:"name"`
Repo string `yaml:"repo"`
}
type ZshConfig struct {
OhMyZshRepo string `yaml:"oh_my_zsh_repo"`
Plugins []ZshPlugin `yaml:"plugins"`
}
type ZellijConfig struct {
ConfigURL string `yaml:"config_url"`
}
type ThemesConfig struct {
Source string `yaml:"source"`
}
type ShellConfigBlock struct {
Name string `yaml:"name"`
File string `yaml:"file"`
Content string `yaml:"content"`
}
type ShellConfigSection struct {
Blocks []ShellConfigBlock `yaml:"blocks"`
}
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"`
}
type Board struct {
Name string `yaml:"name"`
FQBN string `yaml:"fqbn"`
Template string `yaml:"template"`
}
type Scaffolding struct {
Boards []Board `yaml:"boards"`
}
// Config is the fully-resolved runtime configuration.
type Config struct {
Version int `yaml:"version"`
ExtraPackages []string `yaml:"extra_packages"`
DisabledModules []string `yaml:"disabled_modules"`
ThemeOverrides map[string]string `yaml:"theme_overrides"`
Packages Packages `yaml:"packages"`
Fonts []string `yaml:"fonts"`
ArduinoCLI ArduinoCLIConfig `yaml:"arduino_cli"`
Zsh ZshConfig `yaml:"zsh"`
Zellij ZellijConfig `yaml:"zellij"`
Themes ThemesConfig `yaml:"themes"`
ShellConfig ShellConfigSection `yaml:"shell_config"`
Neovim NeovimConfig `yaml:"neovim"`
Renode RenodeConfig `yaml:"renode"`
Scaffolding Scaffolding `yaml:"scaffolding"`
}
const currentVersion = 1
// Load returns defaults merged with the overlay at overlayPath (if present).
// If overlayPath is "", the default location (~/.config/archduino/overlay.yaml)
// is used. A missing overlay is not an error.
func Load(overlayPath string) (Config, error) {
var cfg Config
if err := yaml.Unmarshal(defaultsYAML, &cfg); err != nil {
return Config{}, fmt.Errorf("parse embedded defaults: %w", err)
}
if overlayPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return cfg, nil
}
overlayPath = filepath.Join(home, ".config", "archduino", "overlay.yaml")
}
data, err := os.ReadFile(overlayPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return Config{}, fmt.Errorf("read overlay %s: %w", overlayPath, err)
}
var overlay Config
if err := yaml.Unmarshal(data, &overlay); err != nil {
return Config{}, fmt.Errorf("parse overlay %s: %w", overlayPath, err)
}
if overlay.Version != 0 && overlay.Version != currentVersion {
return Config{}, fmt.Errorf("overlay %s: unsupported version %d (want %d)", overlayPath, overlay.Version, currentVersion)
}
return merge(cfg, overlay), nil
}
func merge(base, over Config) Config {
out := base
if len(over.ExtraPackages) > 0 {
out.ExtraPackages = append(append([]string{}, base.ExtraPackages...), over.ExtraPackages...)
}
if len(over.DisabledModules) > 0 {
out.DisabledModules = append(append([]string{}, base.DisabledModules...), over.DisabledModules...)
}
if len(over.ThemeOverrides) > 0 {
merged := make(map[string]string, len(base.ThemeOverrides)+len(over.ThemeOverrides))
for k, v := range base.ThemeOverrides {
merged[k] = v
}
for k, v := range over.ThemeOverrides {
merged[k] = v
}
out.ThemeOverrides = merged
}
if len(over.Packages.Base) > 0 {
out.Packages = over.Packages
}
if len(over.Fonts) > 0 {
out.Fonts = over.Fonts
}
if over.ArduinoCLI.Version != "" {
out.ArduinoCLI = over.ArduinoCLI
}
if over.Zsh.OhMyZshRepo != "" {
out.Zsh = over.Zsh
}
if over.Zellij.ConfigURL != "" {
out.Zellij = over.Zellij
}
if over.Themes.Source != "" {
out.Themes = over.Themes
}
if len(over.ShellConfig.Blocks) > 0 {
out.ShellConfig = over.ShellConfig
}
if over.Neovim.NvChadStarterRepo != "" {
out.Neovim = over.Neovim
}
if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 {
out.Renode = over.Renode
}
if len(over.Scaffolding.Boards) > 0 {
out.Scaffolding = over.Scaffolding
}
return out
}

View File

@@ -0,0 +1,133 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaultsWhenNoOverlay(t *testing.T) {
dir := t.TempDir()
cfg, err := Load(filepath.Join(dir, "missing.yaml"))
if err != nil {
t.Fatal(err)
}
if cfg.Version != 1 {
t.Errorf("version = %d", cfg.Version)
}
if len(cfg.ExtraPackages) != 0 {
t.Errorf("extra_packages = %v", cfg.ExtraPackages)
}
}
func TestLoadMergesOverlay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "overlay.yaml")
content := []byte(`
version: 1
extra_packages: [htop, ncdu]
disabled_modules: [renode]
theme_overrides:
starship: /tmp/mine.toml
`)
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if got := cfg.ExtraPackages; len(got) != 2 || got[0] != "htop" || got[1] != "ncdu" {
t.Errorf("extra_packages = %v", got)
}
if len(cfg.DisabledModules) != 1 || cfg.DisabledModules[0] != "renode" {
t.Errorf("disabled_modules = %v", cfg.DisabledModules)
}
if cfg.ThemeOverrides["starship"] != "/tmp/mine.toml" {
t.Errorf("theme_overrides = %v", cfg.ThemeOverrides)
}
}
func TestDefaultsCarryModuleInputs(t *testing.T) {
cfg, err := Load("/nonexistent/overlay.yaml")
if err != nil {
t.Fatal(err)
}
if len(cfg.Packages.Base) == 0 {
t.Error("Packages.Base empty")
}
if len(cfg.Fonts) == 0 {
t.Error("Fonts empty")
}
if cfg.ArduinoCLI.Version == "" {
t.Error("ArduinoCLI.Version empty")
}
if cfg.ArduinoCLI.URL == "" {
t.Error("ArduinoCLI.URL empty")
}
if cfg.Zsh.OhMyZshRepo == "" {
t.Error("Zsh.OhMyZshRepo empty")
}
if len(cfg.ShellConfig.Blocks) == 0 {
t.Error("ShellConfig.Blocks empty")
}
}
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])
}
}
}
func TestLoadRejectsUnknownVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "overlay.yaml")
if err := os.WriteFile(path, []byte("version: 99\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("expected version error")
}
}
func TestDefaultsCarryScaffoldingBoards(t *testing.T) {
cfg, err := Load("/nonexistent/overlay.yaml")
if err != nil {
t.Fatal(err)
}
if len(cfg.Scaffolding.Boards) < 6 {
t.Fatalf("want >= 6 boards, got %d", len(cfg.Scaffolding.Boards))
}
want := map[string]string{
"uno": "arduino:avr:uno", "mega": "arduino:avr:mega", "nano": "arduino:avr:nano",
"esp32": "esp32:esp32:esp32", "esp32s3": "esp32:esp32:esp32s3", "esp8266": "esp8266:esp8266:nodemcuv2",
}
got := map[string]string{}
for _, b := range cfg.Scaffolding.Boards {
got[b.Name] = b.FQBN
}
for name, fqbn := range want {
if got[name] != fqbn {
t.Errorf("board %q: got fqbn %q, want %q", name, got[name], fqbn)
}
}
}

View File

@@ -0,0 +1,108 @@
version: 1
extra_packages: []
disabled_modules: []
theme_overrides: {}
packages:
base:
- base-devel
- git
- curl
- wget
- unzip
- zip
- tree
- htop
- fastfetch
- ripgrep
- fd
- bat
- eza
- jq
- zsh
- clang
- ca-certificates
fonts:
- ttf-jetbrains-mono-nerd
arduino_cli:
version: "0.35.3"
url: "https://github.com/arduino/arduino-cli/releases/download/v0.35.3/arduino-cli_0.35.3_Linux_64bit.tar.gz"
cores:
- arduino:avr
zsh:
oh_my_zsh_repo: "https://github.com/ohmyzsh/ohmyzsh.git"
plugins:
- name: zsh-autosuggestions
repo: https://github.com/zsh-users/zsh-autosuggestions
- name: zsh-syntax-highlighting
repo: https://github.com/zsh-users/zsh-syntax-highlighting.git
zellij:
config_url: ""
themes:
source: "lib/themes"
shell_config:
blocks:
- name: archduino-path
file: ".zshrc"
content: |
export PATH="$HOME/.local/bin:$PATH"
- name: archduino-editor
file: ".zshrc"
content: |
export EDITOR="nvim"
export VISUAL="nvim"
- name: archduino-starship
file: ".zshrc"
content: |
eval "$(starship init zsh)"
- name: archduino-zellij
file: ".zshrc"
content: |
if [[ -z "$ZELLIJ" ]]; then
zellij attach --create archduino
fi
- name: archduino-aliases
file: ".zshrc"
content: |
alias adev="zellij --layout arduino"
alias ac="arduino-cli"
alias acc="arduino-cli compile"
alias acu="arduino-cli upload"
alias acm="arduino-cli monitor"
alias acb="arduino-cli board list"
alias lg="lazygit"
alias v="nvim"
alias y="yazi"
alias theme="archduino-theme"
neovim:
nvchad_starter_repo: "https://github.com/NvChad/starter"
renode:
wokwi_version: "0.26.1"
wokwi_url: "https://github.com/wokwi/wokwi-cli/releases/download/v0.26.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"
scaffolding:
boards:
- {name: uno, fqbn: "arduino:avr:uno", template: uno}
- {name: mega, fqbn: "arduino:avr:mega", template: mega}
- {name: nano, fqbn: "arduino:avr:nano", template: nano}
- {name: esp32, fqbn: "esp32:esp32:esp32", template: esp32}
- {name: esp32s3, fqbn: "esp32:esp32:esp32s3", template: esp32s3}
- {name: esp8266, fqbn: "esp8266:esp8266:nodemcuv2", template: esp8266}

91
internal/engine/dag.go Normal file
View File

@@ -0,0 +1,91 @@
package engine
import (
"fmt"
"sort"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// TopoSort returns module IDs in a valid dependency order.
// Ties are broken alphabetically for deterministic output.
// Returns an error on unknown deps or cycles.
func TopoSort(metas []module.Meta) ([]string, error) {
known := make(map[string]bool, len(metas))
for _, m := range metas {
known[m.ID] = true
}
for _, m := range metas {
for _, d := range m.Deps {
if !known[d] {
return nil, fmt.Errorf("module %q depends on unknown %q", m.ID, d)
}
}
}
// Kahn's algorithm with alphabetical tie-breaking.
indeg := make(map[string]int, len(metas))
children := make(map[string][]string, len(metas))
for _, m := range metas {
if _, ok := indeg[m.ID]; !ok {
indeg[m.ID] = 0
}
for _, d := range m.Deps {
indeg[m.ID]++
children[d] = append(children[d], m.ID)
}
}
var ready []string
for id, d := range indeg {
if d == 0 {
ready = append(ready, id)
}
}
sort.Strings(ready)
var order []string
for len(ready) > 0 {
next := ready[0]
ready = ready[1:]
order = append(order, next)
kids := append([]string(nil), children[next]...)
sort.Strings(kids)
for _, c := range kids {
indeg[c]--
if indeg[c] == 0 {
ready = append(ready, c)
sort.Strings(ready)
}
}
}
if len(order) != len(metas) {
return nil, fmt.Errorf("dependency cycle detected among modules")
}
return order, nil
}
// Descendants returns the set of module IDs transitively downstream of root.
// Root itself is NOT included.
func Descendants(metas []module.Meta, root string) map[string]bool {
children := make(map[string][]string)
for _, m := range metas {
for _, d := range m.Deps {
children[d] = append(children[d], m.ID)
}
}
out := make(map[string]bool)
var walk func(string)
walk = func(id string) {
for _, c := range children[id] {
if out[c] {
continue
}
out[c] = true
walk(c)
}
}
walk(root)
return out
}

View File

@@ -0,0 +1,79 @@
package engine
import (
"reflect"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
func TestTopoSort(t *testing.T) {
cases := []struct {
name string
metas []module.Meta
want []string
err bool
}{
{
name: "linear chain",
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"b"}}},
want: []string{"a", "b", "c"},
},
{
name: "independent modules sorted alphabetically",
metas: []module.Meta{{ID: "c"}, {ID: "a"}, {ID: "b"}},
want: []string{"a", "b", "c"},
},
{
name: "diamond",
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"a"}}, {ID: "d", Deps: []string{"b", "c"}}},
want: []string{"a", "b", "c", "d"},
},
{
name: "unknown dep",
metas: []module.Meta{{ID: "a", Deps: []string{"missing"}}},
err: true,
},
{
name: "cycle",
metas: []module.Meta{{ID: "a", Deps: []string{"b"}}, {ID: "b", Deps: []string{"a"}}},
err: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := TopoSort(tc.metas)
if tc.err {
if err == nil {
t.Fatalf("want error, got nil (order=%v)", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("order = %v, want %v", got, tc.want)
}
})
}
}
func TestDescendants(t *testing.T) {
metas := []module.Meta{
{ID: "a"},
{ID: "b", Deps: []string{"a"}},
{ID: "c", Deps: []string{"a"}},
{ID: "d", Deps: []string{"b"}},
{ID: "e"}, // independent
}
got := Descendants(metas, "a")
want := map[string]bool{"b": true, "c": true, "d": true}
if !reflect.DeepEqual(got, want) {
t.Fatalf("descendants(a) = %v, want %v", got, want)
}
if len(Descendants(metas, "e")) != 0 {
t.Fatalf("descendants(e) should be empty")
}
}

207
internal/engine/engine.go Normal file
View File

@@ -0,0 +1,207 @@
package engine
import (
"context"
"errors"
"fmt"
"sort"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// RunOptions controls a single engine run.
type RunOptions struct {
Selected []string // module IDs to run; empty = all
DryRun bool // Detect + Plan only, no Apply/Verify
HaltOnError bool // stop at first failure instead of continuing siblings
// 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
Reporter module.Reporter
}
// ModuleStatus is the terminal state of a module within a run.
type ModuleStatus string
const (
StatusSucceeded ModuleStatus = "succeeded"
StatusFailed ModuleStatus = "failed"
StatusSkipped ModuleStatus = "skipped"
StatusPlanned ModuleStatus = "planned" // dry-run only
)
// ModuleResult captures what happened for one module.
type ModuleResult struct {
ID string
Status ModuleStatus
Plan module.Plan
Err error
}
// Summary aggregates results.
type Summary struct {
Results []ModuleResult
}
// Counts returns (succeeded, failed, skipped).
func (s Summary) Counts() (int, int, int) {
var ok, fail, skip int
for _, r := range s.Results {
switch r.Status {
case StatusSucceeded, StatusPlanned:
ok++
case StatusFailed:
fail++
case StatusSkipped:
skip++
}
}
return ok, fail, skip
}
// Run drives the selected modules through the four phases in DAG order.
// Parallelism is intentionally omitted in this foundation; a later plan
// will introduce it. Sequential execution is a correct baseline.
func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, error) {
mods, err := resolveSelection(reg, opts.Selected)
if err != nil {
return Summary{}, err
}
metas := make([]module.Meta, 0, len(mods))
for _, m := range mods {
metas = append(metas, m.Meta())
}
order, err := TopoSort(metas)
if err != nil {
return Summary{}, fmt.Errorf("module graph: %w", err)
}
byID := make(map[string]module.Module, len(mods))
for _, m := range mods {
byID[m.Meta().ID] = m
}
results := make(map[string]*ModuleResult, len(mods))
skipped := make(map[string]bool)
for _, id := range order {
if skipped[id] {
results[id] = &ModuleResult{ID: id, Status: StatusSkipped, Err: errors.New("skipped: upstream failed")}
continue
}
m := byID[id]
res := &ModuleResult{ID: id}
results[id] = res
var (
plan module.Plan
err error
)
if opts.Force {
plan, err = m.PlanForce(ctx)
if err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("plan-force: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
} else {
state, derr := m.Detect(ctx)
if derr != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("detect: %w", derr)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
plan, err = m.Plan(ctx, state)
if err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("plan: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
}
res.Plan = plan
if opts.DryRun {
res.Status = StatusPlanned
continue
}
if err := m.Apply(ctx, plan, opts.Reporter); err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("apply: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
if err := m.Verify(ctx); err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("verify: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
res.Status = StatusSucceeded
}
summary := Summary{}
// Emit results in topo order for stable output.
ids := make([]string, 0, len(results))
for id := range results {
ids = append(ids, id)
}
sort.SliceStable(ids, func(i, j int) bool {
return indexOf(order, ids[i]) < indexOf(order, ids[j])
})
for _, id := range ids {
summary.Results = append(summary.Results, *results[id])
}
return summary, nil
}
func resolveSelection(reg *module.Registry, selected []string) ([]module.Module, error) {
all := reg.All()
if len(selected) == 0 {
return all, nil
}
out := make([]module.Module, 0, len(selected))
for _, id := range selected {
m, ok := reg.Get(id)
if !ok {
return nil, fmt.Errorf("unknown module %q", id)
}
out = append(out, m)
}
return out, nil
}
func markFailed(metas []module.Meta, id string, skipped map[string]bool) {
for d := range Descendants(metas, id) {
skipped[d] = true
}
}
func indexOf(s []string, v string) int {
for i, x := range s {
if x == v {
return i
}
}
return -1
}

View File

@@ -0,0 +1,196 @@
package engine
import (
"context"
"errors"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// scriptedModule lets tests control what each phase returns.
type scriptedModule struct {
id string
deps []string
detect func() (module.State, error)
plan func() (module.Plan, error)
planForce func() (module.Plan, error)
apply func() error
verify func() error
detectCalls int
planCalls int
planForceCalls int
}
func (s *scriptedModule) Meta() module.Meta { return module.Meta{ID: s.id, Name: s.id, Deps: s.deps} }
func (s *scriptedModule) Detect(ctx context.Context) (module.State, error) {
s.detectCalls++
if s.detect != nil {
return s.detect()
}
return nil, nil
}
func (s *scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) {
s.planCalls++
if s.plan != nil {
return s.plan()
}
return module.Plan{}, nil
}
func (s *scriptedModule) PlanForce(ctx context.Context) (module.Plan, error) {
s.planForceCalls++
if s.planForce != nil {
return s.planForce()
}
if s.plan != nil {
return s.plan()
}
return module.Plan{}, nil
}
func (s *scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error {
if s.apply != nil {
return s.apply()
}
return nil
}
func (s *scriptedModule) Verify(ctx context.Context) error {
if s.verify != nil {
return s.verify()
}
return nil
}
func newReg(t *testing.T, mods ...module.Module) *module.Registry {
t.Helper()
r := module.NewRegistry()
for _, m := range mods {
if err := r.Register(m); err != nil {
t.Fatal(err)
}
}
return r
}
func TestRunAllSucceed(t *testing.T) {
reg := newReg(t,
&scriptedModule{id: "a"},
&scriptedModule{id: "b", deps: []string{"a"}},
)
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
ok, fail, skip := sum.Counts()
if ok != 2 || fail != 0 || skip != 0 {
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
}
if sum.Results[0].ID != "a" || sum.Results[1].ID != "b" {
t.Fatalf("order = %v", sum.Results)
}
}
func TestRunDryRunDoesNotApply(t *testing.T) {
applied := false
reg := newReg(t, &scriptedModule{
id: "a",
apply: func() error { applied = true; return nil },
})
sum, err := Run(context.Background(), reg, RunOptions{DryRun: true, Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
if applied {
t.Fatal("Apply was called during dry-run")
}
if sum.Results[0].Status != StatusPlanned {
t.Fatalf("status = %v", sum.Results[0].Status)
}
}
func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
reg := newReg(t,
&scriptedModule{id: "root"},
&scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }},
&scriptedModule{id: "child", deps: []string{"fails"}},
&scriptedModule{id: "sibling", deps: []string{"root"}},
)
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
status := map[string]ModuleStatus{}
for _, r := range sum.Results {
status[r.ID] = r.Status
}
if status["root"] != StatusSucceeded {
t.Errorf("root = %v", status["root"])
}
if status["fails"] != StatusFailed {
t.Errorf("fails = %v", status["fails"])
}
if status["child"] != StatusSkipped {
t.Errorf("child = %v", status["child"])
}
if status["sibling"] != StatusSucceeded {
t.Errorf("sibling = %v (should have run)", status["sibling"])
}
}
func TestRunHaltOnError(t *testing.T) {
reg := newReg(t,
&scriptedModule{id: "a", apply: func() error { return errors.New("nope") }},
&scriptedModule{id: "b"}, // independent
)
sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
// "b" should not appear in results because we halted before reaching it.
for _, r := range sum.Results {
if r.ID == "b" {
t.Fatalf("b was run despite HaltOnError")
}
}
}
func TestRunUnknownModule(t *testing.T) {
reg := module.NewRegistry()
_, err := Run(context.Background(), reg, RunOptions{Selected: []string{"ghost"}})
if err == nil {
t.Fatal("expected error for unknown module")
}
}
func TestRunForceSkipsDetectAndCallsPlanForce(t *testing.T) {
mod := &scriptedModule{
id: "a",
detect: func() (module.State, error) {
panic("Detect must not be called when Force=true")
},
planForce: func() (module.Plan, error) {
return module.Plan{Actions: []module.Action{{Description: "forced"}}}, nil
},
}
reg := newReg(t, mod)
sum, err := Run(context.Background(), reg, RunOptions{Force: true, Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
if mod.planForceCalls != 1 {
t.Errorf("planForceCalls = %d, want 1", mod.planForceCalls)
}
if mod.detectCalls != 0 {
t.Errorf("detectCalls = %d, want 0", mod.detectCalls)
}
if mod.planCalls != 0 {
t.Errorf("planCalls = %d, want 0", mod.planCalls)
}
ok, fail, skip := sum.Counts()
if ok != 1 || fail != 0 || skip != 0 {
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
}
if len(sum.Results[0].Plan.Actions) != 1 {
t.Fatalf("expected 1 action in plan, got %d", len(sum.Results[0].Plan.Actions))
}
}

View File

@@ -0,0 +1,80 @@
// Package engine runs modules through the Detect/Plan/Apply/Verify phases.
package engine
import (
"fmt"
"io"
"sync"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// Event is a single item streamed from a module during Apply.
type Event struct {
ModuleID string
Kind EventKind
Current int
Total int
Message string
}
type EventKind int
const (
EventProgress EventKind = iota
EventLog
)
// BufferedReporter stores every event in memory. Used in tests.
type BufferedReporter struct {
mu sync.Mutex
events []Event
}
func NewBufferedReporter() *BufferedReporter { return &BufferedReporter{} }
func (b *BufferedReporter) Progress(id string, current, total int, msg string) {
b.mu.Lock()
defer b.mu.Unlock()
b.events = append(b.events, Event{ModuleID: id, Kind: EventProgress, Current: current, Total: total, Message: msg})
}
func (b *BufferedReporter) Log(id, line string) {
b.mu.Lock()
defer b.mu.Unlock()
b.events = append(b.events, Event{ModuleID: id, Kind: EventLog, Message: line})
}
func (b *BufferedReporter) Events() []Event {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]Event, len(b.events))
copy(out, b.events)
return out
}
// PlainReporter writes human-readable lines to w. Concurrency-safe.
type PlainReporter struct {
mu sync.Mutex
w io.Writer
}
func NewPlainReporter(w io.Writer) *PlainReporter { return &PlainReporter{w: w} }
func (p *PlainReporter) Progress(id string, current, total int, msg string) {
p.mu.Lock()
defer p.mu.Unlock()
fmt.Fprintf(p.w, "[%s] (%d/%d) %s\n", id, current, total, msg)
}
func (p *PlainReporter) Log(id, line string) {
p.mu.Lock()
defer p.mu.Unlock()
fmt.Fprintf(p.w, "[%s] %s\n", id, line)
}
// Compile-time guarantee both types satisfy module.Reporter.
var (
_ module.Reporter = (*BufferedReporter)(nil)
_ module.Reporter = (*PlainReporter)(nil)
)

View File

@@ -0,0 +1,56 @@
package engine
import (
"bytes"
"strings"
"sync"
"testing"
)
func TestBufferedReporterRecordsEventsInOrder(t *testing.T) {
r := NewBufferedReporter()
r.Progress("m", 1, 3, "step 1")
r.Log("m", "hello")
r.Progress("m", 2, 3, "step 2")
ev := r.Events()
if len(ev) != 3 {
t.Fatalf("want 3 events, got %d", len(ev))
}
if ev[0].Kind != EventProgress || ev[0].Current != 1 {
t.Errorf("event[0] = %+v", ev[0])
}
if ev[1].Kind != EventLog || ev[1].Message != "hello" {
t.Errorf("event[1] = %+v", ev[1])
}
}
func TestBufferedReporterIsConcurrencySafe(t *testing.T) {
r := NewBufferedReporter()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
r.Progress("m", i, 50, "x")
}(i)
}
wg.Wait()
if got := len(r.Events()); got != 50 {
t.Fatalf("want 50 events, got %d", got)
}
}
func TestPlainReporterFormat(t *testing.T) {
var buf bytes.Buffer
r := NewPlainReporter(&buf)
r.Progress("packages", 2, 10, "installing tio")
r.Log("packages", "pacman: done")
out := buf.String()
for _, want := range []string{"[packages] (2/10) installing tio", "[packages] pacman: done"} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q; got:\n%s", want, out)
}
}
}

67
internal/module/module.go Normal file
View File

@@ -0,0 +1,67 @@
// Package module defines the four-phase contract every installable unit
// implements, along with the value types exchanged with the engine.
package module
import "context"
// Meta is static, declarative information about a module.
type Meta struct {
ID string // stable machine identifier, e.g. "packages"
Name string // human label, e.g. "Base packages"
Description string // one-line summary for TUI
Deps []string // IDs of modules that must complete before this one
}
// State is an opaque, module-defined snapshot of current system state.
// The engine never inspects it; it is threaded from Detect → Plan.
type State any
// ActionKind categorizes what an Action does, for UI grouping only.
type ActionKind int
const (
ActionUnknown ActionKind = iota
ActionPackageInstall
ActionFileWrite
ActionSymlink
ActionCommand
ActionGitClone
ActionGroupAdd
)
// Action is a single planned change with a human-readable description.
type Action struct {
Description string
Kind ActionKind
Exec func(ctx context.Context) error
}
// Plan is an ordered list of actions returned by Module.Plan.
type Plan struct {
Actions []Action
}
// IsEmpty reports whether the plan would make no changes.
func (p Plan) IsEmpty() bool { return len(p.Actions) == 0 }
// Module is the four-phase contract.
type Module interface {
Meta() Meta
Detect(ctx context.Context) (State, error)
Plan(ctx context.Context, current State) (Plan, error)
// 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.
PlanForce(ctx context.Context) (Plan, error)
Apply(ctx context.Context, plan Plan, r Reporter) error
Verify(ctx context.Context) error
}
// Reporter is how modules stream progress events. Defined here as an
// interface to avoid an import cycle with the engine package.
type Reporter interface {
Progress(moduleID string, current, total int, message string)
Log(moduleID, line string)
}

View File

@@ -0,0 +1,56 @@
package module
import (
"fmt"
"sort"
"sync"
)
// Registry holds all known modules keyed by Meta.ID.
type Registry struct {
mu sync.RWMutex
modules map[string]Module
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{modules: make(map[string]Module)}
}
// Register adds a module. Returns an error if the ID is already present
// or empty. Safe for concurrent use.
func (r *Registry) Register(m Module) error {
id := m.Meta().ID
if id == "" {
return fmt.Errorf("module has empty ID")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, dup := r.modules[id]; dup {
return fmt.Errorf("module %q already registered", id)
}
r.modules[id] = m
return nil
}
// Get returns the module with the given ID, or (nil, false) if absent.
func (r *Registry) Get(id string) (Module, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.modules[id]
return m, ok
}
// All returns all registered modules sorted by ID for determinism.
func (r *Registry) All() []Module {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Module, 0, len(r.modules))
for _, m := range r.modules {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Meta().ID < out[j].Meta().ID
})
return out
}

View File

@@ -0,0 +1,61 @@
package module
import (
"context"
"testing"
)
type fakeModule struct {
id string
deps []string
}
func (f fakeModule) Meta() Meta { return Meta{ID: f.id, Name: f.id, Deps: f.deps} }
func (f fakeModule) Detect(context.Context) (State, error) { return nil, nil }
func (f fakeModule) Plan(context.Context, State) (Plan, error) { return Plan{}, nil }
func (f fakeModule) PlanForce(context.Context) (Plan, error) { return Plan{}, nil }
func (f fakeModule) Apply(context.Context, Plan, Reporter) error { return nil }
func (f fakeModule) Verify(context.Context) error { return nil }
func TestRegistry(t *testing.T) {
t.Run("register then get", func(t *testing.T) {
r := NewRegistry()
if err := r.Register(fakeModule{id: "a"}); err != nil {
t.Fatal(err)
}
m, ok := r.Get("a")
if !ok || m.Meta().ID != "a" {
t.Fatalf("want module a, got %v ok=%v", m, ok)
}
})
t.Run("duplicate register errors", func(t *testing.T) {
r := NewRegistry()
_ = r.Register(fakeModule{id: "a"})
if err := r.Register(fakeModule{id: "a"}); err == nil {
t.Fatal("expected duplicate error")
}
})
t.Run("empty id errors", func(t *testing.T) {
r := NewRegistry()
if err := r.Register(fakeModule{id: ""}); err == nil {
t.Fatal("expected empty-id error")
}
})
t.Run("all is sorted by id", func(t *testing.T) {
r := NewRegistry()
_ = r.Register(fakeModule{id: "b"})
_ = r.Register(fakeModule{id: "a"})
_ = r.Register(fakeModule{id: "c"})
all := r.All()
got := []string{all[0].Meta().ID, all[1].Meta().ID, all[2].Meta().ID}
want := []string{"a", "b", "c"}
for i := range got {
if got[i] != want[i] {
t.Fatalf("All() = %v, want %v", got, want)
}
}
})
}

View File

@@ -0,0 +1,172 @@
// Package arduinocli installs arduino-cli and its configured cores.
package arduinocli
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
cfg config.ArduinoCLIConfig
runner platform.Runner
}
func New(cfg config.ArduinoCLIConfig, r platform.Runner) Module {
return Module{cfg: cfg, runner: r}
}
type state struct {
BinaryPath string
BinaryInstalled bool
MissingCores []string
}
func (Module) Meta() module.Meta {
return module.Meta{ID: "arduinocli", Name: "arduino-cli", Description: "Install arduino-cli and cores."}
}
func binaryPath() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".local", "bin", "arduino-cli"), nil
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
bp, err := binaryPath()
if err != nil {
return nil, err
}
s := state{BinaryPath: bp}
if _, err := os.Stat(bp); err == nil {
s.BinaryInstalled = true
out, _ := m.runner.Output(ctx, bp, "core", "list")
for _, core := range m.cfg.Cores {
if !bytes.Contains(out, []byte(core)) {
s.MissingCores = append(s.MissingCores, core)
}
}
} else {
s.MissingCores = append([]string{}, m.cfg.Cores...)
}
return s, nil
}
// downloadBinary fetches, extracts, and installs arduino-cli to bp.
func (m Module) downloadBinary(ctx context.Context, bp string) error {
tmpDir, err := os.MkdirTemp("", "arduinocli-")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
archive := filepath.Join(tmpDir, "arduino-cli.tar.gz")
if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil {
return err
}
if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil {
return err
}
data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli"))
if err != nil {
return err
}
return os.WriteFile(bp, data, 0o755)
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
var actions []module.Action
if !s.BinaryInstalled {
bp := s.BinaryPath
actions = append(actions, module.Action{
Description: "download + install arduino-cli " + m.cfg.Version,
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error { return m.downloadBinary(ctx, bp) },
})
}
for _, core := range s.MissingCores {
core := core
actions = append(actions, module.Action{
Description: "arduino-cli core update-index (for " + core + ")",
Kind: module.ActionCommand,
Exec: func(ctx context.Context) error {
return m.runner.Run(ctx, s.BinaryPath, "core", "update-index")
},
})
actions = append(actions, module.Action{
Description: "arduino-cli core install " + core,
Kind: module.ActionCommand,
Exec: func(ctx context.Context) error {
return m.runner.Run(ctx, s.BinaryPath, "core", "install", core)
},
})
}
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.BinaryInstalled {
return fmt.Errorf("arduino-cli binary missing")
}
if len(s.MissingCores) > 0 {
return fmt.Errorf("cores missing: %v", s.MissingCores)
}
return nil
}
// PlanForce redownloads the arduino-cli binary, refreshes the core index,
// and reinstalls every configured core unconditionally. arduino-cli's
// `core install` is idempotent, so reissuing it is safe.
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
bp, err := binaryPath()
if err != nil {
return module.Plan{}, err
}
actions := []module.Action{
{
Description: "redownload + reinstall arduino-cli " + m.cfg.Version + " (force)",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error { return m.downloadBinary(ctx, bp) },
},
{
Description: "arduino-cli core update-index (force)",
Kind: module.ActionCommand,
Exec: func(ctx context.Context) error { return m.runner.Run(ctx, bp, "core", "update-index") },
},
}
for _, core := range m.cfg.Cores {
core := core
actions = append(actions, module.Action{
Description: "arduino-cli core install " + core + " (force)",
Kind: module.ActionCommand,
Exec: func(ctx context.Context) error { return m.runner.Run(ctx, bp, "core", "install", core) },
})
}
return module.Plan{Actions: actions}, nil
}

View File

@@ -0,0 +1,154 @@
package arduinocli
import (
"archive/tar"
"compress/gzip"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
coreListOut []byte
}
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 filepath.Base(name) == "arduino-cli" && len(args) >= 2 && args[0] == "core" && args[1] == "list" {
return f.coreListOut, nil
}
return nil, nil
}
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func makeTarGz(t *testing.T, entries map[string][]byte) string {
t.Helper()
p := filepath.Join(t.TempDir(), "a.tgz")
f, _ := os.Create(p)
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
for name, data := range entries {
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))})
_, _ = tw.Write(data)
}
tw.Close()
gz.Close()
f.Close()
return p
}
func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\n")})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := os.ReadFile(archive)
w.Write(data)
}))
defer srv.Close()
r := &fakeRunner{coreListOut: []byte("ID Installed\n")}
m := New(config.ArduinoCLIConfig{
Version: "1.0.0",
URL: srv.URL + "/arduino-cli.tgz",
Cores: []string{"arduino:avr"},
}, r)
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(home, ".local", "bin", "arduino-cli")); err != nil {
t.Fatalf("binary not installed: %v", err)
}
sawCore := false
for _, c := range r.runCalls {
if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" && c[3] == "arduino:avr" {
sawCore = true
}
}
if !sawCore {
t.Fatalf("core install not run; calls=%v", r.runCalls)
}
}
func TestPlanForceRedownloadsAndReinstallsCores(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Pre-populate a stale binary to prove force overwrites.
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
bp := filepath.Join(home, ".local", "bin", "arduino-cli")
os.WriteFile(bp, []byte("stale"), 0o755)
archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\nfresh\n")})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := os.ReadFile(archive)
w.Write(data)
}))
defer srv.Close()
r := &fakeRunner{coreListOut: []byte("arduino:avr\nesp32:esp32\n")}
m := New(config.ArduinoCLIConfig{
Version: "1.0.0",
URL: srv.URL + "/arduino-cli.tgz",
Cores: []string{"arduino:avr", "esp32:esp32"},
}, r)
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec %q: %v", a.Description, err)
}
}
// Binary got overwritten with fresh payload.
got, _ := os.ReadFile(bp)
if string(got) == "stale" {
t.Fatalf("binary not overwritten by force")
}
// update-index and both `core install` calls fired.
sawUpdate := false
installed := map[string]bool{}
for _, c := range r.runCalls {
if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 3 && c[1] == "core" && c[2] == "update-index" {
sawUpdate = true
}
if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" {
installed[c[3]] = true
}
}
if !sawUpdate {
t.Errorf("no core update-index call; calls=%v", r.runCalls)
}
if !installed["arduino:avr"] || !installed["esp32:esp32"] {
t.Errorf("expected both cores force-installed; got=%v calls=%v", installed, r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,101 @@
// Package fonts installs AUR fonts via the yay helper.
package fonts
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
fonts []string
pm platform.PackageManager
aur platform.AURHelper
}
func New(fonts []string, pm platform.PackageManager, aur platform.AURHelper) Module {
return Module{fonts: fonts, pm: pm, aur: aur}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "fonts", Name: "Nerd fonts", Description: "Install AUR nerd fonts."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, f := range m.fonts {
ok, err := m.pm.IsInstalled(ctx, f)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, f)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{
{
Description: "ensure yay installed",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
},
{
Description: "yay install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.Install(ctx, missing) },
},
}}, 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
}
if miss := st.(state).Missing; len(miss) > 0 {
return fmt.Errorf("fonts still missing: %v", miss)
}
return nil
}
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
if len(m.fonts) == 0 {
return module.Plan{}, nil
}
fonts := append([]string{}, m.fonts...)
return module.Plan{Actions: []module.Action{
{
Description: "ensure yay installed",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
},
{
Description: "yay -S (force) " + strings.Join(fonts, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.Reinstall(ctx, fonts) },
},
}}, nil
}

View File

@@ -0,0 +1,99 @@
package fonts
import (
"context"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
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, errOne
}
return nil, nil
}
type eo struct{}
func (eo) Error() string { return "exit 1" }
var errOne = eo{}
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func TestFontsPlanApplyUsesYay(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yay": true}}
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
sawYay := false
for _, c := range r.runCalls {
if len(c) >= 2 && c[0] == "yay" && c[1] == "-S" {
sawYay = true
}
}
if !sawYay {
t.Fatalf("no yay install; calls=%v", r.runCalls)
}
}
func TestFontsDetectPresentNoActions(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yay": true, "ttf-jetbrains-mono-nerd": true}}
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if !p.IsEmpty() {
t.Fatal("want empty plan when already installed")
}
}
func TestPlanForceReinstallsAllFonts(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yay": true, "ttf-jetbrains-mono-nerd": true}}
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
sawReinstall := false
for _, c := range r.runCalls {
// yay -S --noconfirm ... (no --needed)
if len(c) >= 3 && c[0] == "yay" && c[1] == "-S" && c[2] == "--noconfirm" {
sawReinstall = true
}
}
if !sawReinstall {
t.Fatalf("no yay reinstall (-S --noconfirm, no --needed) call; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,86 @@
// Package gittools installs a declarative list of pacman packages.
package gittools
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
pkgs []string
pm platform.PackageManager
}
func New(pkgs []string, pm platform.PackageManager) Module {
return Module{pkgs: pkgs, pm: pm}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "gittools", Name: "Git tools", Description: "Install lazygit."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, p := range m.pkgs {
ok, err := m.pm.IsInstalled(ctx, p)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, p)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: "pacman install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
}}}, 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
}
if missing := st.(state).Missing; len(missing) > 0 {
return fmt.Errorf("packages not installed after apply: %v", missing)
}
return nil
}
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
}

View File

@@ -0,0 +1,139 @@
package gittools
import (
"context"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool // key: pkg name
}
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(args[1] + " 1.0-1\n"), nil
}
return nil, errExitOne
}
return nil, nil
}
type exitOneErr struct{}
func (exitOneErr) Error() string { return "exit status 1" }
var errExitOne = exitOneErr{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectAbsent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"lazygit"}, platform.NewPacman(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if len(s.Missing) != 1 {
t.Fatalf("want 1 missing, got %v", s.Missing)
}
}
func TestDetectPresent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
m := New([]string{"lazygit"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
if len(st.(state).Missing) != 0 {
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
}
}
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
m := New([]string{"lazygit"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: []string{"lazygit"}})
if len(p.Actions) != 1 {
t.Fatalf("want 1 action, got %d", len(p.Actions))
}
if !strings.Contains(p.Actions[0].Description, "lazygit") {
t.Fatalf("desc: %s", p.Actions[0].Description)
}
}
func TestPlanPresentEmpty(t *testing.T) {
m := New([]string{"lazygit"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: nil})
if !p.IsEmpty() {
t.Fatal("want empty plan")
}
}
func TestApplyShellsOut(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"lazygit"}, platform.NewPacman(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)
}
sawInstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
sawInstall = true
}
}
if !sawInstall {
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
}
}
func TestVerifyAfterApply(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
m := New([]string{"lazygit"}, platform.NewPacman(r))
if err := m.Verify(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestPlanForceReinstallsAllPackages(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
m := New([]string{"lazygit"}, platform.NewPacman(r))
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
sawReinstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
sawReinstall = true
}
}
if !sawReinstall {
t.Fatalf("no pacman reinstall (-S --noconfirm, no --needed) call; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,115 @@
// Package hello is a toy module that writes a marker file. It exists to
// validate the engine end-to-end before real modules arrive in Plan 3.
package hello
import (
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
const defaultRelPath = ".cache/archduino/hello.ok"
const markerContent = "archduino-hello\n"
// Module is the exported module value. Path is overridable for tests.
type Module struct {
Path string // absolute path; if empty, ~/.cache/archduino/hello.ok is used
}
func (m Module) resolve() (string, error) {
if m.Path != "" {
return m.Path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, defaultRelPath), nil
}
func (Module) Meta() module.Meta {
return module.Meta{
ID: "hello",
Name: "Hello",
Description: "Toy module that writes a marker file (engine smoke test).",
}
}
type state struct{ Present bool }
func (m Module) Detect(ctx context.Context) (module.State, error) {
path, err := m.resolve()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return state{Present: false}, nil
}
return nil, err
}
return state{Present: string(data) == markerContent}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if s.Present {
return module.Plan{}, nil
}
path, err := m.resolve()
if err != nil {
return module.Plan{}, err
}
return module.Plan{Actions: []module.Action{{
Description: "write marker " + path,
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(markerContent), 0o644)
},
}}}, 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
}
if !st.(state).Present {
return fmt.Errorf("hello: marker not present after apply")
}
return nil
}
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
path, err := m.resolve()
if err != nil {
return module.Plan{}, err
}
return module.Plan{Actions: []module.Action{{
Description: "hello force reapply (rewrite marker " + path + ")",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(markerContent), 0o644)
},
}}}, nil
}

View File

@@ -0,0 +1,85 @@
package hello
import (
"context"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
)
func TestHelloFullLifecycle(t *testing.T) {
path := filepath.Join(t.TempDir(), "hello.ok")
m := Module{Path: path}
ctx := context.Background()
st, err := m.Detect(ctx)
if err != nil {
t.Fatal(err)
}
if st.(state).Present {
t.Fatal("marker should be absent on fresh tmpdir")
}
plan, err := m.Plan(ctx, st)
if err != nil {
t.Fatal(err)
}
if plan.IsEmpty() {
t.Fatal("plan should have one action")
}
if err := m.Apply(ctx, plan, engine.NewBufferedReporter()); err != nil {
t.Fatal(err)
}
if err := m.Verify(ctx); err != nil {
t.Fatal(err)
}
st2, _ := m.Detect(ctx)
if !st2.(state).Present {
t.Fatal("marker should be present after apply")
}
plan2, _ := m.Plan(ctx, st2)
if !plan2.IsEmpty() {
t.Fatal("second plan should be empty (idempotent)")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != markerContent {
t.Fatalf("content = %q", data)
}
}
func TestHelloPlanForceNonEmpty(t *testing.T) {
path := filepath.Join(t.TempDir(), "hello.ok")
m := Module{Path: path}
// Even after marker is present, force plan must run.
if err := os.WriteFile(path, []byte(markerContent), 0o644); err != nil {
t.Fatal(err)
}
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != markerContent {
t.Fatalf("content after force = %q", data)
}
}

View File

@@ -0,0 +1,10 @@
return {
{
"stevearc/vim-arduino",
ft = { "arduino", "cpp", "c" },
config = function()
vim.g.arduino_use_cli = 1
vim.g.arduino_cli_path = "arduino-cli"
end,
},
}

View File

@@ -0,0 +1,8 @@
---@type ChadrcConfig
local M = {}
M.base46 = {
theme = "{{.NvChadTheme}}",
}
return M

View File

@@ -0,0 +1,10 @@
return {
cmd = {
"clangd",
"--background-index",
"--clang-tidy",
"--header-insertion=iwyu",
"--completion-style=detailed",
},
filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" },
}

View File

@@ -0,0 +1,57 @@
return {
{
"mfussenegger/nvim-dap",
keys = { "<leader>d" },
config = function()
local dap = require("dap")
-- GDB remote adapter (connects to Renode's GDB server)
dap.adapters.gdb_remote = {
type = "executable",
command = vim.fn.executable("arm-none-eabi-gdb") == 1
and "arm-none-eabi-gdb"
or "gdb-multiarch",
args = { "-i", "dap" },
}
-- Default debug configurations for Arduino/embedded
dap.configurations.c = {
{
name = "Attach to Renode (GDB :3333)",
type = "gdb_remote",
request = "attach",
target = "localhost:3333",
cwd = "${workspaceFolder}",
program = function()
return vim.fn.input("ELF path: ", vim.fn.getcwd() .. "/build/", "file")
end,
},
}
dap.configurations.cpp = dap.configurations.c
dap.configurations.arduino = dap.configurations.c
-- Breakpoint signs
vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError" })
vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticOk", linehl = "DiffAdd" })
end,
},
{
"rcarriga/nvim-dap-ui",
dependencies = { "mfussenegger/nvim-dap", "nvim-neotest/nvim-nio" },
keys = { "<leader>du" },
config = function()
local dapui = require("dapui")
dapui.setup()
-- Auto open/close UI on debug sessions
local dap = require("dap")
dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end
dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end
dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end
end,
},
{
"nvim-neotest/nvim-nio",
lazy = true,
},
}

View File

@@ -0,0 +1,2 @@
vim.lsp.enable("clangd")
return {}

View File

@@ -0,0 +1,20 @@
require "nvchad.mappings"
local map = vim.keymap.set
map("n", "<leader>ac", "<cmd>ArduinoChooseBoard<cr>", { desc = "Arduino Choose Board" })
map("n", "<leader>ap", "<cmd>ArduinoChoosePort<cr>", { desc = "Arduino Choose Port" })
map("n", "<leader>av", "<cmd>ArduinoVerify<cr>", { desc = "Arduino Verify (compile)" })
map("n", "<leader>au", "<cmd>ArduinoUpload<cr>", { desc = "Arduino Upload" })
map("n", "<leader>as", "<cmd>ArduinoSerial<cr>", { desc = "Arduino Serial Monitor" })
map("n", "<leader>ai", "<cmd>ArduinoInfo<cr>", { desc = "Arduino Board Info" })
map("n", "<leader>aw", "<cmd>!wokwi-cli .<cr>", { desc = "Arduino Wokwi Simulate" })
-- Debug (DAP)
map("n", "<leader>ds", function() require("dap").continue() end, { desc = "Debug Start/Continue" })
map("n", "<leader>db", function() require("dap").toggle_breakpoint() end, { desc = "Debug Toggle Breakpoint" })
map("n", "<leader>dn", function() require("dap").step_over() end, { desc = "Debug Step Over" })
map("n", "<leader>di", function() require("dap").step_into() end, { desc = "Debug Step Into" })
map("n", "<leader>do", function() require("dap").step_out() end, { desc = "Debug Step Out" })
map("n", "<leader>dx", function() require("dap").terminate() end, { desc = "Debug Terminate" })
map("n", "<leader>du", function() require("dapui").toggle() end, { desc = "Debug UI Toggle" })

View File

@@ -0,0 +1,575 @@
// 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"
"os"
"path/filepath"
"strings"
"text/template"
"time"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
)
//go:embed assets/*
var assets embed.FS
// Register WriteChadrc with the theming package so theming.Apply can
// rewrite chadrc.lua on theme changes without importing this package
// (which would be a cycle: neovim imports theming).
func init() {
theming.ChadrcWriter = WriteChadrc
}
// 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"},
}
// Module installs and configures Neovim + NvChad for Archduino.
type Module struct {
cfg config.NeovimConfig
themeID string
pm platform.PackageManager
git platform.GitClient
}
// New constructs a Neovim Module.
func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module {
return Module{cfg: cfg, themeID: themeID, pm: pm, git: git}
}
type state struct {
NvimInstalled bool
ConfigDirExists bool
ChadrcPresent bool
BackupNeeded bool
BackupSource string
StarterCloned bool
MissingAssets []string
ChadrcDrift bool
LspconfigBlock bool
StarterLspFile bool
ThemeName string
RenderedChadrc []byte
}
// Meta returns the module metadata.
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 := theming.Get(m.themeID); ok {
return t.NvChadTheme
}
return theming.Default().NvChadTheme
}
// WriteChadrc renders chadrc.lua for the given NvChad theme name and writes
// it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist
// and the surrounding nvim config dir does not exist, the function returns
// nil without writing anything (the user hasn't installed neovim yet).
//
// Exported so the theming package can re-render chadrc on theme switches
// without re-running the full Module pipeline.
func WriteChadrc(nvChadThemeName string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
cfg := filepath.Join(home, ".config", "nvim")
chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua")
// If chadrc doesn't exist, only write when the parent dir exists. A
// fresh system where nvim was never installed should be a no-op.
if _, err := os.Stat(chadrcPath); err != nil {
if _, err := os.Stat(cfg); err != nil {
return nil
}
}
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl")
if err != nil {
return err
}
tmpl, err := template.New("chadrc").Parse(string(tmplBytes))
if err != nil {
return err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil {
return err
}
return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644)
}
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
}
// Detect inspects the filesystem and returns the current state.
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
}
if cur, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil {
if !bytes.Equal(cur, rendered) {
s.ChadrcDrift = true
}
}
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
}
// stripLspconfigBlock removes the plugin-table entry that references
// "neovim/nvim-lspconfig" from the NvChad starter's init.lua. It walks the
// source line-by-line: find the marker line, scan backward for the opening
// `{` of that entry, then scan forward counting braces until the matching
// `}` (plus trailing `,` if present). Robust against nested tables inside
// the entry (e.g. `opts = {}`) that defeat single-level regexes.
func stripLspconfigBlock(src []byte) []byte {
lines := strings.Split(string(src), "\n")
markerIdx := -1
for i, l := range lines {
if strings.Contains(l, "neovim/nvim-lspconfig") {
markerIdx = i
break
}
}
if markerIdx == -1 {
return src
}
// Scan backward for the opening `{` that begins the plugin entry.
// We track braces relative to the marker line so nested tables on
// earlier lines don't confuse the search: we want the first `{`
// whose pair would enclose the marker.
startLine := -1
depth := 0
for i := markerIdx; i >= 0; i-- {
// Count braces on this line from right to left.
for j := len(lines[i]) - 1; j >= 0; j-- {
c := lines[i][j]
if c == '}' {
depth++
} else if c == '{' {
if depth == 0 {
startLine = i
// break out of both loops
i = -1
break
}
depth--
}
}
}
if startLine == -1 {
return src
}
// Scan forward from markerIdx for the matching `}`.
endLine := -1
depth = 1 // we're already inside the `{` at startLine
// Start just after the opening `{` on startLine.
for i := startLine; i < len(lines); i++ {
start := 0
if i == startLine {
// skip past the first `{` on this line
idx := strings.Index(lines[i], "{")
if idx >= 0 {
start = idx + 1
}
}
for j := start; j < len(lines[i]); j++ {
c := lines[i][j]
if c == '{' {
depth++
} else if c == '}' {
depth--
if depth == 0 {
endLine = i
break
}
}
}
if endLine != -1 {
break
}
}
if endLine == -1 {
return src
}
// Include an optional trailing comma and whitespace-only lines after endLine.
cutEnd := endLine
// If the `}` line also has a trailing `,`, that's fine; keep the line as-is
// but drop it. If the endLine text after the closing `}` is just `,` or
// `,` + whitespace, keep cutEnd = endLine. Otherwise also keep cutEnd.
// Skip a single blank line after the block to avoid leaving a stray gap.
if cutEnd+1 < len(lines) && strings.TrimSpace(lines[cutEnd+1]) == "" {
cutEnd++
}
out := make([]string, 0, len(lines)-(cutEnd-startLine+1))
out = append(out, lines[:startLine]...)
out = append(out, lines[cutEnd+1:]...)
return []byte(strings.Join(out, "\n"))
}
// Plan computes the idempotent set of actions to converge to desired state.
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"))
},
})
}
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 {
err := os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua"))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
},
})
}
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)
},
})
}
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
},
})
}
return module.Plan{Actions: actions}, nil
}
// Apply executes the planned actions in order.
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
}
// Verify re-Detects and returns an error if state is not converged.
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
}
// PlanForce rewrites chadrc.lua and reinstalls packages but does NOT
// delete an existing NvChad clone, to preserve any user modifications.
// If NvChad is missing, it's cloned fresh.
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
cfg, err := nvimCfg()
if err != nil {
return module.Plan{}, err
}
rendered, err := m.renderChadrc()
if err != nil {
return module.Plan{}, err
}
var actions []module.Action
if m.pm != nil {
actions = append(actions, module.Action{
Description: "pacman -S (force) neovim",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"neovim"}) },
})
}
// Clone NvChad only if absent; preserve any user modifications otherwise.
repo := m.cfg.NvChadStarterRepo
actions = append(actions, module.Action{
Description: "ensure NvChad starter cloned (skip if present)",
Kind: module.ActionGitClone,
Exec: func(ctx context.Context) error {
if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil {
return nil
}
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"))
},
})
actions = append(actions, module.Action{
Description: "strip nvim-lspconfig block from starter init.lua (force)",
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 {
if os.IsNotExist(err) {
return nil
}
return err
}
return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644)
},
})
actions = append(actions, module.Action{
Description: "remove starter lspconfig.lua (force)",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
err := os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua"))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
},
})
renderedCopy := append([]byte{}, rendered...)
themeName := m.resolvedTheme()
actions = append(actions, module.Action{
Description: "rewrite chadrc.lua with theme " + themeName + " (force)",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Join(cfg, "lua"), 0o755); err != nil {
return err
}
return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), renderedCopy, 0o644)
},
})
for _, a := range assetMap {
a := a
actions = append(actions, module.Action{
Description: "rewrite asset " + a.DstRel + " (force)",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
data, err := assets.ReadFile(a.SrcPath)
if err != nil {
return err
}
return platform.WriteUserFile(filepath.Join(cfg, a.DstRel), data, 0o644)
},
})
}
actions = append(actions, module.Action{
Description: "clear NvChad base46 theme cache (force)",
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
},
})
return module.Plan{Actions: actions}, nil
}

View File

@@ -0,0 +1,299 @@
package neovim
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/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...))
if name == "git" && len(args) >= 1 && args[0] == "clone" {
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", 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)
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", 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", 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, 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)
}
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)
}
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "configs", "lspconfig.lua")); !os.IsNotExist(err) {
t.Fatal("starter lspconfig.lua should be removed")
}
for _, name := range []string{"arduino.lua", "dap.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, "lua", "mappings.lua")); err != nil {
t.Errorf("mappings.lua missing: %v", 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", 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 TestUnknownThemeFallsBackToDefault(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", 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"))
// Unknown theme IDs fall back to theming.Default(), which is gruvbox.
if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
t.Fatalf("fallback theme not applied: %s", chadrc)
}
}
func TestWriteChadrcWritesFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg := filepath.Join(home, ".config", "nvim")
if err := os.MkdirAll(cfg, 0o755); err != nil {
t.Fatal(err)
}
if err := WriteChadrc("dracula"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `theme = "dracula"`) {
t.Fatalf("theme not applied: %s", data)
}
}
func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := WriteChadrc("gruvbox"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")); !os.IsNotExist(err) {
t.Fatal("chadrc should not be created on a bare system")
}
}
func TestPlanForceReinstallsAndRewrites(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Pre-populate an "installed" NvChad clone with a local user tweak so we
// can verify PlanForce does NOT wipe the clone.
cfg := filepath.Join(home, ".config", "nvim")
os.MkdirAll(filepath.Join(cfg, "lua", "plugins"), 0o755)
os.WriteFile(filepath.Join(cfg, "lua", "plugins", "init.lua"),
[]byte("return {\n \"other\",\n},\n"), 0o644)
userTweak := filepath.Join(cfg, "lua", "user_tweak.lua")
os.WriteFile(userTweak, []byte("-- my custom stuff"), 0o644)
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", platform.NewPacman(r), platform.GitClient{Runner: r})
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec %q: %v", a.Description, err)
}
}
// User tweak must survive.
if _, err := os.Stat(userTweak); err != nil {
t.Fatalf("PlanForce wiped user NvChad modification: %v", err)
}
// No `git clone` call because starter init.lua was already present.
for _, c := range r.runCalls {
if c[0] == "git" && len(c) >= 2 && c[1] == "clone" {
t.Fatalf("PlanForce re-cloned existing NvChad: %v", c)
}
}
// pacman reinstall must have fired.
sawReinstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
sawReinstall = true
}
}
if !sawReinstall {
t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls)
}
// chadrc.lua was rewritten with the requested theme.
chadrc, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua"))
if err != nil {
t.Fatalf("chadrc missing after force: %v", err)
}
if !strings.Contains(string(chadrc), `theme = "dracula"`) {
t.Fatalf("chadrc theme not applied: %s", chadrc)
}
// assets rewritten
for _, name := range []string{"arduino.lua", "dap.lua"} {
if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", name)); err != nil {
t.Errorf("asset %s missing after force: %v", name, err)
}
}
}
func TestPlanForceClonesIfMissing(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", platform.NewPacman(r), platform.GitClient{Runner: r})
plan, err := m.PlanForce(context.Background())
if err != nil {
t.Fatalf("PlanForce: %v", err)
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec %q: %v", a.Description, err)
}
}
sawClone := false
for _, c := range r.runCalls {
if c[0] == "git" && len(c) >= 2 && c[1] == "clone" {
sawClone = true
}
}
if !sawClone {
t.Fatalf("expected git clone when NvChad absent; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,86 @@
// Package packages installs a declarative list of pacman packages.
package packages
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
pkgs []string
pm platform.PackageManager
}
func New(pkgs []string, pm platform.PackageManager) Module {
return Module{pkgs: pkgs, pm: pm}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "packages", Name: "Base packages", Description: "Install base pacman package list."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, p := range m.pkgs {
ok, err := m.pm.IsInstalled(ctx, p)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, p)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: "pacman install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
}}}, 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
}
if missing := st.(state).Missing; len(missing) > 0 {
return fmt.Errorf("packages not installed after apply: %v", missing)
}
return nil
}
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
}

View File

@@ -0,0 +1,139 @@
package packages
import (
"context"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool // key: pkg name
}
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(args[1] + " 1.0-1\n"), nil
}
return nil, errExitOne
}
return nil, nil
}
type exitOneErr struct{}
func (exitOneErr) Error() string { return "exit status 1" }
var errExitOne = exitOneErr{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectAbsent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if len(s.Missing) != 2 {
t.Fatalf("want 2 missing, got %v", s.Missing)
}
}
func TestDetectPresent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"curl": true, "wget": true}}
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
if len(st.(state).Missing) != 0 {
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
}
}
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
m := New([]string{"curl", "wget"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: []string{"curl", "wget"}})
if len(p.Actions) != 1 {
t.Fatalf("want 1 action, got %d", len(p.Actions))
}
if !strings.Contains(p.Actions[0].Description, "curl") {
t.Fatalf("desc: %s", p.Actions[0].Description)
}
}
func TestPlanPresentEmpty(t *testing.T) {
m := New([]string{"curl"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: nil})
if !p.IsEmpty() {
t.Fatal("want empty plan")
}
}
func TestApplyShellsOut(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"curl"}, platform.NewPacman(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)
}
sawInstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
sawInstall = true
}
}
if !sawInstall {
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
}
}
func TestVerifyAfterApply(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"curl": true}}
m := New([]string{"curl"}, platform.NewPacman(r))
if err := m.Verify(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestPlanForceReinstallsAllPackages(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"curl": true, "wget": true}}
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
sawReinstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
sawReinstall = true
}
}
if !sawReinstall {
t.Fatalf("no pacman reinstall (-S --noconfirm, no --needed) call; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,54 @@
#!/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"

View File

@@ -0,0 +1,236 @@
// 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"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
//go:embed assets/arduino-sim.tmpl
var assetsFS embed.FS
// Module installs Renode and adjacent emulator tooling.
type Module struct {
cfg config.RenodeConfig
pm platform.PackageManager
aur platform.AURHelper
}
// New constructs a renode Module.
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
}
// Meta returns the module metadata.
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
}
// Detect inspects the system and returns current state.
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
}
// Plan computes the set of actions needed to converge to desired state.
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
}
// Apply executes the planned actions in order.
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
}
// Verify re-Detects and errors if state hasn't converged.
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
}
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
bin, err := binPath()
if err != nil {
return module.Plan{}, err
}
rendered, err := m.renderSim()
if err != nil {
return module.Plan{}, err
}
simPath := filepath.Join(bin, "arduino-sim")
wokwiPath := filepath.Join(bin, "wokwi-cli")
actions := []module.Action{
{
Description: "ensure yay installed",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
},
{
Description: "yay -S (force) renode-bin",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.aur.Reinstall(ctx, []string{"renode-bin"}) },
},
{
Description: "pacman -S (force) arm-none-eabi-gdb",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"arm-none-eabi-gdb"}) },
},
{
Description: "rewrite arduino-sim helper (force)",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
return platform.WriteUserFile(simPath, rendered, 0o755)
},
},
{
Description: "redownload wokwi-cli " + m.cfg.WokwiVersion + " (force)",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(wokwiPath), 0o755); err != nil {
return err
}
return platform.Download(ctx, m.cfg.WokwiURL, wokwiPath, 0o755)
},
},
}
return module.Plan{Actions: actions}, nil
}

View File

@@ -0,0 +1,193 @@
package renode
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/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)
}
}
func TestPlanForceReinstallsEverything(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-forced"))
}))
defer srv.Close()
// Even with everything already "installed", force plan should be non-empty.
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("stale"), 0o755)
r := &fakeRunner{installed: map[string]bool{"yay": true, "renode-bin": true, "arm-none-eabi-gdb": true}}
m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(r))
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec %q: %v", a.Description, err)
}
}
var sawYayReinstall, sawPacmanReinstall bool
for _, c := range r.runCalls {
if len(c) >= 3 && c[0] == "yay" && c[1] == "-S" && c[2] == "--noconfirm" {
sawYayReinstall = true
}
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
sawPacmanReinstall = true
}
}
if !sawYayReinstall {
t.Errorf("no yay reinstall call; calls=%v", r.runCalls)
}
if !sawPacmanReinstall {
t.Errorf("no pacman reinstall call; calls=%v", r.runCalls)
}
// wokwi-cli should have been overwritten with the fresh payload
wokwi, _ := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli"))
if string(wokwi) != "wokwi-forced" {
t.Errorf("wokwi-cli not overwritten: %q", wokwi)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,109 @@
// Package shellconfig writes marker-delimited blocks into rc files.
package shellconfig
import (
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct{ cfg config.ShellConfigSection }
func New(cfg config.ShellConfigSection) Module { return Module{cfg: cfg} }
type state struct{ Missing []config.ShellConfigBlock }
func (Module) Meta() module.Meta {
return module.Meta{ID: "shellconfig", Name: "Shell config blocks", Description: "Write archduino blocks into rc files."}
}
func resolveFile(name string) (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, name), nil
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []config.ShellConfigBlock
for _, b := range m.cfg.Blocks {
p, err := resolveFile(b.File)
if err != nil {
return nil, err
}
has, err := platform.HasBlock(p, b.Name)
if err != nil {
return nil, err
}
if !has {
missing = append(missing, b)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
var actions []module.Action
for _, b := range s.Missing {
b := b
actions = append(actions, module.Action{
Description: fmt.Sprintf("write block %s into %s", b.Name, b.File),
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
p, err := resolveFile(b.File)
if err != nil {
return err
}
return platform.EnsureBlock(p, b.Name, b.Content)
},
})
}
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
}
if miss := st.(state).Missing; len(miss) > 0 {
return fmt.Errorf("shell blocks still missing: %d", len(miss))
}
return nil
}
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
var actions []module.Action
for _, b := range m.cfg.Blocks {
b := b
actions = append(actions, module.Action{
Description: fmt.Sprintf("rewrite block %s into %s (force)", b.Name, b.File),
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
p, err := resolveFile(b.File)
if err != nil {
return err
}
return platform.EnsureBlock(p, b.Name, b.Content)
},
})
}
return module.Plan{Actions: actions}, nil
}

View File

@@ -0,0 +1,96 @@
package shellconfig
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func TestShellConfigWritesBlocks(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
m := New(config.ShellConfigSection{
Blocks: []config.ShellConfigBlock{
{Name: "path", File: ".zshrc", Content: `export PATH="$HOME/.local/bin:$PATH"`},
},
})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(home, ".zshrc"))
if !strings.Contains(string(data), ">>> path >>>") {
t.Fatalf("block not present: %q", data)
}
if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) {
t.Fatalf("content missing: %q", data)
}
}
func TestShellConfigIdempotent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg := config.ShellConfigSection{Blocks: []config.ShellConfigBlock{
{Name: "x", File: ".zshrc", Content: "echo x"},
}}
m := New(cfg)
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
_ = m.Apply(context.Background(), p, nop{})
st2, _ := m.Detect(context.Background())
p2, _ := m.Plan(context.Background(), st2)
if !p2.IsEmpty() {
t.Fatalf("second plan not empty: %+v", p2.Actions)
}
}
func TestPlanForceRewritesBlocks(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg := config.ShellConfigSection{Blocks: []config.ShellConfigBlock{
{Name: "path", File: ".zshrc", Content: `export PATH="$HOME/.local/bin:$PATH"`},
}}
m := New(cfg)
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
st2, _ := m.Detect(context.Background())
p2, _ := m.Plan(context.Background(), st2)
if !p2.IsEmpty() {
t.Fatalf("precondition: regular plan should be empty")
}
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
data, _ := os.ReadFile(filepath.Join(home, ".zshrc"))
if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) {
t.Fatalf("content missing after force: %q", data)
}
if !strings.Contains(string(data), ">>> path >>>") {
t.Fatalf("block marker missing: %q", data)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,86 @@
// Package starship installs a declarative list of pacman packages.
package starship
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
pkgs []string
pm platform.PackageManager
}
func New(pkgs []string, pm platform.PackageManager) Module {
return Module{pkgs: pkgs, pm: pm}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "starship", Name: "Starship prompt", Description: "Install starship prompt."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, p := range m.pkgs {
ok, err := m.pm.IsInstalled(ctx, p)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, p)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: "pacman install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
}}}, 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
}
if missing := st.(state).Missing; len(missing) > 0 {
return fmt.Errorf("packages not installed after apply: %v", missing)
}
return nil
}
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
}

View File

@@ -0,0 +1,139 @@
package starship
import (
"context"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool // key: pkg name
}
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(args[1] + " 1.0-1\n"), nil
}
return nil, errExitOne
}
return nil, nil
}
type exitOneErr struct{}
func (exitOneErr) Error() string { return "exit status 1" }
var errExitOne = exitOneErr{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectAbsent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"starship"}, platform.NewPacman(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if len(s.Missing) != 1 {
t.Fatalf("want 1 missing, got %v", s.Missing)
}
}
func TestDetectPresent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"starship": true}}
m := New([]string{"starship"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
if len(st.(state).Missing) != 0 {
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
}
}
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
m := New([]string{"starship"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: []string{"starship"}})
if len(p.Actions) != 1 {
t.Fatalf("want 1 action, got %d", len(p.Actions))
}
if !strings.Contains(p.Actions[0].Description, "starship") {
t.Fatalf("desc: %s", p.Actions[0].Description)
}
}
func TestPlanPresentEmpty(t *testing.T) {
m := New([]string{"starship"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: nil})
if !p.IsEmpty() {
t.Fatal("want empty plan")
}
}
func TestApplyShellsOut(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"starship"}, platform.NewPacman(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)
}
sawInstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
sawInstall = true
}
}
if !sawInstall {
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
}
}
func TestVerifyAfterApply(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"starship": true}}
m := New([]string{"starship"}, platform.NewPacman(r))
if err := m.Verify(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestPlanForceReinstallsAllPackages(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"starship": true}}
m := New([]string{"starship"}, platform.NewPacman(r))
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
sawReinstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
sawReinstall = true
}
}
if !sawReinstall {
t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,159 @@
// Package themes installs archduino theme files into ~/.config/archduino/themes.
package themes
import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
type Module struct{ cfg config.ThemesConfig }
func New(cfg config.ThemesConfig) Module { return Module{cfg: cfg} }
type filePair struct{ src, dst string }
type state struct{ Missing []filePair }
func (Module) Meta() module.Meta {
return module.Meta{ID: "themes", Name: "Colorscheme themes", Description: "Install archduino themes."}
}
func destRoot() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".config", "archduino", "themes"), nil
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
dst, err := destRoot()
if err != nil {
return nil, err
}
var missing []filePair
err = filepath.WalkDir(m.cfg.Source, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(m.cfg.Source, p)
target := filepath.Join(dst, rel)
a, errA := os.ReadFile(p)
if errA != nil {
return errA
}
b, errB := os.ReadFile(target)
if errB != nil || !bytes.Equal(a, b) {
missing = append(missing, filePair{src: p, dst: target})
}
return nil
})
if err != nil {
if os.IsNotExist(err) {
return state{}, nil
}
return nil, err
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
pairs := append([]filePair{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: fmt.Sprintf("copy %d theme files", len(pairs)),
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
for _, fp := range pairs {
if err := os.MkdirAll(filepath.Dir(fp.dst), 0o755); err != nil {
return err
}
data, err := os.ReadFile(fp.src)
if err != nil {
return err
}
if err := os.WriteFile(fp.dst, data, 0o644); err != nil {
return err
}
}
return nil
},
}}}, 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
}
if miss := st.(state).Missing; len(miss) > 0 {
return fmt.Errorf("themes still missing: %d files", len(miss))
}
return nil
}
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
dst, err := destRoot()
if err != nil {
return module.Plan{}, err
}
var pairs []filePair
err = filepath.WalkDir(m.cfg.Source, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(m.cfg.Source, p)
pairs = append(pairs, filePair{src: p, dst: filepath.Join(dst, rel)})
return nil
})
if err != nil && !os.IsNotExist(err) {
return module.Plan{}, err
}
if len(pairs) == 0 {
return module.Plan{}, nil
}
return module.Plan{Actions: []module.Action{{
Description: fmt.Sprintf("rewrite %d theme files (force)", len(pairs)),
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
for _, fp := range pairs {
if err := os.MkdirAll(filepath.Dir(fp.dst), 0o755); err != nil {
return err
}
data, err := os.ReadFile(fp.src)
if err != nil {
return err
}
if err := os.WriteFile(fp.dst, data, 0o644); err != nil {
return err
}
}
return nil
},
}}}, nil
}

View File

@@ -0,0 +1,111 @@
package themes
import (
"context"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func TestThemesCopiesFiles(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "one.toml"), []byte("A"), 0o644); err != nil {
t.Fatal(err)
}
os.MkdirAll(filepath.Join(src, "sub"), 0o755)
os.WriteFile(filepath.Join(src, "sub", "two.toml"), []byte("B"), 0o644)
home := t.TempDir()
t.Setenv("HOME", home)
m := New(config.ThemesConfig{Source: src})
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "one.toml"))
if string(got) != "A" {
t.Fatalf("one.toml: %q", got)
}
got, _ = os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "sub", "two.toml"))
if string(got) != "B" {
t.Fatalf("two.toml: %q", got)
}
}
func TestThemesIdempotent(t *testing.T) {
src := t.TempDir()
os.WriteFile(filepath.Join(src, "x.toml"), []byte("X"), 0o644)
home := t.TempDir()
t.Setenv("HOME", home)
m := New(config.ThemesConfig{Source: src})
_ = applyOnce(t, m)
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if !p.IsEmpty() {
t.Fatalf("second plan not empty: %+v", p.Actions)
}
}
func applyOnce(t *testing.T, m Module) error {
t.Helper()
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
return m.Apply(context.Background(), p, nop{})
}
func TestPlanForceRewritesAllThemes(t *testing.T) {
src := t.TempDir()
os.WriteFile(filepath.Join(src, "one.toml"), []byte("FRESH-A"), 0o644)
os.MkdirAll(filepath.Join(src, "sub"), 0o755)
os.WriteFile(filepath.Join(src, "sub", "two.toml"), []byte("FRESH-B"), 0o644)
home := t.TempDir()
t.Setenv("HOME", home)
m := New(config.ThemesConfig{Source: src})
dst := filepath.Join(home, ".config", "archduino", "themes")
os.MkdirAll(filepath.Join(dst, "sub"), 0o755)
os.WriteFile(filepath.Join(dst, "one.toml"), []byte("FRESH-A"), 0o644)
os.WriteFile(filepath.Join(dst, "sub", "two.toml"), []byte("FRESH-B"), 0o644)
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if !p.IsEmpty() {
t.Fatalf("precondition: plan should be empty, got %+v", p.Actions)
}
os.WriteFile(filepath.Join(dst, "one.toml"), []byte("STALE"), 0o644)
os.WriteFile(filepath.Join(dst, "sub", "two.toml"), []byte("STALE"), 0o644)
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
got, _ := os.ReadFile(filepath.Join(dst, "one.toml"))
if string(got) != "FRESH-A" {
t.Fatalf("one.toml not rewritten: %q", got)
}
got, _ = os.ReadFile(filepath.Join(dst, "sub", "two.toml"))
if string(got) != "FRESH-B" {
t.Fatalf("two.toml not rewritten: %q", got)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,86 @@
// Package yazi installs a declarative list of pacman packages.
package yazi
import (
"context"
"fmt"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
pkgs []string
pm platform.PackageManager
}
func New(pkgs []string, pm platform.PackageManager) Module {
return Module{pkgs: pkgs, pm: pm}
}
type state struct{ Missing []string }
func (Module) Meta() module.Meta {
return module.Meta{ID: "yazi", Name: "Yazi file manager", Description: "Install yazi."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var missing []string
for _, p := range m.pkgs {
ok, err := m.pm.IsInstalled(ctx, p)
if err != nil {
return nil, err
}
if !ok {
missing = append(missing, p)
}
}
return state{Missing: missing}, nil
}
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state)
if len(s.Missing) == 0 {
return module.Plan{}, nil
}
missing := append([]string{}, s.Missing...)
return module.Plan{Actions: []module.Action{{
Description: "pacman install " + strings.Join(missing, ", "),
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
}}}, 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
}
if missing := st.(state).Missing; len(missing) > 0 {
return fmt.Errorf("packages not installed after apply: %v", missing)
}
return nil
}
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
}

View File

@@ -0,0 +1,139 @@
package yazi
import (
"context"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
outCalls [][]string
installed map[string]bool // key: pkg name
}
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(args[1] + " 1.0-1\n"), nil
}
return nil, errExitOne
}
return nil, nil
}
type exitOneErr struct{}
func (exitOneErr) Error() string { return "exit status 1" }
var errExitOne = exitOneErr{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestDetectAbsent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"yazi"}, platform.NewPacman(r))
st, err := m.Detect(context.Background())
if err != nil {
t.Fatal(err)
}
s := st.(state)
if len(s.Missing) != 1 {
t.Fatalf("want 1 missing, got %v", s.Missing)
}
}
func TestDetectPresent(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
m := New([]string{"yazi"}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
if len(st.(state).Missing) != 0 {
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
}
}
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
m := New([]string{"yazi"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: []string{"yazi"}})
if len(p.Actions) != 1 {
t.Fatalf("want 1 action, got %d", len(p.Actions))
}
if !strings.Contains(p.Actions[0].Description, "yazi") {
t.Fatalf("desc: %s", p.Actions[0].Description)
}
}
func TestPlanPresentEmpty(t *testing.T) {
m := New([]string{"yazi"}, nil)
p, _ := m.Plan(context.Background(), state{Missing: nil})
if !p.IsEmpty() {
t.Fatal("want empty plan")
}
}
func TestApplyShellsOut(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{}}
m := New([]string{"yazi"}, platform.NewPacman(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)
}
sawInstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
sawInstall = true
}
}
if !sawInstall {
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
}
}
func TestVerifyAfterApply(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
m := New([]string{"yazi"}, platform.NewPacman(r))
if err := m.Verify(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestPlanForceReinstallsAllPackages(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
m := New([]string{"yazi"}, platform.NewPacman(r))
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
sawReinstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
sawReinstall = true
}
}
if !sawReinstall {
t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls)
}
}
var _ module.Module = Module{}

View File

@@ -0,0 +1,135 @@
// Package zellij installs zellij and optionally writes a remote config.
package zellij
import (
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
cfg config.ZellijConfig
pm platform.PackageManager
}
func New(cfg config.ZellijConfig, pm platform.PackageManager) Module {
return Module{cfg: cfg, pm: pm}
}
type state struct {
Installed bool
ConfigOK bool
ConfigPath string
}
func (Module) Meta() module.Meta {
return module.Meta{ID: "zellij", Name: "Zellij terminal multiplexer", Description: "Install zellij and config."}
}
func configPath() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".config", "zellij", "config.kdl"), nil
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var s state
ok, err := m.pm.IsInstalled(ctx, "zellij")
if err != nil {
return nil, err
}
s.Installed = ok
p, err := configPath()
if err != nil {
return nil, err
}
s.ConfigPath = p
if m.cfg.ConfigURL == "" {
s.ConfigOK = true
} else if _, err := os.Stat(p); err == nil {
s.ConfigOK = 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.Installed {
actions = append(actions, module.Action{
Description: "pacman install zellij",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zellij"}) },
})
}
if !s.ConfigOK {
actions = append(actions, module.Action{
Description: "download zellij config",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(s.ConfigPath), 0o755); err != nil {
return err
}
return platform.Download(ctx, m.cfg.ConfigURL, s.ConfigPath, 0o644)
},
})
}
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.Installed {
return fmt.Errorf("zellij not installed")
}
if !s.ConfigOK {
return fmt.Errorf("zellij config missing")
}
return nil
}
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
actions := []module.Action{{
Description: "pacman -S (force) zellij",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"zellij"}) },
}}
if m.cfg.ConfigURL != "" {
p, err := configPath()
if err != nil {
return module.Plan{}, err
}
actions = append(actions, module.Action{
Description: "redownload zellij config (force)",
Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
return platform.Download(ctx, m.cfg.ConfigURL, p, 0o644)
},
})
}
return module.Plan{Actions: actions}, nil
}

View File

@@ -0,0 +1,125 @@
package zellij
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
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, errOne
}
return nil, nil
}
type eo struct{}
func (eo) Error() string { return "exit 1" }
var errOne = eo{}
type nop struct{}
func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {}
func TestZellijPlanAppliesInstallAndConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("keybinds {}\n"))
}))
defer srv.Close()
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.ZellijConfig{ConfigURL: srv.URL}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nop{}); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(home, ".config", "zellij", "config.kdl"))
if err != nil {
t.Fatalf("config not written: %v", err)
}
if string(got) != "keybinds {}\n" {
t.Fatalf("unexpected content %q", got)
}
}
func TestZellijInstalledPresentEmptyPlan(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{"zellij": true}}
m := New(config.ZellijConfig{ConfigURL: ""}, platform.NewPacman(r))
st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st)
if !p.IsEmpty() {
t.Fatalf("want empty plan, got %+v", p)
}
}
func TestPlanForceReinstallsAndRewritesConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".config", "zellij"), 0o755)
os.WriteFile(filepath.Join(home, ".config", "zellij", "config.kdl"), []byte("old\n"), 0o644)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("fresh {}\n"))
}))
defer srv.Close()
r := &fakeRunner{installed: map[string]bool{"zellij": true}}
m := New(config.ZellijConfig{ConfigURL: srv.URL}, platform.NewPacman(r))
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
sawReinstall := false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
sawReinstall = true
}
}
if !sawReinstall {
t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls)
}
got, err := os.ReadFile(filepath.Join(home, ".config", "zellij", "config.kdl"))
if err != nil {
t.Fatalf("config not written: %v", err)
}
if string(got) != "fresh {}\n" {
t.Fatalf("config not overwritten: %q", got)
}
}
var _ module.Module = Module{}

173
internal/modules/zsh/zsh.go Normal file
View File

@@ -0,0 +1,173 @@
// Package zsh installs zsh, oh-my-zsh, and configured plugins.
package zsh
import (
"context"
"fmt"
"os"
"path/filepath"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type Module struct {
cfg config.ZshConfig
pm platform.PackageManager
git platform.GitClient
}
func New(cfg config.ZshConfig, pm platform.PackageManager, git platform.GitClient) Module {
return Module{cfg: cfg, pm: pm, git: git}
}
type state struct {
ZshInstalled bool
OMZInstalled bool
MissingPlugins []config.ZshPlugin
}
func omzHome() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".oh-my-zsh"), nil
}
func (Module) Meta() module.Meta {
return module.Meta{ID: "zsh", Name: "Zsh + oh-my-zsh", Description: "Install zsh, oh-my-zsh, and plugins."}
}
func (m Module) Detect(ctx context.Context) (module.State, error) {
var s state
ok, err := m.pm.IsInstalled(ctx, "zsh")
if err != nil {
return nil, err
}
s.ZshInstalled = ok
omz, err := omzHome()
if err != nil {
return nil, err
}
if _, err := os.Stat(omz); err == nil {
s.OMZInstalled = true
}
for _, p := range m.cfg.Plugins {
path := filepath.Join(omz, "custom", "plugins", p.Name, ".git")
if _, err := os.Stat(path); err != nil {
s.MissingPlugins = append(s.MissingPlugins, p)
}
}
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.ZshInstalled {
actions = append(actions, module.Action{
Description: "pacman install zsh",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zsh"}) },
})
}
if !s.OMZInstalled {
actions = append(actions, module.Action{
Description: "clone oh-my-zsh from " + m.cfg.OhMyZshRepo,
Kind: module.ActionGitClone,
Exec: func(ctx context.Context) error {
omz, err := omzHome()
if err != nil {
return err
}
return m.git.EnsureRepo(ctx, m.cfg.OhMyZshRepo, omz)
},
})
}
for _, p := range s.MissingPlugins {
p := p // capture
actions = append(actions, module.Action{
Description: "clone plugin " + p.Name,
Kind: module.ActionGitClone,
Exec: func(ctx context.Context) error {
omz, err := omzHome()
if err != nil {
return err
}
return m.git.EnsureRepo(ctx, p.Repo, filepath.Join(omz, "custom", "plugins", p.Name))
},
})
}
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.ZshInstalled {
return fmt.Errorf("zsh not installed")
}
if !s.OMZInstalled {
return fmt.Errorf("oh-my-zsh not installed")
}
if len(s.MissingPlugins) > 0 {
return fmt.Errorf("plugins missing: %v", s.MissingPlugins)
}
return nil
}
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
actions := []module.Action{{
Description: "pacman -S (force) zsh",
Kind: module.ActionPackageInstall,
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"zsh"}) },
}}
actions = append(actions, module.Action{
Description: "reclone oh-my-zsh from " + m.cfg.OhMyZshRepo + " (force)",
Kind: module.ActionGitClone,
Exec: func(ctx context.Context) error {
omz, err := omzHome()
if err != nil {
return err
}
if err := os.RemoveAll(omz); err != nil {
return err
}
return m.git.Clone(ctx, m.cfg.OhMyZshRepo, omz)
},
})
for _, p := range m.cfg.Plugins {
p := p
actions = append(actions, module.Action{
Description: "reclone plugin " + p.Name + " (force)",
Kind: module.ActionGitClone,
Exec: func(ctx context.Context) error {
omz, err := omzHome()
if err != nil {
return err
}
dest := filepath.Join(omz, "custom", "plugins", p.Name)
if err := os.RemoveAll(dest); err != nil {
return err
}
return m.git.Clone(ctx, p.Repo, dest)
},
})
}
return module.Plan{Actions: actions}, nil
}

View File

@@ -0,0 +1,159 @@
package zsh
import (
"context"
"os"
"path/filepath"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
type fakeRunner struct {
runCalls [][]string
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, errOne
}
return nil, nil
}
type eo struct{}
func (eo) Error() string { return "exit 1" }
var errOne = eo{}
type nopReporter struct{}
func (nopReporter) Progress(string, int, int, string) {}
func (nopReporter) Log(string, string) {}
func TestZshDetectAbsent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.ZshConfig{
OhMyZshRepo: "https://x/omz.git",
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
}, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
s := st.(state)
if s.ZshInstalled {
t.Error("want zsh absent")
}
if s.OMZInstalled {
t.Error("want omz absent")
}
if len(s.MissingPlugins) != 1 {
t.Errorf("want 1 plugin missing, got %v", s.MissingPlugins)
}
}
func TestZshDetectAllPresent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0o755)
os.MkdirAll(filepath.Join(home, ".oh-my-zsh", "custom", "plugins", "zsh-autosuggestions", ".git"), 0o755)
r := &fakeRunner{installed: map[string]bool{"zsh": true}}
m := New(config.ZshConfig{
OhMyZshRepo: "https://x/omz.git",
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
}, platform.NewPacman(r), platform.GitClient{Runner: r})
st, _ := m.Detect(context.Background())
s := st.(state)
if !s.ZshInstalled || !s.OMZInstalled || len(s.MissingPlugins) != 0 {
t.Fatalf("want all present, got %+v", s)
}
}
func TestZshPlanApplyInstallsEverything(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}}
m := New(config.ZshConfig{
OhMyZshRepo: "https://x/omz.git",
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
}, 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("want non-empty plan")
}
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err)
}
sawPacman, sawOMZ, sawPlugin := false, false, false
for _, c := range r.runCalls {
if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" {
sawPacman = true
}
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/omz.git" {
sawOMZ = true
}
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/a.git" {
sawPlugin = true
}
}
if !sawPacman || !sawOMZ || !sawPlugin {
t.Fatalf("missing step; calls=%v", r.runCalls)
}
}
func TestPlanForceReinstallsEverything(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".oh-my-zsh", ".git"), 0o755)
os.MkdirAll(filepath.Join(home, ".oh-my-zsh", "custom", "plugins", "zsh-autosuggestions", ".git"), 0o755)
r := &fakeRunner{installed: map[string]bool{"zsh": true}}
m := New(config.ZshConfig{
OhMyZshRepo: "https://x/omz.git",
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
}, platform.NewPacman(r), platform.GitClient{Runner: r})
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")
}
for _, a := range plan.Actions {
if err := a.Exec(context.Background()); err != nil {
t.Fatalf("Exec: %v", err)
}
}
sawReinstall, sawOMZ, sawPlugin := false, false, false
for _, c := range r.runCalls {
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
sawReinstall = true
}
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/omz.git" {
sawOMZ = true
}
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/a.git" {
sawPlugin = true
}
}
if !sawReinstall || !sawOMZ || !sawPlugin {
t.Fatalf("missing force action; calls=%v", r.runCalls)
}
if _, err := os.Stat(filepath.Join(home, ".oh-my-zsh")); !os.IsNotExist(err) {
t.Fatalf("oh-my-zsh dir should have been removed; stat err=%v", err)
}
}
var _ module.Module = Module{}

69
internal/platform/aur.go Normal file
View File

@@ -0,0 +1,69 @@
package platform
import (
"context"
"os"
"path/filepath"
"strings"
)
// AURHelper abstracts an AUR front-end (currently only yay).
type AURHelper interface {
EnsureInstalled(ctx context.Context) error
Install(ctx context.Context, pkgs []string) error
Reinstall(ctx context.Context, pkgs []string) error
}
type Yay struct {
Runner Runner
pm *Pacman
}
func NewYay(r Runner) *Yay { return &Yay{Runner: r, pm: NewPacman(r)} }
func (y *Yay) EnsureInstalled(ctx context.Context) error {
ok, _ := y.pm.IsInstalled(ctx, "yay")
if ok {
return nil
}
// Bootstrap: install build deps, clone AUR yay, makepkg -si.
if err := y.pm.Install(ctx, []string{"base-devel", "git"}); err != nil {
return err
}
tmp, err := os.MkdirTemp("", "archduino-yay-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
clone := filepath.Join(tmp, "yay")
if err := y.Runner.Run(ctx, "git", "clone", "https://aur.archlinux.org/yay.git", clone); err != nil {
return err
}
// `makepkg -si --noconfirm` needs to cd into the cloned dir. os/exec does
// not have a per-command cwd without exec.Cmd; our Runner interface does
// not expose that, so we invoke via sh -c with a pushd-style prefix.
return y.Runner.Run(ctx, "sh", "-c", "cd "+shellQuote(clone)+" && makepkg -si --noconfirm")
}
func (y *Yay) Install(ctx context.Context, pkgs []string) error {
if len(pkgs) == 0 {
return nil
}
args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...)
return y.Runner.Run(ctx, "yay", args...)
}
// Reinstall runs `yay -S --noconfirm <pkgs...>` WITHOUT `--needed`, forcing
// yay/pacman to re-fetch and reinstall the packages even if already present.
// Used by the `--force` flag to bypass idempotency.
func (y *Yay) Reinstall(ctx context.Context, pkgs []string) error {
if len(pkgs) == 0 {
return nil
}
args := append([]string{"-S", "--noconfirm"}, pkgs...)
return y.Runner.Run(ctx, "yay", args...)
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@@ -0,0 +1,115 @@
package platform
import (
"context"
"strings"
"testing"
)
type aurFakeRunner struct {
runCalls [][]string
outCalls [][]string
// yayInstalled toggles what `pacman -Q yay` returns.
yayInstalled bool
}
func (f *aurFakeRunner) Run(_ context.Context, name string, args ...string) error {
f.runCalls = append(f.runCalls, append([]string{name}, args...))
return nil
}
func (f *aurFakeRunner) 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" && args[1] == "yay" {
if f.yayInstalled {
return []byte("yay 12.0-1\n"), nil
}
return nil, &fakeExitErr{}
}
return nil, nil
}
type fakeExitErr struct{}
func (fakeExitErr) Error() string { return "exit 1" }
func TestAUREnsureInstalledSkipsWhenYayPresent(t *testing.T) {
r := &aurFakeRunner{yayInstalled: true}
y := NewYay(r)
if err := y.EnsureInstalled(context.Background()); err != nil {
t.Fatal(err)
}
if len(r.runCalls) != 0 {
t.Fatalf("no installs expected, got %v", r.runCalls)
}
}
func TestAUREnsureInstalledBootstrapsYay(t *testing.T) {
r := &aurFakeRunner{yayInstalled: false}
y := NewYay(r)
if err := y.EnsureInstalled(context.Background()); err != nil {
t.Fatal(err)
}
// Expect: pacman install base-devel+git, git clone yay, makepkg -si in that dir.
sawPacman := false
sawClone := false
sawMakepkg := false
for _, c := range r.runCalls {
if len(c) >= 2 && c[0] == "sudo" && c[1] == "pacman" {
sawPacman = true
}
if len(c) >= 2 && c[0] == "git" && c[1] == "clone" {
sawClone = true
}
if len(c) >= 3 && c[0] == "sh" && c[1] == "-c" && strings.Contains(c[2], "makepkg") {
sawMakepkg = true
}
}
if !sawPacman || !sawClone || !sawMakepkg {
t.Fatalf("missing bootstrap step; calls=%v", r.runCalls)
}
}
func TestYayReinstall(t *testing.T) {
r := &aurFakeRunner{yayInstalled: true}
y := NewYay(r)
if err := y.Reinstall(context.Background(), []string{"git", "curl"}); err != nil {
t.Fatal(err)
}
if len(r.runCalls) == 0 {
t.Fatal("expected yay reinstall call")
}
c := r.runCalls[len(r.runCalls)-1]
want := []string{"yay", "-S", "--noconfirm", "git", "curl"}
if len(c) != len(want) {
t.Fatalf("call = %v, want %v", c, want)
}
for i, w := range want {
if c[i] != w {
t.Fatalf("call = %v, want %v", c, want)
}
}
for _, a := range c {
if a == "--needed" {
t.Fatalf("Reinstall must not pass --needed; got %v", c)
}
}
}
func TestAURInstall(t *testing.T) {
r := &aurFakeRunner{yayInstalled: true}
y := NewYay(r)
if err := y.Install(context.Background(), []string{"ttf-jetbrains-mono-nerd"}); err != nil {
t.Fatal(err)
}
if len(r.runCalls) == 0 {
t.Fatal("expected yay install call")
}
c := r.runCalls[len(r.runCalls)-1]
want := []string{"yay", "-S", "--needed", "--noconfirm", "ttf-jetbrains-mono-nerd"}
for i, w := range want {
if i >= len(c) || c[i] != w {
t.Fatalf("call = %v, want %v", c, want)
}
}
}

View File

@@ -0,0 +1,42 @@
package platform
import (
"context"
"fmt"
"io"
"net/http"
"os"
)
// Download fetches url into destPath atomically (temp file + rename) with
// the given file mode. Any non-2xx response returns an error and leaves
// no file behind.
func Download(ctx context.Context, url, destPath string, mode os.FileMode) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("download %s: http %d", url, resp.StatusCode)
}
tmp := destPath + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, destPath)
}

View File

@@ -0,0 +1,45 @@
package platform
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestDownloadHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("payload"))
}))
defer srv.Close()
dir := t.TempDir()
dest := filepath.Join(dir, "out.bin")
if err := Download(context.Background(), srv.URL, dest, 0o755); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(dest)
if string(got) != "payload" {
t.Fatalf("got %q want payload", got)
}
info, _ := os.Stat(dest)
if info.Mode().Perm() != 0o755 {
t.Fatalf("mode=%v want 0755", info.Mode().Perm())
}
}
func TestDownload404Errors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusNotFound)
}))
defer srv.Close()
dir := t.TempDir()
dest := filepath.Join(dir, "out.bin")
if err := Download(context.Background(), srv.URL, dest, 0o644); err == nil {
t.Fatal("want error on 404")
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatal("dest should not exist after failed download")
}
}

View File

@@ -0,0 +1,114 @@
package platform
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func safeJoin(dest, name string) (string, error) {
p := filepath.Join(dest, name)
cleanDest := filepath.Clean(dest) + string(os.PathSeparator)
if !strings.HasPrefix(filepath.Clean(p)+string(os.PathSeparator), cleanDest) {
return "", fmt.Errorf("unsafe path %q escapes %q", name, dest)
}
return p, nil
}
func ExtractTarGz(ctx context.Context, archivePath, destDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
if err := ctx.Err(); err != nil {
return err
}
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target, err := safeJoin(destDir, h.Name)
if err != nil {
return err
}
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(h.Mode)); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
if err != nil {
return err
}
if _, err := io.Copy(w, tr); err != nil {
w.Close()
return err
}
w.Close()
}
}
return nil
}
func ExtractZip(ctx context.Context, archivePath, destDir string) error {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer zr.Close()
for _, zf := range zr.File {
if err := ctx.Err(); err != nil {
return err
}
target, err := safeJoin(destDir, zf.Name)
if err != nil {
return err
}
if zf.FileInfo().IsDir() {
if err := os.MkdirAll(target, zf.Mode()); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := zf.Open()
if err != nil {
return err
}
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode())
if err != nil {
rc.Close()
return err
}
_, err = io.Copy(w, rc)
rc.Close()
w.Close()
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,80 @@
package platform
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"os"
"path/filepath"
"testing"
)
func makeTarGz(t *testing.T, entries map[string]string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "a.tar.gz")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
for name, body := range entries {
tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))})
tw.Write([]byte(body))
}
tw.Close()
gz.Close()
f.Close()
return p
}
func makeZip(t *testing.T, entries map[string]string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "a.zip")
f, _ := os.Create(p)
zw := zip.NewWriter(f)
for name, body := range entries {
w, _ := zw.Create(name)
w.Write([]byte(body))
}
zw.Close()
f.Close()
return p
}
func TestExtractTarGz(t *testing.T) {
src := makeTarGz(t, map[string]string{"hello.txt": "world", "sub/x.txt": "y"})
dest := t.TempDir()
if err := ExtractTarGz(context.Background(), src, dest); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(dest, "hello.txt"))
if string(got) != "world" {
t.Fatalf("got %q", got)
}
got, _ = os.ReadFile(filepath.Join(dest, "sub", "x.txt"))
if string(got) != "y" {
t.Fatalf("got %q", got)
}
}
func TestExtractZip(t *testing.T) {
src := makeZip(t, map[string]string{"a.txt": "A"})
dest := t.TempDir()
if err := ExtractZip(context.Background(), src, dest); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(dest, "a.txt"))
if string(got) != "A" {
t.Fatalf("got %q", got)
}
}
func TestExtractRejectsPathTraversal(t *testing.T) {
src := makeTarGz(t, map[string]string{"../evil.txt": "x"})
dest := t.TempDir()
if err := ExtractTarGz(context.Background(), src, dest); err == nil {
t.Fatal("want error on path traversal")
}
}

23
internal/platform/git.go Normal file
View File

@@ -0,0 +1,23 @@
package platform
import (
"context"
"os"
"path/filepath"
)
// GitClient wraps `git` via a Runner.
type GitClient struct{ Runner Runner }
// Clone always runs `git clone --depth=1 url dest`.
func (g GitClient) Clone(ctx context.Context, url, dest string) error {
return g.Runner.Run(ctx, "git", "clone", "--depth=1", url, dest)
}
// EnsureRepo clones url into dest if dest/.git is missing; otherwise noop.
func (g GitClient) EnsureRepo(ctx context.Context, url, dest string) error {
if _, err := os.Stat(filepath.Join(dest, ".git")); err == nil {
return nil
}
return g.Clone(ctx, url, dest)
}

View File

@@ -0,0 +1,55 @@
package platform
import (
"context"
"os"
"path/filepath"
"testing"
)
type gitFakeRunner struct {
calls [][]string
err error
}
func (f *gitFakeRunner) Run(_ context.Context, name string, args ...string) error {
f.calls = append(f.calls, append([]string{name}, args...))
return f.err
}
func (f *gitFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
return nil, nil
}
func TestGitEnsureRepoClonesWhenMissing(t *testing.T) {
r := &gitFakeRunner{}
g := GitClient{Runner: r}
dest := filepath.Join(t.TempDir(), "repo")
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
t.Fatal(err)
}
if len(r.calls) != 1 {
t.Fatalf("want 1 call, got %d: %v", len(r.calls), r.calls)
}
got := r.calls[0]
want := []string{"git", "clone", "--depth=1", "https://x/y.git", dest}
for i := range want {
if i >= len(got) || got[i] != want[i] {
t.Fatalf("call = %v, want %v", got, want)
}
}
}
func TestGitEnsureRepoSkipsWhenPresent(t *testing.T) {
dest := t.TempDir()
if err := os.MkdirAll(filepath.Join(dest, ".git"), 0o755); err != nil {
t.Fatal(err)
}
r := &gitFakeRunner{}
g := GitClient{Runner: r}
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
t.Fatal(err)
}
if len(r.calls) != 0 {
t.Fatalf("expected no calls, got %v", r.calls)
}
}

View File

@@ -0,0 +1,96 @@
package platform
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
// ExecRunner runs commands via os/exec. This is the production Runner.
type ExecRunner struct{}
func (ExecRunner) Run(ctx context.Context, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
var stderr bytes.Buffer
cmd.Stdout = nil
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg)
}
return nil
}
func (ExecRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, stderr.String())
}
return out, nil
}
// Pacman implements PackageManager via `pacman` on Arch.
type Pacman struct {
Runner Runner
}
func NewPacman(r Runner) *Pacman {
if r == nil {
r = ExecRunner{}
}
return &Pacman{Runner: r}
}
func (Pacman) Name() string { return "pacman" }
// IsInstalled returns true when `pacman -Q <pkg>` succeeds.
func (p Pacman) IsInstalled(ctx context.Context, pkg string) (bool, error) {
if pkg == "" {
return false, errors.New("empty package name")
}
_, err := p.Runner.Output(ctx, "pacman", "-Q", pkg)
if err == nil {
return true, nil
}
// pacman -Q exits 1 when not installed; we treat any error as "not installed"
// because false-positives on transient errors here are harmless (Install
// is idempotent via --needed).
return false, nil
}
// Install runs `sudo pacman -S --needed --noconfirm <pkgs...>`.
func (p Pacman) Install(ctx context.Context, pkgs []string) error {
if len(pkgs) == 0 {
return nil
}
args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, pkgs...)
return p.Runner.Run(ctx, "sudo", args...)
}
// Reinstall runs `sudo pacman -S --noconfirm <pkgs...>` WITHOUT `--needed`,
// so pacman physically re-fetches and reinstalls the packages even if they
// are already present. Used by the `--force` flag to bypass idempotency.
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.Run(ctx, "sudo", args...)
}
// Update runs `sudo pacman -Syu --noconfirm` (full sync + system upgrade).
// On Arch, a bare `pacman -Sy` followed by an install is the partial-upgrade
// anti-pattern that can break the system when a dep's ABI changes, so we
// always pair the sync with the upgrade.
func (p Pacman) Update(ctx context.Context) error {
return p.Runner.Run(ctx, "sudo", "pacman", "-Syu", "--noconfirm")
}

View File

@@ -0,0 +1,108 @@
package platform
import (
"context"
"errors"
"reflect"
"testing"
)
type fakeRunner struct {
runCalls [][]string
outputCalls [][]string
outputErr error
output []byte
}
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.outputCalls = append(f.outputCalls, append([]string{name}, args...))
return f.output, f.outputErr
}
func TestPacmanInstallShellsOutCorrectly(t *testing.T) {
r := &fakeRunner{}
p := NewPacman(r)
if err := p.Install(context.Background(), []string{"tio", "git"}); err != nil {
t.Fatal(err)
}
if len(r.runCalls) != 1 {
t.Fatalf("run calls = %d", len(r.runCalls))
}
want := []string{"sudo", "pacman", "-S", "--needed", "--noconfirm", "tio", "git"}
if !reflect.DeepEqual(r.runCalls[0], want) {
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
}
}
func TestPacmanReinstall(t *testing.T) {
r := &fakeRunner{}
p := NewPacman(r)
if err := p.Reinstall(context.Background(), []string{"git", "curl"}); err != nil {
t.Fatal(err)
}
if len(r.runCalls) != 1 {
t.Fatalf("run calls = %d", len(r.runCalls))
}
got := r.runCalls[0]
want := []string{"sudo", "pacman", "-S", "--noconfirm", "git", "curl"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("args = %v, want %v", got, want)
}
for _, a := range got {
if a == "--needed" {
t.Fatalf("Reinstall must not pass --needed; got %v", got)
}
}
}
func TestPacmanInstallEmptyIsNoop(t *testing.T) {
r := &fakeRunner{}
p := NewPacman(r)
if err := p.Install(context.Background(), nil); err != nil {
t.Fatal(err)
}
if len(r.runCalls) != 0 {
t.Fatalf("expected no calls, got %v", r.runCalls)
}
}
func TestPacmanIsInstalledTrue(t *testing.T) {
r := &fakeRunner{output: []byte("tio 3.2-1")}
p := NewPacman(r)
ok, err := p.IsInstalled(context.Background(), "tio")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("want installed=true")
}
}
func TestPacmanIsInstalledFalseOnError(t *testing.T) {
r := &fakeRunner{outputErr: errors.New("not found")}
p := NewPacman(r)
ok, err := p.IsInstalled(context.Background(), "ghost")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("want installed=false")
}
}
func TestPacmanUpdate(t *testing.T) {
r := &fakeRunner{}
p := NewPacman(r)
if err := p.Update(context.Background()); err != nil {
t.Fatal(err)
}
want := []string{"sudo", "pacman", "-Syu", "--noconfirm"}
if !reflect.DeepEqual(r.runCalls[0], want) {
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
}
}

View File

@@ -0,0 +1,20 @@
package platform
import "context"
// PackageManager abstracts OS-level package operations so later plans
// can add Debian/Fedora without touching modules.
type PackageManager interface {
Name() string
IsInstalled(ctx context.Context, pkg string) (bool, error)
Install(ctx context.Context, pkgs []string) error
Reinstall(ctx context.Context, pkgs []string) error
Update(ctx context.Context) error
}
// Runner executes a command with arguments. Injected into package managers
// so tests can stub shell-outs.
type Runner interface {
Run(ctx context.Context, name string, args ...string) error
Output(ctx context.Context, name string, args ...string) ([]byte, error)
}

View File

@@ -0,0 +1,63 @@
// Package platform detects the host OS/distro/WSL status.
package platform
import (
"bufio"
"fmt"
"io"
"os"
"runtime"
"strings"
)
// Distro is a narrow enum of distros we care about.
type Distro string
const (
DistroArch Distro = "arch"
DistroUnknown Distro = "unknown"
)
// Platform holds detected host information.
type Platform struct {
Distro Distro
IsWSL bool
Arch string // e.g. "amd64", "arm64"
}
// Detect reads the live system. Returns DistroUnknown rather than an error
// when /etc/os-release is missing or unrecognized; callers decide what to do.
func Detect() (Platform, error) {
f, err := os.Open("/etc/os-release")
distro := DistroUnknown
if err == nil {
distro = parseDistro(f)
_ = f.Close()
} else if !os.IsNotExist(err) {
return Platform{}, fmt.Errorf("open /etc/os-release: %w", err)
}
wsl := false
if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
lower := strings.ToLower(string(data))
wsl = strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
}
return Platform{Distro: distro, IsWSL: wsl, Arch: runtime.GOARCH}, nil
}
func parseDistro(r io.Reader) Distro {
sc := bufio.NewScanner(r)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "ID=") {
continue
}
id := strings.Trim(strings.TrimPrefix(line, "ID="), `"`)
if id == "arch" {
return DistroArch
}
return DistroUnknown
}
return DistroUnknown
}

View File

@@ -0,0 +1,45 @@
package platform
import (
"strings"
"testing"
)
func TestParseDistroArch(t *testing.T) {
osRelease := `NAME="Arch Linux"
ID=arch
ID_LIKE=archlinux
`
if got := parseDistro(strings.NewReader(osRelease)); got != DistroArch {
t.Fatalf("got %v", got)
}
}
func TestParseDistroUbuntu(t *testing.T) {
osRelease := `NAME="Ubuntu"
ID=ubuntu
`
if got := parseDistro(strings.NewReader(osRelease)); got != DistroUnknown {
t.Fatalf("got %v", got)
}
}
func TestParseDistroQuoted(t *testing.T) {
osRelease := `ID="arch"
`
if got := parseDistro(strings.NewReader(osRelease)); got != DistroArch {
t.Fatalf("got %v", got)
}
}
func TestDetectReturnsArch(t *testing.T) {
p, err := Detect()
if err != nil {
t.Fatal(err)
}
if p.Arch == "" {
t.Fatal("arch is empty")
}
// We don't assert Distro here because the test host may not be Arch.
// The parse-level tests above cover that logic.
}

View File

@@ -0,0 +1,78 @@
package platform
import (
"fmt"
"os"
"strings"
)
func blockMarkers(marker string) (string, string) {
return fmt.Sprintf("# >>> %s >>>", marker), fmt.Sprintf("# <<< %s <<<", marker)
}
// EnsureBlock writes a marker-delimited block into path. If the file does
// not exist it is created. If a block with the same marker already exists
// it is replaced in place. Surrounding content is preserved.
func EnsureBlock(path, marker, content string) error {
start, end := blockMarkers(marker)
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
block := start + "\n" + strings.TrimRight(content, "\n") + "\n" + end + "\n"
text := string(existing)
si := strings.Index(text, start)
ei := strings.Index(text, end)
if si >= 0 && ei > si {
endPos := ei + len(end)
if endPos < len(text) && text[endPos] == '\n' {
endPos++
}
text = text[:si] + block + text[endPos:]
} else {
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
text += block
}
return os.WriteFile(path, []byte(text), 0o644)
}
// HasBlock reports whether path contains a block with the given marker.
// A missing file yields (false, nil).
func HasBlock(path, marker string) (bool, error) {
start, _ := blockMarkers(marker)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return strings.Contains(string(data), start), nil
}
// RemoveBlock deletes a marker-delimited block if present.
func RemoveBlock(path, marker string) error {
start, end := blockMarkers(marker)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
text := string(data)
si := strings.Index(text, start)
ei := strings.Index(text, end)
if si < 0 || ei <= si {
return nil
}
endPos := ei + len(end)
if endPos < len(text) && text[endPos] == '\n' {
endPos++
}
text = text[:si] + text[endPos:]
return os.WriteFile(path, []byte(text), 0o644)
}

View File

@@ -0,0 +1,88 @@
package platform
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureBlockInsertsIntoMissingFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
if err := EnsureBlock(p, "archduino", "export FOO=1"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(p)
want := "# >>> archduino >>>\nexport FOO=1\n# <<< archduino <<<\n"
if string(got) != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestEnsureBlockReplacesExisting(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
initial := "alias ll='ls -l'\n# >>> archduino >>>\nold content\n# <<< archduino <<<\necho tail\n"
if err := os.WriteFile(p, []byte(initial), 0o644); err != nil {
t.Fatal(err)
}
if err := EnsureBlock(p, "archduino", "new content"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(p)
want := "alias ll='ls -l'\n# >>> archduino >>>\nnew content\n# <<< archduino <<<\necho tail\n"
if string(got) != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestEnsureBlockIdempotent(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
_ = EnsureBlock(p, "a", "x")
before, _ := os.ReadFile(p)
_ = EnsureBlock(p, "a", "x")
after, _ := os.ReadFile(p)
if string(before) != string(after) {
t.Fatalf("not idempotent: %q vs %q", before, after)
}
}
func TestHasBlockTrueFalse(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
has, err := HasBlock(p, "a")
if err != nil {
t.Fatal(err)
}
if has {
t.Fatal("missing file should report has=false")
}
_ = os.WriteFile(p, []byte("no blocks here\n"), 0o644)
has, _ = HasBlock(p, "a")
if has {
t.Fatal("unblocked file should report has=false")
}
_ = EnsureBlock(p, "a", "x")
has, _ = HasBlock(p, "a")
if !has {
t.Fatal("blocked file should report has=true")
}
}
func TestRemoveBlock(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "rc")
_ = os.WriteFile(p, []byte("head\n# >>> a >>>\nmid\n# <<< a <<<\ntail\n"), 0o644)
if err := RemoveBlock(p, "a"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(p)
if strings.Contains(string(got), ">>> a >>>") {
t.Fatalf("block not removed: %q", got)
}
if !strings.Contains(string(got), "head") || !strings.Contains(string(got), "tail") {
t.Fatalf("surrounding content lost: %q", got)
}
}

View File

@@ -0,0 +1,25 @@
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
}

View File

@@ -0,0 +1,54 @@
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())
}
}

View File

@@ -0,0 +1,82 @@
// Package privilege manages sudo timestamp acquisition and keepalive so
// a long-running installer only prompts once.
package privilege
import (
"context"
"fmt"
"os"
"os/exec"
"time"
)
// Keepalive holds the lifetime of a background sudo-refresh goroutine.
type Keepalive struct {
cancel context.CancelFunc
done chan struct{}
}
// Stop terminates the keepalive goroutine. Safe to call multiple times.
func (k *Keepalive) Stop() {
if k == nil || k.cancel == nil {
return
}
k.cancel()
<-k.done
k.cancel = nil
}
// Acquirer abstracts the `sudo` binary so tests can inject a fake.
type Acquirer interface {
Validate(ctx context.Context) error // sudo -v (may prompt)
ValidateNonInteractive(ctx context.Context) error // sudo -n -v
}
// ExecSudo is the production Acquirer.
type ExecSudo struct{}
func (ExecSudo) Validate(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "sudo", "-v")
// Connect to the real terminal so sudo can prompt for the password.
// exec.Cmd.Stdin defaults to /dev/null, not the parent process's stdin.
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func (ExecSudo) ValidateNonInteractive(ctx context.Context) error {
return exec.CommandContext(ctx, "sudo", "-n", "-v").Run()
}
// Acquire runs `sudo -v` once (prompting if needed) and returns a Keepalive
// that refreshes the timestamp every `interval` using ValidateNonInteractive.
// If the initial Validate fails, no goroutine is started and the error is returned.
func Acquire(ctx context.Context, a Acquirer, interval time.Duration) (*Keepalive, error) {
if a == nil {
a = ExecSudo{}
}
if interval <= 0 {
interval = 50 * time.Second
}
if err := a.Validate(ctx); err != nil {
return nil, fmt.Errorf("sudo -v failed: %w", err)
}
kCtx, cancel := context.WithCancel(ctx)
k := &Keepalive{cancel: cancel, done: make(chan struct{})}
go func() {
defer close(k.done)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-kCtx.Done():
return
case <-t.C:
_ = a.ValidateNonInteractive(kCtx) // best-effort
}
}
}()
return k, nil
}

View File

@@ -0,0 +1,65 @@
package privilege
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
type fakeAcquirer struct {
validateErr error
validates int32
refreshes int32
}
func (f *fakeAcquirer) Validate(ctx context.Context) error {
atomic.AddInt32(&f.validates, 1)
return f.validateErr
}
func (f *fakeAcquirer) ValidateNonInteractive(ctx context.Context) error {
atomic.AddInt32(&f.refreshes, 1)
return nil
}
func TestAcquireFailsIfValidateFails(t *testing.T) {
a := &fakeAcquirer{validateErr: errors.New("wrong password")}
_, err := Acquire(context.Background(), a, 10*time.Millisecond)
if err == nil {
t.Fatal("expected error")
}
if atomic.LoadInt32(&a.refreshes) != 0 {
t.Fatal("keepalive ran despite Validate failure")
}
}
func TestAcquireStartsKeepaliveAndStops(t *testing.T) {
a := &fakeAcquirer{}
k, err := Acquire(context.Background(), a, 5*time.Millisecond)
if err != nil {
t.Fatal(err)
}
time.Sleep(25 * time.Millisecond)
k.Stop()
got := atomic.LoadInt32(&a.refreshes)
if got < 2 {
t.Fatalf("refreshes = %d, want >= 2", got)
}
// After Stop, no additional refreshes should occur.
snap := atomic.LoadInt32(&a.refreshes)
time.Sleep(20 * time.Millisecond)
if after := atomic.LoadInt32(&a.refreshes); after != snap {
t.Fatalf("refreshes continued after Stop: %d → %d", snap, after)
}
}
func TestStopIsIdempotent(t *testing.T) {
k, err := Acquire(context.Background(), &fakeAcquirer{}, 5*time.Millisecond)
if err != nil {
t.Fatal(err)
}
k.Stop()
k.Stop() // must not panic or hang
}

109
internal/state/state.go Normal file
View File

@@ -0,0 +1,109 @@
// Package state persists a per-run log used only for resume hints in the UI.
// Correctness always comes from live Detect() calls.
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// RunRecord is the JSON document written to runs/<ts>.json.
type RunRecord struct {
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
Modules []ModuleRecord `json:"modules"`
}
// ModuleRecord captures how one module fared.
type ModuleRecord struct {
ID string `json:"id"`
Status string `json:"status"` // succeeded|failed|skipped|planned
Duration string `json:"duration"`
Error string `json:"error,omitempty"`
At time.Time `json:"at"`
}
// Store is a file-backed run log.
type Store struct {
Root string // e.g. ~/.local/state/archduino
}
// DefaultStore returns a Store rooted at $XDG_STATE_HOME/archduino or
// ~/.local/state/archduino.
func DefaultStore() (*Store, error) {
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
return &Store{Root: filepath.Join(xdg, "archduino")}, nil
}
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
return &Store{Root: filepath.Join(home, ".local", "state", "archduino")}, nil
}
// Save writes record as runs/<ts>.json and updates last.json. Both paths are
// created if missing. Old runs beyond `keep` are deleted.
func (s *Store) Save(rec RunRecord, keep int) error {
if keep <= 0 {
keep = 10
}
runsDir := filepath.Join(s.Root, "runs")
if err := os.MkdirAll(runsDir, 0o755); err != nil {
return err
}
name := rec.StartedAt.UTC().Format("20060102T150405Z") + ".json"
path := filepath.Join(runsDir, name)
data, err := json.MarshalIndent(rec, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(s.Root, "last.json"), data, 0o644); err != nil {
return err
}
return rotate(runsDir, keep)
}
// LastRun reads last.json if present.
func (s *Store) LastRun() (RunRecord, bool, error) {
data, err := os.ReadFile(filepath.Join(s.Root, "last.json"))
if err != nil {
if os.IsNotExist(err) {
return RunRecord{}, false, nil
}
return RunRecord{}, false, err
}
var rec RunRecord
if err := json.Unmarshal(data, &rec); err != nil {
return RunRecord{}, false, fmt.Errorf("parse last.json: %w", err)
}
return rec, true, nil
}
func rotate(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
if len(names) <= keep {
return nil
}
sort.Strings(names) // lexicographic == chronological (UTC stamp)
for _, n := range names[:len(names)-keep] {
_ = os.Remove(filepath.Join(dir, n))
}
return nil
}

View File

@@ -0,0 +1,63 @@
package state
import (
"os"
"path/filepath"
"testing"
"time"
)
func newStore(t *testing.T) *Store {
t.Helper()
return &Store{Root: t.TempDir()}
}
func TestSaveAndLoadLast(t *testing.T) {
s := newStore(t)
rec := RunRecord{
StartedAt: time.Date(2026, 4, 13, 10, 0, 0, 0, time.UTC),
EndedAt: time.Date(2026, 4, 13, 10, 1, 0, 0, time.UTC),
Modules: []ModuleRecord{{ID: "hello", Status: "succeeded", Duration: "1s"}},
}
if err := s.Save(rec, 10); err != nil {
t.Fatal(err)
}
got, ok, err := s.LastRun()
if err != nil || !ok {
t.Fatalf("LastRun err=%v ok=%v", err, ok)
}
if len(got.Modules) != 1 || got.Modules[0].ID != "hello" {
t.Fatalf("modules = %+v", got.Modules)
}
}
func TestLastRunMissingReturnsFalse(t *testing.T) {
s := newStore(t)
_, ok, err := s.LastRun()
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected ok=false")
}
}
func TestRotateKeepsNewest(t *testing.T) {
s := newStore(t)
for i := 0; i < 12; i++ {
rec := RunRecord{
StartedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC),
EndedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC),
}
if err := s.Save(rec, 5); err != nil {
t.Fatal(err)
}
}
entries, err := os.ReadDir(filepath.Join(s.Root, "runs"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 5 {
t.Fatalf("runs count = %d, want 5", len(entries))
}
}

View File

@@ -0,0 +1,104 @@
// Package doctor reports module convergence and pinned-tool version drift.
package doctor
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
)
// Run inspects every module in the registry plus pinned tool versions and
// writes a human-readable report to w. Returns 0 when everything is
// converged with no drift; 1 otherwise. Never panics on module errors.
func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int {
var converged, pending, errs, drift int
for _, m := range reg.All() {
id := m.Meta().ID
st, err := m.Detect(ctx)
if err != nil {
fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err)
errs++
continue
}
pl, err := m.Plan(ctx, st)
if err != nil {
fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err)
errs++
continue
}
if pl.IsEmpty() {
fmt.Fprintf(w, "[✓] %s\n", id)
converged++
} else {
fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions))
pending++
}
}
drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w)
drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w)
fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs)
if pending > 0 || drift > 0 || errs > 0 {
return 1
}
return 0
}
type arduinoCLIVersion struct {
VersionString string `json:"VersionString"`
}
func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
if want == "" {
return 0
}
out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json")
if err != nil {
return 0 // not installed → silent skip
}
var v arduinoCLIVersion
if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" {
fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n")
return 1
}
if v.VersionString != want {
fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want)
return 1
}
fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString)
return 0
}
var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
if want == "" {
return 0
}
out, err := runner.Output(ctx, "wokwi-cli", "--version")
if err != nil {
return 0
}
m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out)))
if len(m) < 2 {
fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n")
return 1
}
got := m[1]
if got != want {
fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want)
return 1
}
fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got)
return 0
}

View File

@@ -0,0 +1,146 @@
package doctor
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
)
// convergedModule returns an empty Plan — always converged.
type convergedModule struct{ id string }
func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} }
func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil }
func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{}, nil
}
func (c convergedModule) PlanForce(context.Context) (module.Plan, error) {
return module.Plan{}, nil
}
func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (c convergedModule) Verify(context.Context) error { return nil }
type pendingModule struct{ id string }
func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} }
func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil }
func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
}
func (p pendingModule) PlanForce(context.Context) (module.Plan, error) {
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
}
func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (p pendingModule) Verify(context.Context) error { return nil }
type detectFailModule struct{ id string }
func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} }
func (d detectFailModule) Detect(context.Context) (module.State, error) {
return nil, errors.New("boom")
}
func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{}, nil
}
func (d detectFailModule) PlanForce(context.Context) (module.Plan, error) {
return module.Plan{}, nil
}
func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (d detectFailModule) Verify(context.Context) error { return nil }
// notFoundRunner simulates "command not found" for version checks.
type notFoundRunner struct{}
func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") }
func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) {
return nil, errors.New("not found")
}
// fixedRunner returns a preset version string for a matching command.
type fixedRunner struct {
arduinoOut []byte
arduinoErr error
wokwiOut []byte
wokwiErr error
}
func (f fixedRunner) Run(context.Context, string, ...string) error { return nil }
func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
switch name {
case "arduino-cli":
return f.arduinoOut, f.arduinoErr
case "wokwi-cli":
return f.wokwiOut, f.wokwiErr
}
return nil, errors.New("no match")
}
func TestDoctorAllConvergedExitsZero(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 0 {
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
}
if !strings.Contains(buf.String(), "[✓] foo") {
t.Errorf("missing converged mark: %s", buf.String())
}
}
func TestDoctorDivergedExitsOne(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(pendingModule{id: "bar"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 1 {
t.Fatalf("exit %d, want 1", code)
}
if !strings.Contains(buf.String(), "[✗] bar") {
t.Errorf("missing diverged mark: %s", buf.String())
}
}
func TestDoctorDetectErrorIsReported(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(detectFailModule{id: "baz"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 1 {
t.Fatalf("exit %d, want 1", code)
}
if !strings.Contains(buf.String(), "[!] baz") {
t.Errorf("missing error mark: %s", buf.String())
}
}
func TestDoctorArduinoCLIDriftIsReported(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)}
var buf bytes.Buffer
code := Run(context.Background(), cfg, reg, runner, &buf)
if code != 1 {
t.Fatalf("exit %d, want 1 (drift)", code)
}
if !strings.Contains(buf.String(), "drift") {
t.Errorf("missing drift report: %s", buf.String())
}
}
func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
var buf bytes.Buffer
code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf)
if code != 0 {
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
}
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,22 @@
#include <WiFi.h>
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASS";
void setup() {
Serial.begin(115200);
delay(1000);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected! IP: ");
Serial.println(WiFi.localIP());
}
void loop() {
delay(1000);
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,22 @@
#include <ESP8266WiFi.h>
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASS";
void setup() {
Serial.begin(115200);
delay(1000);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected! IP: ");
Serial.println(WiFi.localIP());
}
void loop() {
delay(1000);
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

Some files were not shown because too many files have changed in this diff Show More