Compare commits

34 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
57 changed files with 3803 additions and 151 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 }}

6
.gitignore vendored
View File

@@ -8,3 +8,9 @@ WokwiKey.txt
*.out *.out
coverage.* coverage.*
.worktrees/ .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 ## 🚀 Quickstart
## Installation
One-line install on Arch Linux:
```bash ```bash
git clone --depth=1 https://github.com/<your-username>/ArduinoArch.git curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
cd ArduinoArch
chmod +x install.sh
./install.sh
``` ```
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 ### Development
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
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/ 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/ └── lib/
├── common.sh # Colors, helpers, environment detection, progress bar ├── common.sh # Colors, helpers, environment detection, progress bar
├── themes.sh # Colorscheme selection menu ├── 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 "$@"

View File

@@ -38,7 +38,11 @@ import (
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/wizard" "gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/wizard"
) )
var version = "dev" var (
version = "dev"
commit = "none"
date = "unknown"
)
// buildRegistry constructs a module registry populated with all Plan 3a // buildRegistry constructs a module registry populated with all Plan 3a
// modules, wired against the provided config and platform dependencies. // modules, wired against the provided config and platform dependencies.
@@ -85,6 +89,7 @@ func main() {
root.AddCommand(newThemeCmd()) root.AddCommand(newThemeCmd())
root.AddCommand(newNewCmd()) root.AddCommand(newNewCmd())
root.AddCommand(newDoctorCmd()) root.AddCommand(newDoctorCmd())
root.AddCommand(newVersionCmd())
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases // Wire SIGINT/SIGTERM to a cancellable context so long-running phases
// (engine.Run, sudo keepalive) observe the cancellation promptly. // (engine.Run, sudo keepalive) observe the cancellation promptly.
@@ -103,6 +108,7 @@ func newInstallCmd() *cobra.Command {
dryRun bool dryRun bool
halt bool halt bool
noSudo bool noSudo bool
force bool
only []string only []string
skip []string skip []string
overlayPath string overlayPath string
@@ -169,12 +175,14 @@ func newInstallCmd() *cobra.Command {
Selected: selected, Selected: selected,
DryRun: dryRun, DryRun: dryRun,
HaltOnError: halt, HaltOnError: halt,
Force: force,
Reporter: reporter, Reporter: reporter,
}) })
} else { } else {
// TUI path: wizard → dashboard. If the user does not confirm, // TUI path: wizard → dashboard. If the user does not confirm,
// we exit cleanly with a zero-summary and no error. // we exit cleanly with a zero-summary and no error.
initial := tui.NewWizardState(cfg, reg) initial := tui.NewWizardState(cfg, reg)
initial.Force = force
// Pre-filter the wizard's module list by --only/--skip so the // Pre-filter the wizard's module list by --only/--skip so the
// user cannot accidentally widen selection in the TUI beyond // user cannot accidentally widen selection in the TUI beyond
// what they asked for on the CLI. // what they asked for on the CLI.
@@ -249,6 +257,7 @@ func newInstallCmd() *cobra.Command {
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned actions without applying") 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(&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(&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(&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().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.Flags().StringVar(&overlayPath, "config", "", "path to overlay.yaml (default: ~/.config/archduino/overlay.yaml)")
@@ -422,3 +431,13 @@ func newDoctorCmd() *cobra.Command {
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml") cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd 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)
},
}
}

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.

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,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,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).

View File

@@ -1,4 +1,6 @@
#!/bin/bash #!/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 # Archduino Install — TUI Arduino Development Environment
# Works on both native Arch Linux and Arch/Ubuntu WSL2 # Works on both native Arch Linux and Arch/Ubuntu WSL2

View File

@@ -14,7 +14,10 @@ type RunOptions struct {
Selected []string // module IDs to run; empty = all Selected []string // module IDs to run; empty = all
DryRun bool // Detect + Plan only, no Apply/Verify DryRun bool // Detect + Plan only, no Apply/Verify
HaltOnError bool // stop at first failure instead of continuing siblings HaltOnError bool // stop at first failure instead of continuing siblings
Reporter module.Reporter // 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. // ModuleStatus is the terminal state of a module within a run.
@@ -91,26 +94,43 @@ func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, e
res := &ModuleResult{ID: id} res := &ModuleResult{ID: id}
results[id] = res results[id] = res
state, err := m.Detect(ctx) var (
if err != nil { plan module.Plan
res.Status = StatusFailed err error
res.Err = fmt.Errorf("detect: %w", err) )
markFailed(metas, id, skipped) if opts.Force {
if opts.HaltOnError { plan, err = m.PlanForce(ctx)
break 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
} }
continue
}
plan, err := m.Plan(ctx, state) plan, err = m.Plan(ctx, state)
if err != nil { if err != nil {
res.Status = StatusFailed res.Status = StatusFailed
res.Err = fmt.Errorf("plan: %w", err) res.Err = fmt.Errorf("plan: %w", err)
markFailed(metas, id, skipped) markFailed(metas, id, skipped)
if opts.HaltOnError { if opts.HaltOnError {
break break
}
continue
} }
continue
} }
res.Plan = plan res.Plan = plan

View File

@@ -10,34 +10,51 @@ import (
// scriptedModule lets tests control what each phase returns. // scriptedModule lets tests control what each phase returns.
type scriptedModule struct { type scriptedModule struct {
id string id string
deps []string deps []string
detect func() (module.State, error) detect func() (module.State, error)
plan func() (module.Plan, error) plan func() (module.Plan, error)
apply func() error planForce func() (module.Plan, error)
verify func() 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) 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) { func (s *scriptedModule) Detect(ctx context.Context) (module.State, error) {
s.detectCalls++
if s.detect != nil { if s.detect != nil {
return s.detect() return s.detect()
} }
return nil, nil return nil, nil
} }
func (s scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) { func (s *scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) {
s.planCalls++
if s.plan != nil { if s.plan != nil {
return s.plan() return s.plan()
} }
return module.Plan{}, nil return module.Plan{}, nil
} }
func (s scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error { 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 { if s.apply != nil {
return s.apply() return s.apply()
} }
return nil return nil
} }
func (s scriptedModule) Verify(ctx context.Context) error { func (s *scriptedModule) Verify(ctx context.Context) error {
if s.verify != nil { if s.verify != nil {
return s.verify() return s.verify()
} }
@@ -57,8 +74,8 @@ func newReg(t *testing.T, mods ...module.Module) *module.Registry {
func TestRunAllSucceed(t *testing.T) { func TestRunAllSucceed(t *testing.T) {
reg := newReg(t, reg := newReg(t,
scriptedModule{id: "a"}, &scriptedModule{id: "a"},
scriptedModule{id: "b", deps: []string{"a"}}, &scriptedModule{id: "b", deps: []string{"a"}},
) )
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
if err != nil { if err != nil {
@@ -75,7 +92,7 @@ func TestRunAllSucceed(t *testing.T) {
func TestRunDryRunDoesNotApply(t *testing.T) { func TestRunDryRunDoesNotApply(t *testing.T) {
applied := false applied := false
reg := newReg(t, scriptedModule{ reg := newReg(t, &scriptedModule{
id: "a", id: "a",
apply: func() error { applied = true; return nil }, apply: func() error { applied = true; return nil },
}) })
@@ -93,10 +110,10 @@ func TestRunDryRunDoesNotApply(t *testing.T) {
func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) { func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
reg := newReg(t, reg := newReg(t,
scriptedModule{id: "root"}, &scriptedModule{id: "root"},
scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }}, &scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }},
scriptedModule{id: "child", deps: []string{"fails"}}, &scriptedModule{id: "child", deps: []string{"fails"}},
scriptedModule{id: "sibling", deps: []string{"root"}}, &scriptedModule{id: "sibling", deps: []string{"root"}},
) )
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
if err != nil { if err != nil {
@@ -122,8 +139,8 @@ func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
func TestRunHaltOnError(t *testing.T) { func TestRunHaltOnError(t *testing.T) {
reg := newReg(t, reg := newReg(t,
scriptedModule{id: "a", apply: func() error { return errors.New("nope") }}, &scriptedModule{id: "a", apply: func() error { return errors.New("nope") }},
scriptedModule{id: "b"}, // independent &scriptedModule{id: "b"}, // independent
) )
sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()}) sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()})
if err != nil { if err != nil {
@@ -144,3 +161,36 @@ func TestRunUnknownModule(t *testing.T) {
t.Fatal("expected error for unknown module") 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

@@ -49,6 +49,12 @@ type Module interface {
Meta() Meta Meta() Meta
Detect(ctx context.Context) (State, error) Detect(ctx context.Context) (State, error)
Plan(ctx context.Context, current State) (Plan, 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 Apply(ctx context.Context, plan Plan, r Reporter) error
Verify(ctx context.Context) error Verify(ctx context.Context) error
} }

View File

@@ -5,13 +5,17 @@ import (
"testing" "testing"
) )
type fakeModule struct{ id string; deps []string } 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) 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) Detect(context.Context) (State, error) { return nil, nil }
func (f fakeModule) Plan(context.Context, State) (Plan, error) { return Plan{}, nil } func (f fakeModule) Plan(context.Context, State) (Plan, error) { return Plan{}, nil }
func (f fakeModule) Apply(context.Context, Plan, Reporter) error { return nil } func (f fakeModule) PlanForce(context.Context) (Plan, error) { return Plan{}, nil }
func (f fakeModule) Verify(context.Context) error { return 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) { func TestRegistry(t *testing.T) {
t.Run("register then get", func(t *testing.T) { t.Run("register then get", func(t *testing.T) {

View File

@@ -60,6 +60,30 @@ func (m Module) Detect(ctx context.Context) (module.State, error) {
return s, nil 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) { func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
s, _ := cur.(state) s, _ := cur.(state)
var actions []module.Action var actions []module.Action
@@ -68,28 +92,7 @@ func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error)
actions = append(actions, module.Action{ actions = append(actions, module.Action{
Description: "download + install arduino-cli " + m.cfg.Version, Description: "download + install arduino-cli " + m.cfg.Version,
Kind: module.ActionFileWrite, Kind: module.ActionFileWrite,
Exec: func(ctx context.Context) error { Exec: func(ctx context.Context) error { return m.downloadBinary(ctx, bp) },
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)
},
}) })
} }
for _, core := range s.MissingCores { for _, core := range s.MissingCores {
@@ -136,3 +139,34 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -36,7 +36,7 @@ func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]b
type nop struct{} type nop struct{}
func (nop) Progress(string, int, int, string) {} func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {} func (nop) Log(string, string) {}
func makeTarGz(t *testing.T, entries map[string][]byte) string { func makeTarGz(t *testing.T, entries map[string][]byte) string {
t.Helper() t.Helper()
@@ -91,4 +91,64 @@ func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -80,3 +80,22 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -38,7 +38,7 @@ var errOne = eo{}
type nop struct{} type nop struct{}
func (nop) Progress(string, int, int, string) {} func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {} func (nop) Log(string, string) {}
func TestFontsPlanApplyUsesYay(t *testing.T) { func TestFontsPlanApplyUsesYay(t *testing.T) {
r := &fakeRunner{installed: map[string]bool{"yay": true}} r := &fakeRunner{installed: map[string]bool{"yay": true}}
@@ -69,4 +69,31 @@ func TestFontsDetectPresentNoActions(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -72,3 +72,15 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -110,4 +110,30 @@ func TestVerifyAfterApply(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -96,3 +96,20 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -55,3 +55,31 @@ func TestHelloFullLifecycle(t *testing.T) {
t.Fatalf("content = %q", data) 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

@@ -461,3 +461,115 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -207,4 +207,93 @@ func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -72,3 +72,15 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -110,4 +110,30 @@ func TestVerifyAfterApply(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -185,3 +185,52 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -142,4 +142,52 @@ func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -88,3 +88,22 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -55,4 +55,42 @@ func TestShellConfigIdempotent(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -72,3 +72,15 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -110,4 +110,30 @@ func TestVerifyAfterApply(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -113,3 +113,47 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -13,7 +13,7 @@ import (
type nop struct{} type nop struct{}
func (nop) Progress(string, int, int, string) {} func (nop) Progress(string, int, int, string) {}
func (nop) Log(string, string) {} func (nop) Log(string, string) {}
func TestThemesCopiesFiles(t *testing.T) { func TestThemesCopiesFiles(t *testing.T) {
src := t.TempDir() src := t.TempDir()
@@ -63,4 +63,49 @@ func applyOnce(t *testing.T, m Module) error {
return m.Apply(context.Background(), p, nop{}) 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{} var _ module.Module = Module{}

View File

@@ -72,3 +72,15 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -110,4 +110,30 @@ func TestVerifyAfterApply(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -108,3 +108,28 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -81,4 +81,45 @@ func TestZellijInstalledPresentEmptyPlan(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -130,3 +130,44 @@ func (m Module) Verify(ctx context.Context) error {
} }
return nil 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

@@ -114,4 +114,46 @@ func TestZshPlanApplyInstallsEverything(t *testing.T) {
} }
} }
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{} var _ module.Module = Module{}

View File

@@ -11,6 +11,7 @@ import (
type AURHelper interface { type AURHelper interface {
EnsureInstalled(ctx context.Context) error EnsureInstalled(ctx context.Context) error
Install(ctx context.Context, pkgs []string) error Install(ctx context.Context, pkgs []string) error
Reinstall(ctx context.Context, pkgs []string) error
} }
type Yay struct { type Yay struct {
@@ -52,6 +53,17 @@ func (y *Yay) Install(ctx context.Context, pkgs []string) error {
return y.Runner.Run(ctx, "yay", args...) 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 { func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
} }

View File

@@ -70,6 +70,32 @@ func TestAUREnsureInstalledBootstrapsYay(t *testing.T) {
} }
} }
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) { func TestAURInstall(t *testing.T) {
r := &aurFakeRunner{yayInstalled: true} r := &aurFakeRunner{yayInstalled: true}
y := NewYay(r) y := NewYay(r)

View File

@@ -76,6 +76,17 @@ func (p Pacman) Install(ctx context.Context, pkgs []string) error {
return p.Runner.Run(ctx, "sudo", args...) 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). // Update runs `sudo pacman -Syu --noconfirm` (full sync + system upgrade).
// On Arch, a bare `pacman -Sy` followed by an install is the partial-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 // anti-pattern that can break the system when a dep's ABI changes, so we

View File

@@ -39,6 +39,27 @@ func TestPacmanInstallShellsOutCorrectly(t *testing.T) {
} }
} }
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) { func TestPacmanInstallEmptyIsNoop(t *testing.T) {
r := &fakeRunner{} r := &fakeRunner{}
p := NewPacman(r) p := NewPacman(r)

View File

@@ -8,6 +8,7 @@ type PackageManager interface {
Name() string Name() string
IsInstalled(ctx context.Context, pkg string) (bool, error) IsInstalled(ctx context.Context, pkg string) (bool, error)
Install(ctx context.Context, pkgs []string) error Install(ctx context.Context, pkgs []string) error
Reinstall(ctx context.Context, pkgs []string) error
Update(ctx context.Context) error Update(ctx context.Context) error
} }

View File

@@ -14,33 +14,44 @@ import (
// convergedModule returns an empty Plan — always converged. // convergedModule returns an empty Plan — always converged.
type convergedModule struct{ id string } type convergedModule struct{ id string }
func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} } 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) Detect(context.Context) (module.State, error) { return nil, nil }
func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) { func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{}, nil 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) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (c convergedModule) Verify(context.Context) error { return nil } func (c convergedModule) Verify(context.Context) error { return nil }
type pendingModule struct{ id string } type pendingModule struct{ id string }
func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} } 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) Detect(context.Context) (module.State, error) { return nil, nil }
func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) { func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil 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) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (p pendingModule) Verify(context.Context) error { return nil } func (p pendingModule) Verify(context.Context) error { return nil }
type detectFailModule struct{ id string } type detectFailModule struct{ id string }
func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} } 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) Detect(context.Context) (module.State, error) {
return nil, errors.New("boom")
}
func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) { func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{}, nil 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) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (d detectFailModule) Verify(context.Context) error { return nil } func (d detectFailModule) Verify(context.Context) error { return nil }
// notFoundRunner simulates "command not found" for version checks. // notFoundRunner simulates "command not found" for version checks.
type notFoundRunner struct{} type notFoundRunner struct{}

View File

@@ -31,6 +31,10 @@ type WizardState struct {
Parallelism int // advanced; reserved for a future concurrent engine Parallelism int // advanced; reserved for a future concurrent engine
HaltOnError bool // advanced HaltOnError bool // advanced
Confirmed bool // true once the user hits Confirm on the last screen Confirmed bool // true once the user hits Confirm on the last screen
// 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
} }
// NewWizardState builds an initial state from the running config and the // NewWizardState builds an initial state from the running config and the

View File

@@ -18,8 +18,11 @@ func (f fakeModule) Detect(context.Context) (module.State, error) { return nil,
func (f fakeModule) Plan(context.Context, module.State) (module.Plan, error) { func (f fakeModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{}, nil return module.Plan{}, nil
} }
func (f fakeModule) PlanForce(context.Context) (module.Plan, error) {
return module.Plan{}, nil
}
func (f fakeModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } func (f fakeModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (f fakeModule) Verify(context.Context) error { return nil } func (f fakeModule) Verify(context.Context) error { return nil }
func TestNewWizardStatePreTicksAllByDefault(t *testing.T) { func TestNewWizardStatePreTicksAllByDefault(t *testing.T) {
reg := module.NewRegistry() reg := module.NewRegistry()
@@ -71,4 +74,3 @@ func TestSelectedIDsOnlyReturnsChecked(t *testing.T) {
t.Fatalf("got %v", got) t.Fatalf("got %v", got)
} }
} }

View File

@@ -77,7 +77,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
Title("Review plan"). Title("Review plan").
DescriptionFunc(func() string { DescriptionFunc(func() string {
s.Modules = syncSelected(s.Modules, selectedModules) s.Modules = syncSelected(s.Modules, selectedModules)
return renderPlanCached(ctx, reg, s.SelectedIDs(), cache) return renderPlanCached(ctx, reg, s.SelectedIDs(), cache, s.Force)
}, &selectedModules)), }, &selectedModules)),
huh.NewGroup(huh.NewConfirm(). huh.NewGroup(huh.NewConfirm().
@@ -118,24 +118,35 @@ func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice
return out return out
} }
// planCache memoises rendered Detect+Plan output per module ID so // planCache memoises rendered Detect+Plan (or PlanForce) output per
// DescriptionFunc (which fires on every redraw) does not re-run // (module ID, force) tuple so DescriptionFunc (which fires on every
// potentially expensive Detect/Plan calls. Entries are evicted when a // redraw) does not re-run potentially expensive module calls. Including
// module leaves the current selection. // force in the key ensures a force-mode preview never reuses a non-force
// cached plan. Entries are evicted when a module leaves the current
// selection.
type planCacheEntry struct{ out string } type planCacheEntry struct{ out string }
type planCache struct{ entries map[string]planCacheEntry } type planCache struct{ entries map[string]planCacheEntry }
func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} } func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} }
func (c *planCache) get(id string) (string, bool) { func cacheKey(id string, force bool) string {
e, ok := c.entries[id] if force {
return "force=1|" + id
}
return "force=0|" + id
}
func (c *planCache) get(id string, force bool) (string, bool) {
e, ok := c.entries[cacheKey(id, force)]
return e.out, ok return e.out, ok
} }
func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} } func (c *planCache) put(id string, force bool, out string) {
func (c *planCache) prune(keepIDs []string) { c.entries[cacheKey(id, force)] = planCacheEntry{out: out}
}
func (c *planCache) prune(keepIDs []string, force bool) {
keep := map[string]bool{} keep := map[string]bool{}
for _, id := range keepIDs { for _, id := range keepIDs {
keep[id] = true keep[cacheKey(id, force)] = true
} }
for k := range c.entries { for k := range c.entries {
if !keep[k] { if !keep[k] {
@@ -147,38 +158,51 @@ func (c *planCache) prune(keepIDs []string) {
// renderPlanCached is the cached equivalent of running Detect+Plan for // renderPlanCached is the cached equivalent of running Detect+Plan for
// every selected module and joining the rendered lines. IDs no longer in // every selected module and joining the rendered lines. IDs no longer in
// the selection are evicted so a deselect+reselect re-runs Detect. // the selection are evicted so a deselect+reselect re-runs Detect.
func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string { func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache, force bool) string {
cache.prune(selectedIDs) cache.prune(selectedIDs, force)
if len(selectedIDs) == 0 { if len(selectedIDs) == 0 {
return "No modules selected." return "No modules selected."
} }
var b strings.Builder var b strings.Builder
if force {
b.WriteString("⚠ Force mode: all selected modules will be reapplied\n\n")
}
for _, id := range selectedIDs { for _, id := range selectedIDs {
if cached, ok := cache.get(id); ok { if cached, ok := cache.get(id, force); ok {
b.WriteString(cached) b.WriteString(cached)
continue continue
} }
line := renderOne(ctx, reg, id) line := renderOne(ctx, reg, id, force)
cache.put(id, line) cache.put(id, force, line)
b.WriteString(line) b.WriteString(line)
} }
return b.String() return b.String()
} }
// renderOne runs Detect+Plan for a single module and returns a rendered // renderOne runs Detect+Plan (or PlanForce when force=true) for a single
// line. A failing Detect/Plan is shown inline but does not abort. // module and returns a rendered line. A failing Detect/Plan/PlanForce is
func renderOne(ctx context.Context, reg *module.Registry, id string) string { // shown inline but does not abort.
func renderOne(ctx context.Context, reg *module.Registry, id string, force bool) string {
m, ok := reg.Get(id) m, ok := reg.Get(id)
if !ok { if !ok {
return fmt.Sprintf("• %s — (unknown)\n", id) return fmt.Sprintf("• %s — (unknown)\n", id)
} }
state, err := m.Detect(ctx) var plan module.Plan
if err != nil { var err error
return fmt.Sprintf("• %s — detect failed: %v\n", id, err) if force {
} plan, err = m.PlanForce(ctx)
plan, err := m.Plan(ctx, state) if err != nil {
if err != nil { return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
return fmt.Sprintf("• %s — plan failed: %v\n", id, err) }
} else {
state, derr := m.Detect(ctx)
if derr != nil {
return fmt.Sprintf("• %s — detect failed: %v\n", id, derr)
}
plan, err = m.Plan(ctx, state)
if err != nil {
return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
}
} }
if plan.IsEmpty() { if plan.IsEmpty() {
return fmt.Sprintf("• %s — already up to date\n", id) return fmt.Sprintf("• %s — already up to date\n", id)

View File

@@ -31,28 +31,28 @@ func TestSyncSelected(t *testing.T) {
func TestRenderPlanWithHello(t *testing.T) { func TestRenderPlanWithHello(t *testing.T) {
reg := module.NewRegistry() reg := module.NewRegistry()
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"}) _ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"})
out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache()) out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache(), false)
if !strings.Contains(out, "hello") { if !strings.Contains(out, "hello") {
t.Fatalf("output missing hello: %q", out) t.Fatalf("output missing hello: %q", out)
} }
} }
func TestRenderPlanEmpty(t *testing.T) { func TestRenderPlanEmpty(t *testing.T) {
if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache()); got != "No modules selected." { if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache(), false); got != "No modules selected." {
t.Fatalf("got %q", got) t.Fatalf("got %q", got)
} }
} }
func TestParseIntClamp(t *testing.T) { func TestParseIntClamp(t *testing.T) {
cases := map[string]int{ cases := map[string]int{
"": 1, "": 1,
" ": 1, " ": 1,
"abc": 1, "abc": 1,
"3abc": 1, // partial match is rejected (strconv.Atoi, not Sscanf) "3abc": 1, // partial match is rejected (strconv.Atoi, not Sscanf)
"0": 1, "0": 1,
"-5": 1, "-5": 1,
"3": 3, "3": 3,
"12": 12, "12": 12,
"99999999999999999999": 1, // overflow → fallback "99999999999999999999": 1, // overflow → fallback
} }
for in, want := range cases { for in, want := range cases {
@@ -75,9 +75,9 @@ func TestRenderPlanCachesDetectAndPlan(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
cache := newPlanCache() cache := newPlanCache()
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
if detects != 1 || plans != 1 { if detects != 1 || plans != 1 {
t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans) t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans)
} }
@@ -92,18 +92,20 @@ func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) {
reg := module.NewRegistry() reg := module.NewRegistry()
_ = reg.Register(m) _ = reg.Register(m)
cache := newPlanCache() cache := newPlanCache()
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{}, cache) _ = renderPlanCached(context.Background(), reg, []string{}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
if detects != 2 { if detects != 2 {
t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects) t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects)
} }
} }
type countingModule struct { type countingModule struct {
meta module.Meta meta module.Meta
onDetect func() onDetect func()
onPlan func() onPlan func()
onPlanForce func()
planForceOut module.Plan
} }
func (c countingModule) Meta() module.Meta { return c.meta } func (c countingModule) Meta() module.Meta { return c.meta }
@@ -119,9 +121,63 @@ func (c countingModule) Plan(context.Context, module.State) (module.Plan, error)
} }
return module.Plan{}, nil return module.Plan{}, nil
} }
func (c countingModule) PlanForce(context.Context) (module.Plan, error) {
if c.onPlanForce != nil {
c.onPlanForce()
}
return c.planForceOut, nil
}
func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (c countingModule) Verify(context.Context) error { return nil } func (c countingModule) Verify(context.Context) error { return nil }
func TestWizardPreviewUsesPlanForce(t *testing.T) {
var planForceCalls int
m := countingModule{
meta: module.Meta{ID: "count", Name: "Count"},
onDetect: func() {
t.Fatal("Detect must not be called in force mode")
},
onPlan: func() {
t.Fatal("Plan must not be called in force mode")
},
onPlanForce: func() { planForceCalls++ },
planForceOut: module.Plan{
Actions: []module.Action{{Description: "forced-reapply"}},
},
}
reg := module.NewRegistry()
if err := reg.Register(m); err != nil {
t.Fatal(err)
}
out := renderPlanCached(context.Background(), reg, []string{"count"}, newPlanCache(), true)
if planForceCalls != 1 {
t.Fatalf("want 1 PlanForce call, got %d", planForceCalls)
}
if !strings.Contains(out, "forced-reapply") {
t.Fatalf("output missing PlanForce action: %q", out)
}
}
func TestWizardPreviewCacheSeparatesForceAndNonForce(t *testing.T) {
var detects, planForceCalls int
m := countingModule{
meta: module.Meta{ID: "count", Name: "Count"},
onDetect: func() { detects++ },
onPlanForce: func() { planForceCalls++ },
}
reg := module.NewRegistry()
_ = reg.Register(m)
cache := newPlanCache()
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, true)
if detects != 1 {
t.Fatalf("want 1 Detect (non-force path), got %d", detects)
}
if planForceCalls != 1 {
t.Fatalf("want 1 PlanForce (force path), got %d", planForceCalls)
}
}
func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) { func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()

View File

@@ -17,26 +17,38 @@ write_zellij_config ~/.config/zellij/config.kdl
cat >~/.config/zellij/layouts/arduino.kdl <<'LAYOUT' cat >~/.config/zellij/layouts/arduino.kdl <<'LAYOUT'
layout { layout {
tab name="code" focus=true { tab name="code" focus=true {
pane split_direction="vertical" { pane split_direction="vertical" {
pane size="65%" { pane size=1 borderless=true {
command "nvim" plugin location="tab-bar"
args "." }
} pane size="65%" {
pane split_direction="horizontal" { command "nvim"
pane size="60%" name="terminal" args "."
pane size="40%" name="serial" { }
command "bash" pane split_direction="horizontal" {
args "-c" "echo '⚡ Serial — run: tio /dev/ttyUSB0 -b 115200'" pane size="60%" name="terminal"
} pane size="40%" name="serial" {
command "bash"
args "-c" "echo '⚡ Serial — run: tio /dev/ttyUSB0 -b 115200'"
} }
} }
} }
tab name="git" { pane size=1 borderless=true {
pane { plugin location="status-bar"
command "lazygit"
}
} }
}
tab name="git" {
pane size=1 borderless=true {
plugin location="tab-bar"
}
pane {
command "lazygit"
}
pane size=1 borderless=true {
plugin location="status-bar"
}
}
} }
LAYOUT LAYOUT
info "Zellij configured with $THEME_DISPLAY_NAME + Arduino layout" info "Zellij configured with $THEME_DISPLAY_NAME + Arduino layout"