refactor: migrate to GNU Make build system and restructure UI with dedicated views
- commit
- 4afd200a219b5301598bbc397791441fbb494e7d
- author
- bnema · · 2025-10-27 18:16 UTC
- parent
- 45cd87a
- signature
- unsigned
19 files · +652 -260
M Makefile +101 -52
| @@ -1,56 +1,105 @@ | |||
| 1 | - | .PHONY: check check-syntax check-shellcheck clean help | |
| 1 | + | # ArchUp Go Installer Makefile | |
| 2 | + | # Builds the Go-based installer with proper version injection | |
| 2 | 3 | ||
| 3 | - | # Default target | |
| 4 | - | help: | |
| 5 | - | @echo "archup - Makefile targets:" | |
| 6 | - | @echo "" | |
| 7 | - | @echo " make check - Run all checks (syntax + shellcheck)" | |
| 8 | - | @echo " make check-syntax - Check shell script syntax with bash -n" | |
| 9 | - | @echo " make check-shellcheck - Run shellcheck linting" | |
| 10 | - | @echo " make clean - Remove generated files" | |
| 11 | - | @echo "" | |
| 4 | + | # Get version from git tag or use dev | |
| 5 | + | VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") | |
| 6 | + | LDFLAGS := -ldflags "-X main.version=$(VERSION)" | |
| 7 | + | LDFLAGS_STRIP := -ldflags "-X main.version=$(VERSION) -s -w" | |
| 12 | 8 | ||
| 13 | - | # Run all checks | |
| 14 | - | check: check-syntax check-shellcheck | |
| 15 | - | @echo "" | |
| 16 | - | @echo "✓ All checks passed!" | |
| 17 | - | ||
| 18 | - | # Check syntax with bash -n | |
| 19 | - | check-syntax: | |
| 20 | - | @echo "=========================================" | |
| 21 | - | @echo "Checking shell script syntax..." | |
| 22 | - | @echo "=========================================" | |
| 23 | - | @for script in install.sh install/bootstrap.sh install/helpers/*.sh install/preflight/*.sh install/partitioning/*.sh install/base/*.sh install/config/*.sh install/boot/*.sh install/repos/*.sh install/post-install/*.sh install/post-boot/*.sh; do \ | |
| 24 | - | if [ -f "$$script" ]; then \ | |
| 25 | - | echo "Checking: $$script"; \ | |
| 26 | - | bash -n "$$script" || exit 1; \ | |
| 27 | - | fi \ | |
| 28 | - | done | |
| 29 | - | @echo "✓ Syntax check passed" | |
| 30 | - | ||
| 31 | - | # Run shellcheck | |
| 32 | - | check-shellcheck: | |
| 33 | - | @echo "" | |
| 34 | - | @echo "=========================================" | |
| 35 | - | @echo "Running shellcheck..." | |
| 36 | - | @echo "=========================================" | |
| 37 | - | @if ! command -v shellcheck >/dev/null 2>&1; then \ | |
| 38 | - | echo "⚠ shellcheck not found, skipping..."; \ | |
| 39 | - | echo "Install with: sudo pacman -S shellcheck"; \ | |
| 40 | - | exit 0; \ | |
| 41 | - | fi | |
| 42 | - | @for script in install.sh install/bootstrap.sh install/helpers/*.sh install/preflight/*.sh install/partitioning/*.sh install/base/*.sh install/config/*.sh install/boot/*.sh install/repos/*.sh install/post-install/*.sh install/post-boot/*.sh; do \ | |
| 43 | - | if [ -f "$$script" ]; then \ | |
| 44 | - | echo ""; \ | |
| 45 | - | echo "Checking: $$script"; \ | |
| 46 | - | shellcheck --exclude=SC1090,SC1091,SC2086,SC2004,SC2059,SC2129,SC2155 "$$script" || exit 1; \ | |
| 47 | - | fi \ | |
| 48 | - | done | |
| 49 | - | @echo "" | |
| 50 | - | @echo "✓ shellcheck passed" | |
| 9 | + | # Build settings | |
| 10 | + | BINARY_NAME := archup-installer | |
| 11 | + | BUILD_DIR := build | |
| 12 | + | CMD_DIR := cmd/archup-installer | |
| 13 | + | INSTALL_DIR := /usr/local/bin | |
| 14 | + | ||
| 15 | + | # Go build flags | |
| 16 | + | GOFLAGS := -trimpath | |
| 17 | + | CGO_ENABLED := 0 | |
| 18 | + | ||
| 19 | + | .PHONY: all build clean install uninstall run test fmt vet lint mod-tidy help | |
| 20 | + | ||
| 21 | + | all: build | |
| 22 | + | ||
| 23 | + | ## build: Build the installer binary with version injection | |
| 24 | + | build: | |
| 25 | + | @echo "Building $(BINARY_NAME) $(VERSION)..." | |
| 26 | + | @mkdir -p $(BUILD_DIR) | |
| 27 | + | CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |
| 28 | + | @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |
| 29 | + | ||
| 30 | + | ## build-release: Build optimized release binary (stripped, smaller size) | |
| 31 | + | build-release: | |
| 32 | + | @echo "Building release $(BINARY_NAME) $(VERSION)..." | |
| 33 | + | @mkdir -p $(BUILD_DIR) | |
| 34 | + | CGO_ENABLED=0 go build $(GOFLAGS) $(LDFLAGS_STRIP) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |
| 35 | + | @echo "Release build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |
| 36 | + | @ls -lh $(BUILD_DIR)/$(BINARY_NAME) | |
| 37 | + | ||
| 38 | + | ## build-static: Build fully static binary (no dependencies) | |
| 39 | + | build-static: | |
| 40 | + | @echo "Building static $(BINARY_NAME) $(VERSION)..." | |
| 41 | + | @mkdir -p $(BUILD_DIR) | |
| 42 | + | CGO_ENABLED=0 go build $(GOFLAGS) $(LDFLAGS) -a -installsuffix cgo -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |
| 43 | + | @echo "Static build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |
| 51 | 44 | ||
| 52 | - | # Clean generated files | |
| 45 | + | ## install: Install the binary to system | |
| 46 | + | install: build | |
| 47 | + | @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." | |
| 48 | + | @sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) | |
| 49 | + | @echo "Installed successfully" | |
| 50 | + | ||
| 51 | + | ## uninstall: Remove the binary from system | |
| 52 | + | uninstall: | |
| 53 | + | @echo "Uninstalling $(BINARY_NAME)..." | |
| 54 | + | @sudo rm -f $(INSTALL_DIR)/$(BINARY_NAME) | |
| 55 | + | @echo "Uninstalled successfully" | |
| 56 | + | ||
| 57 | + | ## run: Build and run the installer | |
| 58 | + | run: build | |
| 59 | + | @echo "Running $(BINARY_NAME)..." | |
| 60 | + | @$(BUILD_DIR)/$(BINARY_NAME) | |
| 61 | + | ||
| 62 | + | ## test: Run tests | |
| 63 | + | test: | |
| 64 | + | @echo "Running tests..." | |
| 65 | + | @go test -v -race -coverprofile=coverage.out ./... | |
| 66 | + | @go tool cover -func=coverage.out | |
| 67 | + | ||
| 68 | + | ## fmt: Format all Go code | |
| 69 | + | fmt: | |
| 70 | + | @echo "Formatting code..." | |
| 71 | + | @go fmt ./... | |
| 72 | + | @gofmt -s -w . | |
| 73 | + | ||
| 74 | + | ## vet: Run go vet | |
| 75 | + | vet: | |
| 76 | + | @echo "Running go vet..." | |
| 77 | + | @go vet ./... | |
| 78 | + | ||
| 79 | + | ## lint: Run golangci-lint (install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) | |
| 80 | + | lint: | |
| 81 | + | @echo "Running golangci-lint..." | |
| 82 | + | @which golangci-lint > /dev/null || (echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1) | |
| 83 | + | @golangci-lint run ./... | |
| 84 | + | ||
| 85 | + | ## mod-tidy: Tidy go.mod | |
| 86 | + | mod-tidy: | |
| 87 | + | @echo "Tidying go.mod..." | |
| 88 | + | @go mod tidy | |
| 89 | + | ||
| 90 | + | ## clean: Remove build artifacts | |
| 53 | 91 | clean: | |
| 54 | - | @echo "Cleaning generated files..." | |
| 55 | - | @rm -f /var/log/archup-install.log | |
| 56 | - | @echo "✓ Clean complete" | |
| 92 | + | @echo "Cleaning build artifacts..." | |
| 93 | + | @rm -rf $(BUILD_DIR) | |
| 94 | + | @rm -f coverage.out | |
| 95 | + | @echo "Clean complete" | |
| 96 | + | ||
| 97 | + | ## version: Show version information | |
| 98 | + | version: | |
| 99 | + | @echo "Version: $(VERSION)" | |
| 100 | + | ||
| 101 | + | ## help: Show this help message | |
| 102 | + | help: | |
| 103 | + | @echo "ArchUp Go Installer - Makefile Commands" | |
| 104 | + | @echo "" | |
| 105 | + | @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' | |
| @@ -1,56 +1,105 @@ | |||||
| 1 | - | .PHONY: check check-syntax check-shellcheck clean help | 1 | + | # ArchUp Go Installer Makefile |
| 2 | + | # Builds the Go-based installer with proper version injection | |||
| 2 | 3 | ||||
| 3 | - | # Default target | 4 | + | # Get version from git tag or use dev |
| 4 | - | help: | 5 | + | VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") |
| 5 | - | @echo "archup - Makefile targets:" | 6 | + | LDFLAGS := -ldflags "-X main.version=$(VERSION)" |
| 6 | - | @echo "" | 7 | + | LDFLAGS_STRIP := -ldflags "-X main.version=$(VERSION) -s -w" |
| 7 | - | @echo " make check - Run all checks (syntax + shellcheck)" | |||
| 8 | - | @echo " make check-syntax - Check shell script syntax with bash -n" | |||
| 9 | - | @echo " make check-shellcheck - Run shellcheck linting" | |||
| 10 | - | @echo " make clean - Remove generated files" | |||
| 11 | - | @echo "" | |||
| 12 | 8 | ||||
| 13 | - | # Run all checks | 9 | + | # Build settings |
| 14 | - | check: check-syntax check-shellcheck | 10 | + | BINARY_NAME := archup-installer |
| 15 | - | @echo "" | 11 | + | BUILD_DIR := build |
| 16 | - | @echo "✓ All checks passed!" | 12 | + | CMD_DIR := cmd/archup-installer |
| 17 | - | 13 | + | INSTALL_DIR := /usr/local/bin | |
| 18 | - | # Check syntax with bash -n | 14 | + | |
| 19 | - | check-syntax: | 15 | + | # Go build flags |
| 20 | - | @echo "=========================================" | 16 | + | GOFLAGS := -trimpath |
| 21 | - | @echo "Checking shell script syntax..." | 17 | + | CGO_ENABLED := 0 |
| 22 | - | @echo "=========================================" | 18 | + | |
| 23 | - | @for script in install.sh install/bootstrap.sh install/helpers/*.sh install/preflight/*.sh install/partitioning/*.sh install/base/*.sh install/config/*.sh install/boot/*.sh install/repos/*.sh install/post-install/*.sh install/post-boot/*.sh; do \ | 19 | + | .PHONY: all build clean install uninstall run test fmt vet lint mod-tidy help |
| 24 | - | if [ -f "$$script" ]; then \ | 20 | + | |
| 25 | - | echo "Checking: $$script"; \ | 21 | + | all: build |
| 26 | - | bash -n "$$script" || exit 1; \ | 22 | + | |
| 27 | - | fi \ | 23 | + | ## build: Build the installer binary with version injection |
| 28 | - | done | 24 | + | build: |
| 29 | - | @echo "✓ Syntax check passed" | 25 | + | @echo "Building $(BINARY_NAME) $(VERSION)..." |
| 30 | - | 26 | + | @mkdir -p $(BUILD_DIR) | |
| 31 | - | # Run shellcheck | 27 | + | CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) |
| 32 | - | check-shellcheck: | 28 | + | @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" |
| 33 | - | @echo "" | 29 | + | |
| 34 | - | @echo "=========================================" | 30 | + | ## build-release: Build optimized release binary (stripped, smaller size) |
| 35 | - | @echo "Running shellcheck..." | 31 | + | build-release: |
| 36 | - | @echo "=========================================" | 32 | + | @echo "Building release $(BINARY_NAME) $(VERSION)..." |
| 37 | - | @if ! command -v shellcheck >/dev/null 2>&1; then \ | 33 | + | @mkdir -p $(BUILD_DIR) |
| 38 | - | echo "⚠ shellcheck not found, skipping..."; \ | 34 | + | CGO_ENABLED=0 go build $(GOFLAGS) $(LDFLAGS_STRIP) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) |
| 39 | - | echo "Install with: sudo pacman -S shellcheck"; \ | 35 | + | @echo "Release build complete: $(BUILD_DIR)/$(BINARY_NAME)" |
| 40 | - | exit 0; \ | 36 | + | @ls -lh $(BUILD_DIR)/$(BINARY_NAME) |
| 41 | - | fi | 37 | + | |
| 42 | - | @for script in install.sh install/bootstrap.sh install/helpers/*.sh install/preflight/*.sh install/partitioning/*.sh install/base/*.sh install/config/*.sh install/boot/*.sh install/repos/*.sh install/post-install/*.sh install/post-boot/*.sh; do \ | 38 | + | ## build-static: Build fully static binary (no dependencies) |
| 43 | - | if [ -f "$$script" ]; then \ | 39 | + | build-static: |
| 44 | - | echo ""; \ | 40 | + | @echo "Building static $(BINARY_NAME) $(VERSION)..." |
| 45 | - | echo "Checking: $$script"; \ | 41 | + | @mkdir -p $(BUILD_DIR) |
| 46 | - | shellcheck --exclude=SC1090,SC1091,SC2086,SC2004,SC2059,SC2129,SC2155 "$$script" || exit 1; \ | 42 | + | CGO_ENABLED=0 go build $(GOFLAGS) $(LDFLAGS) -a -installsuffix cgo -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) |
| 47 | - | fi \ | 43 | + | @echo "Static build complete: $(BUILD_DIR)/$(BINARY_NAME)" |
| 48 | - | done | |||
| 49 | - | @echo "" | |||
| 50 | - | @echo "✓ shellcheck passed" | |||
| 51 | 44 | ||||
| 52 | - | # Clean generated files | 45 | + | ## install: Install the binary to system |
| 46 | + | install: build | |||
| 47 | + | @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." | |||
| 48 | + | @sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) | |||
| 49 | + | @echo "Installed successfully" | |||
| 50 | + | ||||
| 51 | + | ## uninstall: Remove the binary from system | |||
| 52 | + | uninstall: | |||
| 53 | + | @echo "Uninstalling $(BINARY_NAME)..." | |||
| 54 | + | @sudo rm -f $(INSTALL_DIR)/$(BINARY_NAME) | |||
| 55 | + | @echo "Uninstalled successfully" | |||
| 56 | + | ||||
| 57 | + | ## run: Build and run the installer | |||
| 58 | + | run: build | |||
| 59 | + | @echo "Running $(BINARY_NAME)..." | |||
| 60 | + | @$(BUILD_DIR)/$(BINARY_NAME) | |||
| 61 | + | ||||
| 62 | + | ## test: Run tests | |||
| 63 | + | test: | |||
| 64 | + | @echo "Running tests..." | |||
| 65 | + | @go test -v -race -coverprofile=coverage.out ./... | |||
| 66 | + | @go tool cover -func=coverage.out | |||
| 67 | + | ||||
| 68 | + | ## fmt: Format all Go code | |||
| 69 | + | fmt: | |||
| 70 | + | @echo "Formatting code..." | |||
| 71 | + | @go fmt ./... | |||
| 72 | + | @gofmt -s -w . | |||
| 73 | + | ||||
| 74 | + | ## vet: Run go vet | |||
| 75 | + | vet: | |||
| 76 | + | @echo "Running go vet..." | |||
| 77 | + | @go vet ./... | |||
| 78 | + | ||||
| 79 | + | ## lint: Run golangci-lint (install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) | |||
| 80 | + | lint: | |||
| 81 | + | @echo "Running golangci-lint..." | |||
| 82 | + | @which golangci-lint > /dev/null || (echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1) | |||
| 83 | + | @golangci-lint run ./... | |||
| 84 | + | ||||
| 85 | + | ## mod-tidy: Tidy go.mod | |||
| 86 | + | mod-tidy: | |||
| 87 | + | @echo "Tidying go.mod..." | |||
| 88 | + | @go mod tidy | |||
| 89 | + | ||||
| 90 | + | ## clean: Remove build artifacts | |||
| 53 | clean: | 91 | clean: | ||
| 54 | - | @echo "Cleaning generated files..." | 92 | + | @echo "Cleaning build artifacts..." |
| 55 | - | @rm -f /var/log/archup-install.log | 93 | + | @rm -rf $(BUILD_DIR) |
| 56 | - | @echo "✓ Clean complete" | 94 | + | @rm -f coverage.out |
| 95 | + | @echo "Clean complete" | |||
| 96 | + | ||||
| 97 | + | ## version: Show version information | |||
| 98 | + | version: | |||
| 99 | + | @echo "Version: $(VERSION)" | |||
| 100 | + | ||||
| 101 | + | ## help: Show this help message | |||
| 102 | + | help: | |||
| 103 | + | @echo "ArchUp Go Installer - Makefile Commands" | |||
| 104 | + | @echo "" | |||
| 105 | + | @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' | |||
D Makefile.go +0 -105
| @@ -1,105 +0,0 @@ | |||
| 1 | - | # ArchUp Go Installer Makefile | |
| 2 | - | # Builds the Go-based installer with proper version injection | |
| 3 | - | ||
| 4 | - | # Get version from git tag or use dev | |
| 5 | - | VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") | |
| 6 | - | LDFLAGS := -ldflags "-X main.version=$(VERSION)" | |
| 7 | - | LDFLAGS_STRIP := -ldflags "-X main.version=$(VERSION) -s -w" | |
| 8 | - | ||
| 9 | - | # Build settings | |
| 10 | - | BINARY_NAME := archup-installer | |
| 11 | - | BUILD_DIR := build | |
| 12 | - | CMD_DIR := cmd/archup-installer | |
| 13 | - | INSTALL_DIR := /usr/local/bin | |
| 14 | - | ||
| 15 | - | # Go build flags | |
| 16 | - | GOFLAGS := -trimpath | |
| 17 | - | CGO_ENABLED := 0 | |
| 18 | - | ||
| 19 | - | .PHONY: all build clean install uninstall run test fmt vet lint mod-tidy help | |
| 20 | - | ||
| 21 | - | all: build | |
| 22 | - | ||
| 23 | - | ## build: Build the installer binary with version injection | |
| 24 | - | build: | |
| 25 | - | @echo "Building $(BINARY_NAME) $(VERSION)..." | |
| 26 | - | @mkdir -p $(BUILD_DIR) | |
| 27 | - | CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |
| 28 | - | @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |
| 29 | - | ||
| 30 | - | ## build-release: Build optimized release binary (stripped, smaller size) | |
| 31 | - | build-release: | |
| 32 | - | @echo "Building release $(BINARY_NAME) $(VERSION)..." | |
| 33 | - | @mkdir -p $(BUILD_DIR) | |
| 34 | - | CGO_ENABLED=0 go build $(GOFLAGS) $(LDFLAGS_STRIP) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |
| 35 | - | @echo "Release build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |
| 36 | - | @ls -lh $(BUILD_DIR)/$(BINARY_NAME) | |
| 37 | - | ||
| 38 | - | ## build-static: Build fully static binary (no dependencies) | |
| 39 | - | build-static: | |
| 40 | - | @echo "Building static $(BINARY_NAME) $(VERSION)..." | |
| 41 | - | @mkdir -p $(BUILD_DIR) | |
| 42 | - | CGO_ENABLED=0 go build $(GOFLAGS) $(LDFLAGS) -a -installsuffix cgo -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |
| 43 | - | @echo "Static build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |
| 44 | - | ||
| 45 | - | ## install: Install the binary to system | |
| 46 | - | install: build | |
| 47 | - | @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." | |
| 48 | - | @sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) | |
| 49 | - | @echo "Installed successfully" | |
| 50 | - | ||
| 51 | - | ## uninstall: Remove the binary from system | |
| 52 | - | uninstall: | |
| 53 | - | @echo "Uninstalling $(BINARY_NAME)..." | |
| 54 | - | @sudo rm -f $(INSTALL_DIR)/$(BINARY_NAME) | |
| 55 | - | @echo "Uninstalled successfully" | |
| 56 | - | ||
| 57 | - | ## run: Build and run the installer | |
| 58 | - | run: build | |
| 59 | - | @echo "Running $(BINARY_NAME)..." | |
| 60 | - | @$(BUILD_DIR)/$(BINARY_NAME) | |
| 61 | - | ||
| 62 | - | ## test: Run tests | |
| 63 | - | test: | |
| 64 | - | @echo "Running tests..." | |
| 65 | - | @go test -v -race -coverprofile=coverage.out ./... | |
| 66 | - | @go tool cover -func=coverage.out | |
| 67 | - | ||
| 68 | - | ## fmt: Format all Go code | |
| 69 | - | fmt: | |
| 70 | - | @echo "Formatting code..." | |
| 71 | - | @go fmt ./... | |
| 72 | - | @gofmt -s -w . | |
| 73 | - | ||
| 74 | - | ## vet: Run go vet | |
| 75 | - | vet: | |
| 76 | - | @echo "Running go vet..." | |
| 77 | - | @go vet ./... | |
| 78 | - | ||
| 79 | - | ## lint: Run golangci-lint (install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) | |
| 80 | - | lint: | |
| 81 | - | @echo "Running golangci-lint..." | |
| 82 | - | @which golangci-lint > /dev/null || (echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1) | |
| 83 | - | @golangci-lint run ./... | |
| 84 | - | ||
| 85 | - | ## mod-tidy: Tidy go.mod | |
| 86 | - | mod-tidy: | |
| 87 | - | @echo "Tidying go.mod..." | |
| 88 | - | @go mod tidy | |
| 89 | - | ||
| 90 | - | ## clean: Remove build artifacts | |
| 91 | - | clean: | |
| 92 | - | @echo "Cleaning build artifacts..." | |
| 93 | - | @rm -rf $(BUILD_DIR) | |
| 94 | - | @rm -f coverage.out | |
| 95 | - | @echo "Clean complete" | |
| 96 | - | ||
| 97 | - | ## version: Show version information | |
| 98 | - | version: | |
| 99 | - | @echo "Version: $(VERSION)" | |
| 100 | - | ||
| 101 | - | ## help: Show this help message | |
| 102 | - | help: | |
| 103 | - | @echo "ArchUp Go Installer - Makefile Commands" | |
| 104 | - | @echo "" | |
| 105 | - | @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' | |
| @@ -1,105 +0,0 @@ | |||||
| 1 | - | # ArchUp Go Installer Makefile | |||
| 2 | - | # Builds the Go-based installer with proper version injection | |||
| 3 | - | ||||
| 4 | - | # Get version from git tag or use dev | |||
| 5 | - | VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") | |||
| 6 | - | LDFLAGS := -ldflags "-X main.version=$(VERSION)" | |||
| 7 | - | LDFLAGS_STRIP := -ldflags "-X main.version=$(VERSION) -s -w" | |||
| 8 | - | ||||
| 9 | - | # Build settings | |||
| 10 | - | BINARY_NAME := archup-installer | |||
| 11 | - | BUILD_DIR := build | |||
| 12 | - | CMD_DIR := cmd/archup-installer | |||
| 13 | - | INSTALL_DIR := /usr/local/bin | |||
| 14 | - | ||||
| 15 | - | # Go build flags | |||
| 16 | - | GOFLAGS := -trimpath | |||
| 17 | - | CGO_ENABLED := 0 | |||
| 18 | - | ||||
| 19 | - | .PHONY: all build clean install uninstall run test fmt vet lint mod-tidy help | |||
| 20 | - | ||||
| 21 | - | all: build | |||
| 22 | - | ||||
| 23 | - | ## build: Build the installer binary with version injection | |||
| 24 | - | build: | |||
| 25 | - | @echo "Building $(BINARY_NAME) $(VERSION)..." | |||
| 26 | - | @mkdir -p $(BUILD_DIR) | |||
| 27 | - | CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |||
| 28 | - | @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |||
| 29 | - | ||||
| 30 | - | ## build-release: Build optimized release binary (stripped, smaller size) | |||
| 31 | - | build-release: | |||
| 32 | - | @echo "Building release $(BINARY_NAME) $(VERSION)..." | |||
| 33 | - | @mkdir -p $(BUILD_DIR) | |||
| 34 | - | CGO_ENABLED=0 go build $(GOFLAGS) $(LDFLAGS_STRIP) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |||
| 35 | - | @echo "Release build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |||
| 36 | - | @ls -lh $(BUILD_DIR)/$(BINARY_NAME) | |||
| 37 | - | ||||
| 38 | - | ## build-static: Build fully static binary (no dependencies) | |||
| 39 | - | build-static: | |||
| 40 | - | @echo "Building static $(BINARY_NAME) $(VERSION)..." | |||
| 41 | - | @mkdir -p $(BUILD_DIR) | |||
| 42 | - | CGO_ENABLED=0 go build $(GOFLAGS) $(LDFLAGS) -a -installsuffix cgo -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) | |||
| 43 | - | @echo "Static build complete: $(BUILD_DIR)/$(BINARY_NAME)" | |||
| 44 | - | ||||
| 45 | - | ## install: Install the binary to system | |||
| 46 | - | install: build | |||
| 47 | - | @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." | |||
| 48 | - | @sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) | |||
| 49 | - | @echo "Installed successfully" | |||
| 50 | - | ||||
| 51 | - | ## uninstall: Remove the binary from system | |||
| 52 | - | uninstall: | |||
| 53 | - | @echo "Uninstalling $(BINARY_NAME)..." | |||
| 54 | - | @sudo rm -f $(INSTALL_DIR)/$(BINARY_NAME) | |||
| 55 | - | @echo "Uninstalled successfully" | |||
| 56 | - | ||||
| 57 | - | ## run: Build and run the installer | |||
| 58 | - | run: build | |||
| 59 | - | @echo "Running $(BINARY_NAME)..." | |||
| 60 | - | @$(BUILD_DIR)/$(BINARY_NAME) | |||
| 61 | - | ||||
| 62 | - | ## test: Run tests | |||
| 63 | - | test: | |||
| 64 | - | @echo "Running tests..." | |||
| 65 | - | @go test -v -race -coverprofile=coverage.out ./... | |||
| 66 | - | @go tool cover -func=coverage.out | |||
| 67 | - | ||||
| 68 | - | ## fmt: Format all Go code | |||
| 69 | - | fmt: | |||
| 70 | - | @echo "Formatting code..." | |||
| 71 | - | @go fmt ./... | |||
| 72 | - | @gofmt -s -w . | |||
| 73 | - | ||||
| 74 | - | ## vet: Run go vet | |||
| 75 | - | vet: | |||
| 76 | - | @echo "Running go vet..." | |||
| 77 | - | @go vet ./... | |||
| 78 | - | ||||
| 79 | - | ## lint: Run golangci-lint (install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) | |||
| 80 | - | lint: | |||
| 81 | - | @echo "Running golangci-lint..." | |||
| 82 | - | @which golangci-lint > /dev/null || (echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1) | |||
| 83 | - | @golangci-lint run ./... | |||
| 84 | - | ||||
| 85 | - | ## mod-tidy: Tidy go.mod | |||
| 86 | - | mod-tidy: | |||
| 87 | - | @echo "Tidying go.mod..." | |||
| 88 | - | @go mod tidy | |||
| 89 | - | ||||
| 90 | - | ## clean: Remove build artifacts | |||
| 91 | - | clean: | |||
| 92 | - | @echo "Cleaning build artifacts..." | |||
| 93 | - | @rm -rf $(BUILD_DIR) | |||
| 94 | - | @rm -f coverage.out | |||
| 95 | - | @echo "Clean complete" | |||
| 96 | - | ||||
| 97 | - | ## version: Show version information | |||
| 98 | - | version: | |||
| 99 | - | @echo "Version: $(VERSION)" | |||
| 100 | - | ||||
| 101 | - | ## help: Show this help message | |||
| 102 | - | help: | |||
| 103 | - | @echo "ArchUp Go Installer - Makefile Commands" | |||
| 104 | - | @echo "" | |||
| 105 | - | @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' | |||
A Makefile.sh +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | .PHONY: check check-syntax check-shellcheck clean help | |
| 2 | + | ||
| 3 | + | # Default target | |
| 4 | + | help: | |
| 5 | + | @echo "archup - Makefile targets:" | |
| 6 | + | @echo "" | |
| 7 | + | @echo " make check - Run all checks (syntax + shellcheck)" | |
| 8 | + | @echo " make check-syntax - Check shell script syntax with bash -n" | |
| 9 | + | @echo " make check-shellcheck - Run shellcheck linting" | |
| 10 | + | @echo " make clean - Remove generated files" | |
| 11 | + | @echo "" | |
| 12 | + | ||
| 13 | + | # Run all checks | |
| 14 | + | check: check-syntax check-shellcheck | |
| 15 | + | @echo "" | |
| 16 | + | @echo "✓ All checks passed!" | |
| 17 | + | ||
| 18 | + | # Check syntax with bash -n | |
| 19 | + | check-syntax: | |
| 20 | + | @echo "=========================================" | |
| 21 | + | @echo "Checking shell script syntax..." | |
| 22 | + | @echo "=========================================" | |
| 23 | + | @for script in install.sh install/bootstrap.sh install/helpers/*.sh install/preflight/*.sh install/partitioning/*.sh install/base/*.sh install/config/*.sh install/boot/*.sh install/repos/*.sh install/post-install/*.sh install/post-boot/*.sh; do \ | |
| 24 | + | if [ -f "$$script" ]; then \ | |
| 25 | + | echo "Checking: $$script"; \ | |
| 26 | + | bash -n "$$script" || exit 1; \ | |
| 27 | + | fi \ | |
| 28 | + | done | |
| 29 | + | @echo "✓ Syntax check passed" | |
| 30 | + | ||
| 31 | + | # Run shellcheck | |
| 32 | + | check-shellcheck: | |
| 33 | + | @echo "" | |
| 34 | + | @echo "=========================================" | |
| 35 | + | @echo "Running shellcheck..." | |
| 36 | + | @echo "=========================================" | |
| 37 | + | @if ! command -v shellcheck >/dev/null 2>&1; then \ | |
| 38 | + | echo "⚠ shellcheck not found, skipping..."; \ | |
| 39 | + | echo "Install with: sudo pacman -S shellcheck"; \ | |
| 40 | + | exit 0; \ | |
| 41 | + | fi | |
| 42 | + | @for script in install.sh install/bootstrap.sh install/helpers/*.sh install/preflight/*.sh install/partitioning/*.sh install/base/*.sh install/config/*.sh install/boot/*.sh install/repos/*.sh install/post-install/*.sh install/post-boot/*.sh; do \ | |
| 43 | + | if [ -f "$$script" ]; then \ | |
| 44 | + | echo ""; \ | |
| 45 | + | echo "Checking: $$script"; \ | |
| 46 | + | shellcheck --exclude=SC1090,SC1091,SC2086,SC2004,SC2059,SC2129,SC2155 "$$script" || exit 1; \ | |
| 47 | + | fi \ | |
| 48 | + | done | |
| 49 | + | @echo "" | |
| 50 | + | @echo "✓ shellcheck passed" | |
| 51 | + | ||
| 52 | + | # Clean generated files | |
| 53 | + | clean: | |
| 54 | + | @echo "Cleaning generated files..." | |
| 55 | + | @rm -f /var/log/archup-install.log | |
| 56 | + | @echo "✓ Clean complete" | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | .PHONY: check check-syntax check-shellcheck clean help | |||
| 2 | + | ||||
| 3 | + | # Default target | |||
| 4 | + | help: | |||
| 5 | + | @echo "archup - Makefile targets:" | |||
| 6 | + | @echo "" | |||
| 7 | + | @echo " make check - Run all checks (syntax + shellcheck)" | |||
| 8 | + | @echo " make check-syntax - Check shell script syntax with bash -n" | |||
| 9 | + | @echo " make check-shellcheck - Run shellcheck linting" | |||
| 10 | + | @echo " make clean - Remove generated files" | |||
| 11 | + | @echo "" | |||
| 12 | + | ||||
| 13 | + | # Run all checks | |||
| 14 | + | check: check-syntax check-shellcheck | |||
| 15 | + | @echo "" | |||
| 16 | + | @echo "✓ All checks passed!" | |||
| 17 | + | ||||
| 18 | + | # Check syntax with bash -n | |||
| 19 | + | check-syntax: | |||
| 20 | + | @echo "=========================================" | |||
| 21 | + | @echo "Checking shell script syntax..." | |||
| 22 | + | @echo "=========================================" | |||
| 23 | + | @for script in install.sh install/bootstrap.sh install/helpers/*.sh install/preflight/*.sh install/partitioning/*.sh install/base/*.sh install/config/*.sh install/boot/*.sh install/repos/*.sh install/post-install/*.sh install/post-boot/*.sh; do \ | |||
| 24 | + | if [ -f "$$script" ]; then \ | |||
| 25 | + | echo "Checking: $$script"; \ | |||
| 26 | + | bash -n "$$script" || exit 1; \ | |||
| 27 | + | fi \ | |||
| 28 | + | done | |||
| 29 | + | @echo "✓ Syntax check passed" | |||
| 30 | + | ||||
| 31 | + | # Run shellcheck | |||
| 32 | + | check-shellcheck: | |||
| 33 | + | @echo "" | |||
| 34 | + | @echo "=========================================" | |||
| 35 | + | @echo "Running shellcheck..." | |||
| 36 | + | @echo "=========================================" | |||
| 37 | + | @if ! command -v shellcheck >/dev/null 2>&1; then \ | |||
| 38 | + | echo "⚠ shellcheck not found, skipping..."; \ | |||
| 39 | + | echo "Install with: sudo pacman -S shellcheck"; \ | |||
| 40 | + | exit 0; \ | |||
| 41 | + | fi | |||
| 42 | + | @for script in install.sh install/bootstrap.sh install/helpers/*.sh install/preflight/*.sh install/partitioning/*.sh install/base/*.sh install/config/*.sh install/boot/*.sh install/repos/*.sh install/post-install/*.sh install/post-boot/*.sh; do \ | |||
| 43 | + | if [ -f "$$script" ]; then \ | |||
| 44 | + | echo ""; \ | |||
| 45 | + | echo "Checking: $$script"; \ | |||
| 46 | + | shellcheck --exclude=SC1090,SC1091,SC2086,SC2004,SC2059,SC2129,SC2155 "$$script" || exit 1; \ | |||
| 47 | + | fi \ | |||
| 48 | + | done | |||
| 49 | + | @echo "" | |||
| 50 | + | @echo "✓ shellcheck passed" | |||
| 51 | + | ||||
| 52 | + | # Clean generated files | |||
| 53 | + | clean: | |||
| 54 | + | @echo "Cleaning generated files..." | |||
| 55 | + | @rm -f /var/log/archup-install.log | |||
| 56 | + | @echo "✓ Clean complete" | |||
M internal/config/config.go +19 -0
| @@ -183,6 +183,25 @@ var PlymouthFiles = []string{ | |||
| 183 | 183 | "progress_box.png", | |
| 184 | 184 | } | |
| 185 | 185 | ||
| 186 | + | // Post-boot paths and files | |
| 187 | + | const ( | |
| 188 | + | PathMntPostBoot = "/mnt/usr/local/share/archup/post-boot" | |
| 189 | + | PathMntSystemdSystem = "/mnt/etc/systemd/system" | |
| 190 | + | PostBootServiceName = "archup-first-boot.service" | |
| 191 | + | ) | |
| 192 | + | ||
| 193 | + | // Post-boot script files to download | |
| 194 | + | var PostBootScripts = []string{ | |
| 195 | + | "all.sh", | |
| 196 | + | "snapper.sh", | |
| 197 | + | "ufw.sh", | |
| 198 | + | "ssh-keygen.sh", | |
| 199 | + | "archup-cli.sh", | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // Post-boot service template URL path | |
| 203 | + | const PostBootServiceTemplate = "assets/systemd/archup-first-boot.service" | |
| 204 | + | ||
| 186 | 205 | // Config holds the installation configuration | |
| 187 | 206 | // This mirrors the shell script's config format for compatibility | |
| 188 | 207 | type Config struct { | |
| @@ -183,6 +183,25 @@ var PlymouthFiles = []string{ | |||||
| 183 | "progress_box.png", | 183 | "progress_box.png", | ||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| 186 | + | // Post-boot paths and files | |||
| 187 | + | const ( | |||
| 188 | + | PathMntPostBoot = "/mnt/usr/local/share/archup/post-boot" | |||
| 189 | + | PathMntSystemdSystem = "/mnt/etc/systemd/system" | |||
| 190 | + | PostBootServiceName = "archup-first-boot.service" | |||
| 191 | + | ) | |||
| 192 | + | ||||
| 193 | + | // Post-boot script files to download | |||
| 194 | + | var PostBootScripts = []string{ | |||
| 195 | + | "all.sh", | |||
| 196 | + | "snapper.sh", | |||
| 197 | + | "ufw.sh", | |||
| 198 | + | "ssh-keygen.sh", | |||
| 199 | + | "archup-cli.sh", | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Post-boot service template URL path | |||
| 203 | + | const PostBootServiceTemplate = "assets/systemd/archup-first-boot.service" | |||
| 204 | + | ||||
| 186 | // Config holds the installation configuration | 205 | // Config holds the installation configuration | ||
| 187 | // This mirrors the shell script's config format for compatibility | 206 | // This mirrors the shell script's config format for compatibility | ||
| 188 | type Config struct { | 207 | type Config struct { | ||
M internal/phases/orchestrator.go +30 -15
| @@ -2,27 +2,32 @@ package phases | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/archup/internal/config" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // Orchestrator manages the execution of installation phases | |
| 10 | 11 | type Orchestrator struct { | |
| 11 | - | phases []Phase | |
| 12 | - | currentIdx int | |
| 13 | - | config *config.Config | |
| 14 | - | logPath string | |
| 15 | - | progressCh chan ProgressUpdate | |
| 12 | + | phases []Phase | |
| 13 | + | currentIdx int | |
| 14 | + | config *config.Config | |
| 15 | + | logPath string | |
| 16 | + | progressCh chan ProgressUpdate | |
| 17 | + | installStartTime time.Time | |
| 18 | + | phaseDurations map[string]time.Duration | |
| 16 | 19 | } | |
| 17 | 20 | ||
| 18 | 21 | // NewOrchestrator creates a new phase orchestrator | |
| 19 | 22 | func NewOrchestrator(cfg *config.Config, logPath string) *Orchestrator { | |
| 20 | 23 | return &Orchestrator{ | |
| 21 | - | phases: make([]Phase, 0), | |
| 22 | - | currentIdx: -1, | |
| 23 | - | config: cfg, | |
| 24 | - | logPath: logPath, | |
| 25 | - | progressCh: make(chan ProgressUpdate, 100), | |
| 24 | + | phases: make([]Phase, 0), | |
| 25 | + | currentIdx: -1, | |
| 26 | + | config: cfg, | |
| 27 | + | logPath: logPath, | |
| 28 | + | progressCh: make(chan ProgressUpdate, 100), | |
| 29 | + | installStartTime: time.Now(), | |
| 30 | + | phaseDurations: make(map[string]time.Duration), | |
| 26 | 31 | } | |
| 27 | 32 | } | |
| 28 | 33 | ||
| @@ -75,7 +80,6 @@ func (o *Orchestrator) ProgressChannel() <-chan ProgressUpdate { | |||
| 75 | 80 | ||
| 76 | 81 | // ExecutePhase runs a specific phase | |
| 77 | 82 | func (o *Orchestrator) ExecutePhase(phase Phase) error { | |
| 78 | - | // Update current index | |
| 79 | 83 | for i, p := range o.phases { | |
| 80 | 84 | if p.Name() == phase.Name() { | |
| 81 | 85 | o.currentIdx = i | |
| @@ -83,19 +87,21 @@ func (o *Orchestrator) ExecutePhase(phase Phase) error { | |||
| 83 | 87 | } | |
| 84 | 88 | } | |
| 85 | 89 | ||
| 86 | - | // Pre-check | |
| 90 | + | startTime := time.Now() | |
| 91 | + | ||
| 87 | 92 | phase.SetStatus(StatusRunning) | |
| 88 | 93 | if err := phase.PreCheck(); err != nil { | |
| 89 | 94 | phase.SetStatus(StatusFailed) | |
| 90 | 95 | return fmt.Errorf("pre-check failed: %w", err) | |
| 91 | 96 | } | |
| 92 | 97 | ||
| 93 | - | // Execute | |
| 94 | 98 | result := phase.Execute(o.progressCh) | |
| 99 | + | duration := time.Since(startTime) | |
| 100 | + | o.phaseDurations[phase.Name()] = duration | |
| 101 | + | ||
| 95 | 102 | if !result.Success { | |
| 96 | 103 | phase.SetStatus(StatusFailed) | |
| 97 | 104 | ||
| 98 | - | // Attempt rollback | |
| 99 | 105 | if rbErr := phase.Rollback(); rbErr != nil { | |
| 100 | 106 | return fmt.Errorf("execution failed: %v, rollback failed: %v", result.Error, rbErr) | |
| 101 | 107 | } | |
| @@ -103,7 +109,6 @@ func (o *Orchestrator) ExecutePhase(phase Phase) error { | |||
| 103 | 109 | return fmt.Errorf("execution failed: %w", result.Error) | |
| 104 | 110 | } | |
| 105 | 111 | ||
| 106 | - | // Post-check | |
| 107 | 112 | if err := phase.PostCheck(); err != nil { | |
| 108 | 113 | phase.SetStatus(StatusFailed) | |
| 109 | 114 | return fmt.Errorf("post-check failed: %w", err) | |
| @@ -185,3 +190,13 @@ func (o *Orchestrator) HasFailed() bool { | |||
| 185 | 190 | } | |
| 186 | 191 | return false | |
| 187 | 192 | } | |
| 193 | + | ||
| 194 | + | // GetTotalDuration returns total installation time | |
| 195 | + | func (o *Orchestrator) GetTotalDuration() time.Duration { | |
| 196 | + | return time.Since(o.installStartTime) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // GetPhaseDurations returns timing breakdown by phase | |
| 200 | + | func (o *Orchestrator) GetPhaseDurations() map[string]time.Duration { | |
| 201 | + | return o.phaseDurations | |
| 202 | + | } | |
| @@ -2,27 +2,32 @@ package phases | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/archup/internal/config" | 7 | "github.com/bnema/archup/internal/config" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | // Orchestrator manages the execution of installation phases | 10 | // Orchestrator manages the execution of installation phases | ||
| 10 | type Orchestrator struct { | 11 | type Orchestrator struct { | ||
| 11 | - | phases []Phase | 12 | + | phases []Phase |
| 12 | - | currentIdx int | 13 | + | currentIdx int |
| 13 | - | config *config.Config | 14 | + | config *config.Config |
| 14 | - | logPath string | 15 | + | logPath string |
| 15 | - | progressCh chan ProgressUpdate | 16 | + | progressCh chan ProgressUpdate |
| 17 | + | installStartTime time.Time | |||
| 18 | + | phaseDurations map[string]time.Duration | |||
| 16 | } | 19 | } | ||
| 17 | 20 | ||||
| 18 | // NewOrchestrator creates a new phase orchestrator | 21 | // NewOrchestrator creates a new phase orchestrator | ||
| 19 | func NewOrchestrator(cfg *config.Config, logPath string) *Orchestrator { | 22 | func NewOrchestrator(cfg *config.Config, logPath string) *Orchestrator { | ||
| 20 | return &Orchestrator{ | 23 | return &Orchestrator{ | ||
| 21 | - | phases: make([]Phase, 0), | 24 | + | phases: make([]Phase, 0), |
| 22 | - | currentIdx: -1, | 25 | + | currentIdx: -1, |
| 23 | - | config: cfg, | 26 | + | config: cfg, |
| 24 | - | logPath: logPath, | 27 | + | logPath: logPath, |
| 25 | - | progressCh: make(chan ProgressUpdate, 100), | 28 | + | progressCh: make(chan ProgressUpdate, 100), |
| 29 | + | installStartTime: time.Now(), | |||
| 30 | + | phaseDurations: make(map[string]time.Duration), | |||
| 26 | } | 31 | } | ||
| 27 | } | 32 | } | ||
| 28 | 33 | ||||
| @@ -75,7 +80,6 @@ func (o *Orchestrator) ProgressChannel() <-chan ProgressUpdate { | |||||
| 75 | 80 | ||||
| 76 | // ExecutePhase runs a specific phase | 81 | // ExecutePhase runs a specific phase | ||
| 77 | func (o *Orchestrator) ExecutePhase(phase Phase) error { | 82 | func (o *Orchestrator) ExecutePhase(phase Phase) error { | ||
| 78 | - | // Update current index | |||
| 79 | for i, p := range o.phases { | 83 | for i, p := range o.phases { | ||
| 80 | if p.Name() == phase.Name() { | 84 | if p.Name() == phase.Name() { | ||
| 81 | o.currentIdx = i | 85 | o.currentIdx = i | ||
| @@ -83,19 +87,21 @@ func (o *Orchestrator) ExecutePhase(phase Phase) error { | |||||
| 83 | } | 87 | } | ||
| 84 | } | 88 | } | ||
| 85 | 89 | ||||
| 86 | - | // Pre-check | 90 | + | startTime := time.Now() |
| 91 | + | ||||
| 87 | phase.SetStatus(StatusRunning) | 92 | phase.SetStatus(StatusRunning) | ||
| 88 | if err := phase.PreCheck(); err != nil { | 93 | if err := phase.PreCheck(); err != nil { | ||
| 89 | phase.SetStatus(StatusFailed) | 94 | phase.SetStatus(StatusFailed) | ||
| 90 | return fmt.Errorf("pre-check failed: %w", err) | 95 | return fmt.Errorf("pre-check failed: %w", err) | ||
| 91 | } | 96 | } | ||
| 92 | 97 | ||||
| 93 | - | // Execute | |||
| 94 | result := phase.Execute(o.progressCh) | 98 | result := phase.Execute(o.progressCh) | ||
| 99 | + | duration := time.Since(startTime) | |||
| 100 | + | o.phaseDurations[phase.Name()] = duration | |||
| 101 | + | ||||
| 95 | if !result.Success { | 102 | if !result.Success { | ||
| 96 | phase.SetStatus(StatusFailed) | 103 | phase.SetStatus(StatusFailed) | ||
| 97 | 104 | ||||
| 98 | - | // Attempt rollback | |||
| 99 | if rbErr := phase.Rollback(); rbErr != nil { | 105 | if rbErr := phase.Rollback(); rbErr != nil { | ||
| 100 | return fmt.Errorf("execution failed: %v, rollback failed: %v", result.Error, rbErr) | 106 | return fmt.Errorf("execution failed: %v, rollback failed: %v", result.Error, rbErr) | ||
| 101 | } | 107 | } | ||
| @@ -103,7 +109,6 @@ func (o *Orchestrator) ExecutePhase(phase Phase) error { | |||||
| 103 | return fmt.Errorf("execution failed: %w", result.Error) | 109 | return fmt.Errorf("execution failed: %w", result.Error) | ||
| 104 | } | 110 | } | ||
| 105 | 111 | ||||
| 106 | - | // Post-check | |||
| 107 | if err := phase.PostCheck(); err != nil { | 112 | if err := phase.PostCheck(); err != nil { | ||
| 108 | phase.SetStatus(StatusFailed) | 113 | phase.SetStatus(StatusFailed) | ||
| 109 | return fmt.Errorf("post-check failed: %w", err) | 114 | return fmt.Errorf("post-check failed: %w", err) | ||
| @@ -185,3 +190,13 @@ func (o *Orchestrator) HasFailed() bool { | |||||
| 185 | } | 190 | } | ||
| 186 | return false | 191 | return false | ||
| 187 | } | 192 | } | ||
| 193 | + | ||||
| 194 | + | // GetTotalDuration returns total installation time | |||
| 195 | + | func (o *Orchestrator) GetTotalDuration() time.Duration { | |||
| 196 | + | return time.Since(o.installStartTime) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // GetPhaseDurations returns timing breakdown by phase | |||
| 200 | + | func (o *Orchestrator) GetPhaseDurations() map[string]time.Duration { | |||
| 201 | + | return o.phaseDurations | |||
| 202 | + | } | |||
M internal/phases/phase.go +2 -2
| @@ -46,7 +46,7 @@ type PhaseResult struct { | |||
| 46 | 46 | type ProgressUpdate struct { | |
| 47 | 47 | PhaseName string | |
| 48 | 48 | Step string | |
| 49 | - | OutputLine string // For streaming command output (e.g., pacman) | |
| 49 | + | OutputLine string | |
| 50 | 50 | Current int | |
| 51 | 51 | Total int | |
| 52 | 52 | IsComplete bool | |
| @@ -94,7 +94,7 @@ type BasePhase struct { | |||
| 94 | 94 | description string | |
| 95 | 95 | status PhaseStatus | |
| 96 | 96 | config *config.Config | |
| 97 | - | logger *logger.Logger // Logger with dry-run support | |
| 97 | + | logger *logger.Logger | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | // NewBasePhase creates a new base phase | |
| @@ -46,7 +46,7 @@ type PhaseResult struct { | |||||
| 46 | type ProgressUpdate struct { | 46 | type ProgressUpdate struct { | ||
| 47 | PhaseName string | 47 | PhaseName string | ||
| 48 | Step string | 48 | Step string | ||
| 49 | - | OutputLine string // For streaming command output (e.g., pacman) | 49 | + | OutputLine string |
| 50 | Current int | 50 | Current int | ||
| 51 | Total int | 51 | Total int | ||
| 52 | IsComplete bool | 52 | IsComplete bool | ||
| @@ -94,7 +94,7 @@ type BasePhase struct { | |||||
| 94 | description string | 94 | description string | ||
| 95 | status PhaseStatus | 95 | status PhaseStatus | ||
| 96 | config *config.Config | 96 | config *config.Config | ||
| 97 | - | logger *logger.Logger // Logger with dry-run support | 97 | + | logger *logger.Logger |
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | // NewBasePhase creates a new base phase | 100 | // NewBasePhase creates a new base phase | ||
M internal/phases/postinstall.go +124 -3
| @@ -85,10 +85,14 @@ func (p *PostInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResu | |||
| 85 | 85 | return PhaseResult{Success: false, Error: err} | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | // Step 5: Post-boot setup (skipped for now) | |
| 88 | + | // Step 5: Post-boot setup | |
| 89 | 89 | currentStep++ | |
| 90 | - | p.SendProgress(progressChan, "Post-boot setup...", currentStep, totalSteps) | |
| 91 | - | p.SendOutput(progressChan, "[SKIP] Post-boot setup not implemented") | |
| 90 | + | p.SendProgress(progressChan, "Setting up post-boot scripts...", currentStep, totalSteps) | |
| 91 | + | switch err := p.setupPostBoot(progressChan); { | |
| 92 | + | case err != nil: | |
| 93 | + | p.SendError(progressChan, err) | |
| 94 | + | return PhaseResult{Success: false, Error: err} | |
| 95 | + | } | |
| 92 | 96 | ||
| 93 | 97 | // Step 6: Pacman hooks | |
| 94 | 98 | currentStep++ | |
| @@ -642,3 +646,120 @@ func (p *PostInstallPhase) Rollback() error { | |||
| 642 | 646 | func (p *PostInstallPhase) CanSkip() bool { | |
| 643 | 647 | return false | |
| 644 | 648 | } | |
| 649 | + | ||
| 650 | + | // setupPostBoot downloads and configures first-boot service | |
| 651 | + | func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) error { | |
| 652 | + | p.SendOutput(progressChan, "Configuring first-boot service...") | |
| 653 | + | ||
| 654 | + | switch err := os.MkdirAll(config.PathMntPostBoot, 0755); { | |
| 655 | + | case err != nil: | |
| 656 | + | return fmt.Errorf("failed to create post-boot directory: %w", err) | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | // Download logo.txt | |
| 660 | + | p.SendOutput(progressChan, "Downloading logo.txt...") | |
| 661 | + | logoURL := fmt.Sprintf("%s/logo.txt", p.config.RawURL) | |
| 662 | + | resp, err := http.Get(logoURL) | |
| 663 | + | switch { | |
| 664 | + | case err != nil: | |
| 665 | + | return fmt.Errorf("failed to download logo.txt: %w", err) | |
| 666 | + | case resp.StatusCode != http.StatusOK: | |
| 667 | + | resp.Body.Close() | |
| 668 | + | return fmt.Errorf("failed to download logo.txt: HTTP %d", resp.StatusCode) | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt") | |
| 672 | + | logoFile, err := os.Create(logoPath) | |
| 673 | + | switch { | |
| 674 | + | case err != nil: | |
| 675 | + | resp.Body.Close() | |
| 676 | + | return fmt.Errorf("failed to create logo.txt: %w", err) | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | _, err = io.Copy(logoFile, resp.Body) | |
| 680 | + | logoFile.Close() | |
| 681 | + | resp.Body.Close() | |
| 682 | + | switch { | |
| 683 | + | case err != nil: | |
| 684 | + | return fmt.Errorf("failed to save logo.txt: %w", err) | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | // Download all post-boot scripts | |
| 688 | + | for _, script := range config.PostBootScripts { | |
| 689 | + | p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", script)) | |
| 690 | + | ||
| 691 | + | scriptURL := fmt.Sprintf("%s/assets/post-boot/%s", p.config.RawURL, script) | |
| 692 | + | resp, err := http.Get(scriptURL) | |
| 693 | + | switch { | |
| 694 | + | case err != nil: | |
| 695 | + | return fmt.Errorf("failed to download %s: %w", script, err) | |
| 696 | + | case resp.StatusCode != http.StatusOK: | |
| 697 | + | resp.Body.Close() | |
| 698 | + | return fmt.Errorf("failed to download %s: HTTP %d", script, resp.StatusCode) | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | scriptPath := filepath.Join(config.PathMntPostBoot, script) | |
| 702 | + | scriptFile, err := os.Create(scriptPath) | |
| 703 | + | switch { | |
| 704 | + | case err != nil: | |
| 705 | + | resp.Body.Close() | |
| 706 | + | return fmt.Errorf("failed to create %s: %w", script, err) | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | _, err = io.Copy(scriptFile, resp.Body) | |
| 710 | + | scriptFile.Close() | |
| 711 | + | resp.Body.Close() | |
| 712 | + | ||
| 713 | + | switch { | |
| 714 | + | case err != nil: | |
| 715 | + | return fmt.Errorf("failed to save %s: %w", script, err) | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | switch err := os.Chmod(scriptPath, 0755); { | |
| 719 | + | case err != nil: | |
| 720 | + | return fmt.Errorf("failed to set permissions on %s: %w", script, err) | |
| 721 | + | } | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | p.SendOutput(progressChan, "[OK] Post-boot scripts downloaded") | |
| 725 | + | ||
| 726 | + | // Download service template | |
| 727 | + | p.SendOutput(progressChan, "Creating systemd service...") | |
| 728 | + | serviceURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.PostBootServiceTemplate) | |
| 729 | + | resp, err = http.Get(serviceURL) | |
| 730 | + | switch { | |
| 731 | + | case err != nil: | |
| 732 | + | return fmt.Errorf("failed to download service template: %w", err) | |
| 733 | + | case resp.StatusCode != http.StatusOK: | |
| 734 | + | resp.Body.Close() | |
| 735 | + | return fmt.Errorf("failed to download service template: HTTP %d", resp.StatusCode) | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | templateBytes, err := io.ReadAll(resp.Body) | |
| 739 | + | resp.Body.Close() | |
| 740 | + | switch { | |
| 741 | + | case err != nil: | |
| 742 | + | return fmt.Errorf("failed to read service template: %w", err) | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | serviceContent := string(templateBytes) | |
| 746 | + | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", p.config.Email) | |
| 747 | + | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", p.config.Username) | |
| 748 | + | ||
| 749 | + | servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName) | |
| 750 | + | switch err := os.WriteFile(servicePath, []byte(serviceContent), 0644); { | |
| 751 | + | case err != nil: | |
| 752 | + | return fmt.Errorf("failed to write service file: %w", err) | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | p.SendOutput(progressChan, "[OK] Service file created") | |
| 756 | + | ||
| 757 | + | p.SendOutput(progressChan, "Enabling first-boot service...") | |
| 758 | + | switch err := system.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); { | |
| 759 | + | case err != nil: | |
| 760 | + | return fmt.Errorf("failed to enable service: %w", err) | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | p.SendOutput(progressChan, "[OK] First-boot service enabled") | |
| 764 | + | return nil | |
| 765 | + | } | |
| @@ -85,10 +85,14 @@ func (p *PostInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResu | |||||
| 85 | return PhaseResult{Success: false, Error: err} | 85 | return PhaseResult{Success: false, Error: err} | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | // Step 5: Post-boot setup (skipped for now) | 88 | + | // Step 5: Post-boot setup |
| 89 | currentStep++ | 89 | currentStep++ | ||
| 90 | - | p.SendProgress(progressChan, "Post-boot setup...", currentStep, totalSteps) | 90 | + | p.SendProgress(progressChan, "Setting up post-boot scripts...", currentStep, totalSteps) |
| 91 | - | p.SendOutput(progressChan, "[SKIP] Post-boot setup not implemented") | 91 | + | switch err := p.setupPostBoot(progressChan); { |
| 92 | + | case err != nil: | |||
| 93 | + | p.SendError(progressChan, err) | |||
| 94 | + | return PhaseResult{Success: false, Error: err} | |||
| 95 | + | } | |||
| 92 | 96 | ||||
| 93 | // Step 6: Pacman hooks | 97 | // Step 6: Pacman hooks | ||
| 94 | currentStep++ | 98 | currentStep++ | ||
| @@ -642,3 +646,120 @@ func (p *PostInstallPhase) Rollback() error { | |||||
| 642 | func (p *PostInstallPhase) CanSkip() bool { | 646 | func (p *PostInstallPhase) CanSkip() bool { | ||
| 643 | return false | 647 | return false | ||
| 644 | } | 648 | } | ||
| 649 | + | ||||
| 650 | + | // setupPostBoot downloads and configures first-boot service | |||
| 651 | + | func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) error { | |||
| 652 | + | p.SendOutput(progressChan, "Configuring first-boot service...") | |||
| 653 | + | ||||
| 654 | + | switch err := os.MkdirAll(config.PathMntPostBoot, 0755); { | |||
| 655 | + | case err != nil: | |||
| 656 | + | return fmt.Errorf("failed to create post-boot directory: %w", err) | |||
| 657 | + | } | |||
| 658 | + | ||||
| 659 | + | // Download logo.txt | |||
| 660 | + | p.SendOutput(progressChan, "Downloading logo.txt...") | |||
| 661 | + | logoURL := fmt.Sprintf("%s/logo.txt", p.config.RawURL) | |||
| 662 | + | resp, err := http.Get(logoURL) | |||
| 663 | + | switch { | |||
| 664 | + | case err != nil: | |||
| 665 | + | return fmt.Errorf("failed to download logo.txt: %w", err) | |||
| 666 | + | case resp.StatusCode != http.StatusOK: | |||
| 667 | + | resp.Body.Close() | |||
| 668 | + | return fmt.Errorf("failed to download logo.txt: HTTP %d", resp.StatusCode) | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt") | |||
| 672 | + | logoFile, err := os.Create(logoPath) | |||
| 673 | + | switch { | |||
| 674 | + | case err != nil: | |||
| 675 | + | resp.Body.Close() | |||
| 676 | + | return fmt.Errorf("failed to create logo.txt: %w", err) | |||
| 677 | + | } | |||
| 678 | + | ||||
| 679 | + | _, err = io.Copy(logoFile, resp.Body) | |||
| 680 | + | logoFile.Close() | |||
| 681 | + | resp.Body.Close() | |||
| 682 | + | switch { | |||
| 683 | + | case err != nil: | |||
| 684 | + | return fmt.Errorf("failed to save logo.txt: %w", err) | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | // Download all post-boot scripts | |||
| 688 | + | for _, script := range config.PostBootScripts { | |||
| 689 | + | p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", script)) | |||
| 690 | + | ||||
| 691 | + | scriptURL := fmt.Sprintf("%s/assets/post-boot/%s", p.config.RawURL, script) | |||
| 692 | + | resp, err := http.Get(scriptURL) | |||
| 693 | + | switch { | |||
| 694 | + | case err != nil: | |||
| 695 | + | return fmt.Errorf("failed to download %s: %w", script, err) | |||
| 696 | + | case resp.StatusCode != http.StatusOK: | |||
| 697 | + | resp.Body.Close() | |||
| 698 | + | return fmt.Errorf("failed to download %s: HTTP %d", script, resp.StatusCode) | |||
| 699 | + | } | |||
| 700 | + | ||||
| 701 | + | scriptPath := filepath.Join(config.PathMntPostBoot, script) | |||
| 702 | + | scriptFile, err := os.Create(scriptPath) | |||
| 703 | + | switch { | |||
| 704 | + | case err != nil: | |||
| 705 | + | resp.Body.Close() | |||
| 706 | + | return fmt.Errorf("failed to create %s: %w", script, err) | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | _, err = io.Copy(scriptFile, resp.Body) | |||
| 710 | + | scriptFile.Close() | |||
| 711 | + | resp.Body.Close() | |||
| 712 | + | ||||
| 713 | + | switch { | |||
| 714 | + | case err != nil: | |||
| 715 | + | return fmt.Errorf("failed to save %s: %w", script, err) | |||
| 716 | + | } | |||
| 717 | + | ||||
| 718 | + | switch err := os.Chmod(scriptPath, 0755); { | |||
| 719 | + | case err != nil: | |||
| 720 | + | return fmt.Errorf("failed to set permissions on %s: %w", script, err) | |||
| 721 | + | } | |||
| 722 | + | } | |||
| 723 | + | ||||
| 724 | + | p.SendOutput(progressChan, "[OK] Post-boot scripts downloaded") | |||
| 725 | + | ||||
| 726 | + | // Download service template | |||
| 727 | + | p.SendOutput(progressChan, "Creating systemd service...") | |||
| 728 | + | serviceURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.PostBootServiceTemplate) | |||
| 729 | + | resp, err = http.Get(serviceURL) | |||
| 730 | + | switch { | |||
| 731 | + | case err != nil: | |||
| 732 | + | return fmt.Errorf("failed to download service template: %w", err) | |||
| 733 | + | case resp.StatusCode != http.StatusOK: | |||
| 734 | + | resp.Body.Close() | |||
| 735 | + | return fmt.Errorf("failed to download service template: HTTP %d", resp.StatusCode) | |||
| 736 | + | } | |||
| 737 | + | ||||
| 738 | + | templateBytes, err := io.ReadAll(resp.Body) | |||
| 739 | + | resp.Body.Close() | |||
| 740 | + | switch { | |||
| 741 | + | case err != nil: | |||
| 742 | + | return fmt.Errorf("failed to read service template: %w", err) | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | serviceContent := string(templateBytes) | |||
| 746 | + | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", p.config.Email) | |||
| 747 | + | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", p.config.Username) | |||
| 748 | + | ||||
| 749 | + | servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName) | |||
| 750 | + | switch err := os.WriteFile(servicePath, []byte(serviceContent), 0644); { | |||
| 751 | + | case err != nil: | |||
| 752 | + | return fmt.Errorf("failed to write service file: %w", err) | |||
| 753 | + | } | |||
| 754 | + | ||||
| 755 | + | p.SendOutput(progressChan, "[OK] Service file created") | |||
| 756 | + | ||||
| 757 | + | p.SendOutput(progressChan, "Enabling first-boot service...") | |||
| 758 | + | switch err := system.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); { | |||
| 759 | + | case err != nil: | |||
| 760 | + | return fmt.Errorf("failed to enable service: %w", err) | |||
| 761 | + | } | |||
| 762 | + | ||||
| 763 | + | p.SendOutput(progressChan, "[OK] First-boot service enabled") | |||
| 764 | + | return nil | |||
| 765 | + | } | |||
M internal/ui/components/forms.go +4 -5
| @@ -78,13 +78,12 @@ func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) | |||
| 78 | 78 | ||
| 79 | 79 | // CreateForm creates a form with accessibility settings and custom theme | |
| 80 | 80 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | |
| 81 | - | // Use 70% of terminal width for better readability (max 80) | |
| 82 | - | formWidth := fb.width * 70 / 100 | |
| 83 | - | if formWidth > 80 { | |
| 84 | - | formWidth = 80 | |
| 81 | + | formWidth := fb.width | |
| 82 | + | if formWidth > 76 { | |
| 83 | + | formWidth = 76 | |
| 85 | 84 | } | |
| 86 | 85 | if formWidth < 40 { | |
| 87 | - | formWidth = 40 // Minimum width | |
| 86 | + | formWidth = 40 | |
| 88 | 87 | } | |
| 89 | 88 | ||
| 90 | 89 | form := huh.NewForm(groups...). | |
| @@ -78,13 +78,12 @@ func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) | |||||
| 78 | 78 | ||||
| 79 | // CreateForm creates a form with accessibility settings and custom theme | 79 | // CreateForm creates a form with accessibility settings and custom theme | ||
| 80 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | 80 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | ||
| 81 | - | // Use 70% of terminal width for better readability (max 80) | 81 | + | formWidth := fb.width |
| 82 | - | formWidth := fb.width * 70 / 100 | 82 | + | if formWidth > 76 { |
| 83 | - | if formWidth > 80 { | 83 | + | formWidth = 76 |
| 84 | - | formWidth = 80 | |||
| 85 | } | 84 | } | ||
| 86 | if formWidth < 40 { | 85 | if formWidth < 40 { | ||
| 87 | - | formWidth = 40 // Minimum width | 86 | + | formWidth = 40 |
| 88 | } | 87 | } | ||
| 89 | 88 | ||||
| 90 | form := huh.NewForm(groups...). | 89 | form := huh.NewForm(groups...). | ||
M internal/ui/logo.go +2 -15
| @@ -72,22 +72,9 @@ func (l *Logo) Render() string { | |||
| 72 | 72 | return rendered.String() | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | - | // RenderCentered renders the logo centered horizontally within the terminal using lipgloss | |
| 75 | + | // RenderCentered renders the logo centered using lipgloss alignment | |
| 76 | 76 | func (l *Logo) RenderCentered(termWidth int) string { | |
| 77 | - | logo := l.Render() | |
| 78 | - | lines := strings.Split(logo, "\n") | |
| 79 | - | ||
| 80 | - | centerStyle := lipgloss.NewStyle(). | |
| 81 | - | Width(termWidth). | |
| 82 | - | AlignHorizontal(lipgloss.Center) | |
| 83 | - | ||
| 84 | - | var centered strings.Builder | |
| 85 | - | for _, line := range lines { | |
| 86 | - | centered.WriteString(centerStyle.Render(line)) | |
| 87 | - | centered.WriteString("\n") | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | return centered.String() | |
| 77 | + | return l.Render() | |
| 91 | 78 | } | |
| 92 | 79 | ||
| 93 | 80 | // Width returns the logo width | |
| @@ -72,22 +72,9 @@ func (l *Logo) Render() string { | |||||
| 72 | return rendered.String() | 72 | return rendered.String() | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | - | // RenderCentered renders the logo centered horizontally within the terminal using lipgloss | 75 | + | // RenderCentered renders the logo centered using lipgloss alignment |
| 76 | func (l *Logo) RenderCentered(termWidth int) string { | 76 | func (l *Logo) RenderCentered(termWidth int) string { | ||
| 77 | - | logo := l.Render() | 77 | + | return l.Render() |
| 78 | - | lines := strings.Split(logo, "\n") | |||
| 79 | - | ||||
| 80 | - | centerStyle := lipgloss.NewStyle(). | |||
| 81 | - | Width(termWidth). | |||
| 82 | - | AlignHorizontal(lipgloss.Center) | |||
| 83 | - | ||||
| 84 | - | var centered strings.Builder | |||
| 85 | - | for _, line := range lines { | |||
| 86 | - | centered.WriteString(centerStyle.Render(line)) | |||
| 87 | - | centered.WriteString("\n") | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | return centered.String() | |||
| 91 | } | 78 | } | ||
| 92 | 79 | ||||
| 93 | // Width returns the logo width | 80 | // Width returns the logo width | ||
M internal/ui/model.go +52 -63
| @@ -2,6 +2,7 @@ package ui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/archup/internal/config" | |
| 7 | 8 | "github.com/bnema/archup/internal/logger" | |
| @@ -9,10 +10,10 @@ import ( | |||
| 9 | 10 | "github.com/bnema/archup/internal/system" | |
| 10 | 11 | "github.com/bnema/archup/internal/ui/components" | |
| 11 | 12 | "github.com/bnema/archup/internal/ui/styles" | |
| 13 | + | "github.com/bnema/archup/internal/ui/views" | |
| 12 | 14 | "github.com/charmbracelet/bubbles/spinner" | |
| 13 | 15 | tea "github.com/charmbracelet/bubbletea" | |
| 14 | 16 | "github.com/charmbracelet/huh" | |
| 15 | - | "github.com/charmbracelet/lipgloss" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | // ModelState represents the current UI state | |
| @@ -23,28 +24,32 @@ const ( | |||
| 23 | 24 | StatePreflightForm | |
| 24 | 25 | StateDiskForm | |
| 25 | 26 | StateOptionsForm | |
| 26 | - | StateAMDPStatePrompt // NEW: AMD P-State selection (shown only for AMD CPUs) | |
| 27 | + | StateAMDPStatePrompt | |
| 27 | 28 | StateConfirmation | |
| 28 | 29 | StateExecuting | |
| 29 | 30 | StateComplete | |
| 31 | + | StateResultsSuccess | |
| 32 | + | StateResultsError | |
| 30 | 33 | StateError | |
| 31 | 34 | ) | |
| 32 | 35 | ||
| 33 | 36 | // Model is the main Bubbletea application model | |
| 34 | 37 | type Model struct { | |
| 35 | - | state ModelState | |
| 36 | - | orchestrator *phases.Orchestrator | |
| 37 | - | config *config.Config | |
| 38 | - | currentForm *huh.Form | |
| 39 | - | formBuilder *components.FormBuilder | |
| 40 | - | output *components.OutputViewer | |
| 41 | - | logger *logger.Logger | |
| 42 | - | spinner spinner.Model | |
| 43 | - | version string | |
| 44 | - | width int | |
| 45 | - | height int | |
| 46 | - | err error | |
| 47 | - | cpuInfo *system.CPUInfo // Detected CPU information | |
| 38 | + | state ModelState | |
| 39 | + | orchestrator *phases.Orchestrator | |
| 40 | + | config *config.Config | |
| 41 | + | currentForm *huh.Form | |
| 42 | + | formBuilder *components.FormBuilder | |
| 43 | + | output *components.OutputViewer | |
| 44 | + | logger *logger.Logger | |
| 45 | + | spinner spinner.Model | |
| 46 | + | version string | |
| 47 | + | width int | |
| 48 | + | height int | |
| 49 | + | err error | |
| 50 | + | cpuInfo *system.CPUInfo | |
| 51 | + | installStartTime time.Time | |
| 52 | + | installEndTime time.Duration | |
| 48 | 53 | } | |
| 49 | 54 | ||
| 50 | 55 | // NewModel creates a new installer model | |
| @@ -71,6 +76,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger | |||
| 71 | 76 | ||
| 72 | 77 | // Init initializes the model | |
| 73 | 78 | func (m *Model) Init() tea.Cmd { | |
| 79 | + | m.installStartTime = time.Now() | |
| 74 | 80 | return m.spinner.Tick | |
| 75 | 81 | } | |
| 76 | 82 | ||
| @@ -142,7 +148,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 142 | 148 | case msg.err != nil: | |
| 143 | 149 | m.logger.Error("Phase execution failed", "error", msg.err) | |
| 144 | 150 | m.err = msg.err | |
| 145 | - | m.state = StateError | |
| 151 | + | m.installEndTime = time.Since(m.installStartTime) | |
| 152 | + | m.state = StateResultsError | |
| 146 | 153 | return m, tea.Quit | |
| 147 | 154 | } | |
| 148 | 155 | ||
| @@ -150,11 +157,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 150 | 157 | switch { | |
| 151 | 158 | case m.orchestrator.IsComplete(): | |
| 152 | 159 | m.logger.Info("All phases completed successfully") | |
| 153 | - | m.state = StateComplete | |
| 160 | + | m.installEndTime = time.Since(m.installStartTime) | |
| 161 | + | m.state = StateResultsSuccess | |
| 154 | 162 | return m, tea.Quit | |
| 155 | 163 | default: | |
| 156 | 164 | m.logger.Info("Continuing to next phase") | |
| 157 | - | // Continue with next phase | |
| 158 | 165 | return m, m.executeNextPhase() | |
| 159 | 166 | } | |
| 160 | 167 | ||
| @@ -209,31 +216,33 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 209 | 216 | func (m *Model) View() string { | |
| 210 | 217 | switch m.state { | |
| 211 | 218 | case StateShowLogo: | |
| 212 | - | logo := NewLogo(m.version) | |
| 213 | - | return logo.RenderCentered(m.width) + "\n\n\n" + | |
| 214 | - | centerText(WelcomeTagline, m.width) + "\n\n" + | |
| 215 | - | centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width) | |
| 219 | + | return views.RenderWelcome(m) | |
| 216 | 220 | ||
| 217 | 221 | case StatePreflightForm, StateDiskForm, StateOptionsForm, StateAMDPStatePrompt: | |
| 218 | - | switch { | |
| 219 | - | case m.currentForm != nil: | |
| 220 | - | header := centerText(m.renderPhaseHeader(), m.width) | |
| 221 | - | return header + "\n\n" + m.currentForm.View() | |
| 222 | - | } | |
| 223 | - | return "Loading form..." | |
| 222 | + | return views.RenderForm(m) | |
| 224 | 223 | ||
| 225 | 224 | case StateConfirmation: | |
| 226 | - | return m.renderConfirmation() | |
| 225 | + | return views.RenderConfirmation(m) | |
| 227 | 226 | ||
| 228 | 227 | case StateExecuting: | |
| 229 | - | title := m.spinner.View() + " Installation in progress..." | |
| 230 | - | return styles.TitleStyle.Render(title) + "\n\n" + m.output.View() | |
| 228 | + | return views.RenderExecuting(m) | |
| 231 | 229 | ||
| 232 | - | case StateError: | |
| 233 | - | return styles.RenderError(fmt.Sprintf("Error: %v", m.err)) | |
| 230 | + | case StateResultsSuccess: | |
| 231 | + | phaseDurations := m.orchestrator.GetPhaseDurations() | |
| 232 | + | return views.RenderSuccess(m, m.installEndTime, phaseDurations) | |
| 234 | 233 | ||
| 235 | - | case StateComplete: | |
| 236 | - | return styles.RenderSuccess("Installation complete! You can now reboot.") | |
| 234 | + | case StateResultsError: | |
| 235 | + | phaseName := "unknown" | |
| 236 | + | if phase := m.orchestrator.CurrentPhase(); phase != nil { | |
| 237 | + | phaseName = phase.Name() | |
| 238 | + | } | |
| 239 | + | return views.RenderError(m, m.err, phaseName, m.installEndTime) | |
| 240 | + | ||
| 241 | + | case StateComplete, StateError: | |
| 242 | + | if m.err != nil { | |
| 243 | + | return styles.RenderError(fmt.Sprintf("Error: %v", m.err)) | |
| 244 | + | } | |
| 245 | + | return styles.RenderSuccess("Installation complete!") | |
| 237 | 246 | ||
| 238 | 247 | default: | |
| 239 | 248 | return "Unknown state" | |
| @@ -303,25 +312,14 @@ func (m *Model) handleFormComplete() tea.Cmd { | |||
| 303 | 312 | } | |
| 304 | 313 | } | |
| 305 | 314 | ||
| 306 | - | // renderConfirmation renders the confirmation screen | |
| 307 | - | func (m *Model) renderConfirmation() string { | |
| 308 | - | s := styles.TitleStyle.Render("Confirm Installation") + "\n\n" | |
| 309 | - | s += fmt.Sprintf("Hostname: %s\n", m.config.Hostname) | |
| 310 | - | s += fmt.Sprintf("Username: %s\n", m.config.Username) | |
| 311 | - | s += fmt.Sprintf("Disk: %s\n", m.config.TargetDisk) | |
| 312 | - | s += fmt.Sprintf("Kernel: %s\n", m.config.KernelChoice) | |
| 313 | - | s += fmt.Sprintf("Encryption: %s\n", m.config.EncryptionType) | |
| 314 | - | ||
| 315 | - | // Show AMD P-State mode if configured | |
| 316 | - | if m.config.AMDPState != "" { | |
| 317 | - | s += fmt.Sprintf("AMD P-State: %s\n", m.config.AMDPState) | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | s += "\n" | |
| 321 | - | s += styles.WarningStyle.Render("WARNING: This will erase all data on "+m.config.TargetDisk) + "\n\n" | |
| 322 | - | s += styles.HelpStyle.Render("Press ENTER to continue, Ctrl+C to cancel") | |
| 323 | - | return s | |
| 324 | - | } | |
| 315 | + | // Getter methods for views package | |
| 316 | + | func (m *Model) Width() int { return m.width } | |
| 317 | + | func (m *Model) Version() string { return m.version } | |
| 318 | + | func (m *Model) Config() *config.Config { return m.config } | |
| 319 | + | func (m *Model) CurrentForm() *huh.Form { return m.currentForm } | |
| 320 | + | func (m *Model) Spinner() spinner.Model { return m.spinner } | |
| 321 | + | func (m *Model) Output() *components.OutputViewer { return m.output } | |
| 322 | + | func (m *Model) RenderPhaseHeader() string { return m.renderPhaseHeader() } | |
| 325 | 323 | ||
| 326 | 324 | // executeNextPhase executes the next pending phase | |
| 327 | 325 | func (m *Model) executeNextPhase() tea.Cmd { | |
| @@ -347,15 +345,6 @@ type phaseCompleteMsg struct { | |||
| 347 | 345 | err error | |
| 348 | 346 | } | |
| 349 | 347 | ||
| 350 | - | // centerText centers text within a given width using lipgloss | |
| 351 | - | func centerText(text string, width int) string { | |
| 352 | - | return lipgloss.NewStyle(). | |
| 353 | - | Width(width). | |
| 354 | - | AlignHorizontal(lipgloss.Center). | |
| 355 | - | Render(text) | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | ||
| 359 | 348 | // renderPhaseHeader renders phase progress header | |
| 360 | 349 | func (m *Model) renderPhaseHeader() string { | |
| 361 | 350 | var stepNum, stepName string | |
| @@ -2,6 +2,7 @@ package ui | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/archup/internal/config" | 7 | "github.com/bnema/archup/internal/config" | ||
| 7 | "github.com/bnema/archup/internal/logger" | 8 | "github.com/bnema/archup/internal/logger" | ||
| @@ -9,10 +10,10 @@ import ( | |||||
| 9 | "github.com/bnema/archup/internal/system" | 10 | "github.com/bnema/archup/internal/system" | ||
| 10 | "github.com/bnema/archup/internal/ui/components" | 11 | "github.com/bnema/archup/internal/ui/components" | ||
| 11 | "github.com/bnema/archup/internal/ui/styles" | 12 | "github.com/bnema/archup/internal/ui/styles" | ||
| 13 | + | "github.com/bnema/archup/internal/ui/views" | |||
| 12 | "github.com/charmbracelet/bubbles/spinner" | 14 | "github.com/charmbracelet/bubbles/spinner" | ||
| 13 | tea "github.com/charmbracelet/bubbletea" | 15 | tea "github.com/charmbracelet/bubbletea" | ||
| 14 | "github.com/charmbracelet/huh" | 16 | "github.com/charmbracelet/huh" | ||
| 15 | - | "github.com/charmbracelet/lipgloss" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | // ModelState represents the current UI state | 19 | // ModelState represents the current UI state | ||
| @@ -23,28 +24,32 @@ const ( | |||||
| 23 | StatePreflightForm | 24 | StatePreflightForm | ||
| 24 | StateDiskForm | 25 | StateDiskForm | ||
| 25 | StateOptionsForm | 26 | StateOptionsForm | ||
| 26 | - | StateAMDPStatePrompt // NEW: AMD P-State selection (shown only for AMD CPUs) | 27 | + | StateAMDPStatePrompt |
| 27 | StateConfirmation | 28 | StateConfirmation | ||
| 28 | StateExecuting | 29 | StateExecuting | ||
| 29 | StateComplete | 30 | StateComplete | ||
| 31 | + | StateResultsSuccess | |||
| 32 | + | StateResultsError | |||
| 30 | StateError | 33 | StateError | ||
| 31 | ) | 34 | ) | ||
| 32 | 35 | ||||
| 33 | // Model is the main Bubbletea application model | 36 | // Model is the main Bubbletea application model | ||
| 34 | type Model struct { | 37 | type Model struct { | ||
| 35 | - | state ModelState | 38 | + | state ModelState |
| 36 | - | orchestrator *phases.Orchestrator | 39 | + | orchestrator *phases.Orchestrator |
| 37 | - | config *config.Config | 40 | + | config *config.Config |
| 38 | - | currentForm *huh.Form | 41 | + | currentForm *huh.Form |
| 39 | - | formBuilder *components.FormBuilder | 42 | + | formBuilder *components.FormBuilder |
| 40 | - | output *components.OutputViewer | 43 | + | output *components.OutputViewer |
| 41 | - | logger *logger.Logger | 44 | + | logger *logger.Logger |
| 42 | - | spinner spinner.Model | 45 | + | spinner spinner.Model |
| 43 | - | version string | 46 | + | version string |
| 44 | - | width int | 47 | + | width int |
| 45 | - | height int | 48 | + | height int |
| 46 | - | err error | 49 | + | err error |
| 47 | - | cpuInfo *system.CPUInfo // Detected CPU information | 50 | + | cpuInfo *system.CPUInfo |
| 51 | + | installStartTime time.Time | |||
| 52 | + | installEndTime time.Duration | |||
| 48 | } | 53 | } | ||
| 49 | 54 | ||||
| 50 | // NewModel creates a new installer model | 55 | // NewModel creates a new installer model | ||
| @@ -71,6 +76,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger | |||||
| 71 | 76 | ||||
| 72 | // Init initializes the model | 77 | // Init initializes the model | ||
| 73 | func (m *Model) Init() tea.Cmd { | 78 | func (m *Model) Init() tea.Cmd { | ||
| 79 | + | m.installStartTime = time.Now() | |||
| 74 | return m.spinner.Tick | 80 | return m.spinner.Tick | ||
| 75 | } | 81 | } | ||
| 76 | 82 | ||||
| @@ -142,7 +148,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 142 | case msg.err != nil: | 148 | case msg.err != nil: | ||
| 143 | m.logger.Error("Phase execution failed", "error", msg.err) | 149 | m.logger.Error("Phase execution failed", "error", msg.err) | ||
| 144 | m.err = msg.err | 150 | m.err = msg.err | ||
| 145 | - | m.state = StateError | 151 | + | m.installEndTime = time.Since(m.installStartTime) |
| 152 | + | m.state = StateResultsError | |||
| 146 | return m, tea.Quit | 153 | return m, tea.Quit | ||
| 147 | } | 154 | } | ||
| 148 | 155 | ||||
| @@ -150,11 +157,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 150 | switch { | 157 | switch { | ||
| 151 | case m.orchestrator.IsComplete(): | 158 | case m.orchestrator.IsComplete(): | ||
| 152 | m.logger.Info("All phases completed successfully") | 159 | m.logger.Info("All phases completed successfully") | ||
| 153 | - | m.state = StateComplete | 160 | + | m.installEndTime = time.Since(m.installStartTime) |
| 161 | + | m.state = StateResultsSuccess | |||
| 154 | return m, tea.Quit | 162 | return m, tea.Quit | ||
| 155 | default: | 163 | default: | ||
| 156 | m.logger.Info("Continuing to next phase") | 164 | m.logger.Info("Continuing to next phase") | ||
| 157 | - | // Continue with next phase | |||
| 158 | return m, m.executeNextPhase() | 165 | return m, m.executeNextPhase() | ||
| 159 | } | 166 | } | ||
| 160 | 167 | ||||
| @@ -209,31 +216,33 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 209 | func (m *Model) View() string { | 216 | func (m *Model) View() string { | ||
| 210 | switch m.state { | 217 | switch m.state { | ||
| 211 | case StateShowLogo: | 218 | case StateShowLogo: | ||
| 212 | - | logo := NewLogo(m.version) | 219 | + | return views.RenderWelcome(m) |
| 213 | - | return logo.RenderCentered(m.width) + "\n\n\n" + | |||
| 214 | - | centerText(WelcomeTagline, m.width) + "\n\n" + | |||
| 215 | - | centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width) | |||
| 216 | 220 | ||||
| 217 | case StatePreflightForm, StateDiskForm, StateOptionsForm, StateAMDPStatePrompt: | 221 | case StatePreflightForm, StateDiskForm, StateOptionsForm, StateAMDPStatePrompt: | ||
| 218 | - | switch { | 222 | + | return views.RenderForm(m) |
| 219 | - | case m.currentForm != nil: | |||
| 220 | - | header := centerText(m.renderPhaseHeader(), m.width) | |||
| 221 | - | return header + "\n\n" + m.currentForm.View() | |||
| 222 | - | } | |||
| 223 | - | return "Loading form..." | |||
| 224 | 223 | ||||
| 225 | case StateConfirmation: | 224 | case StateConfirmation: | ||
| 226 | - | return m.renderConfirmation() | 225 | + | return views.RenderConfirmation(m) |
| 227 | 226 | ||||
| 228 | case StateExecuting: | 227 | case StateExecuting: | ||
| 229 | - | title := m.spinner.View() + " Installation in progress..." | 228 | + | return views.RenderExecuting(m) |
| 230 | - | return styles.TitleStyle.Render(title) + "\n\n" + m.output.View() | |||
| 231 | 229 | ||||
| 232 | - | case StateError: | 230 | + | case StateResultsSuccess: |
| 233 | - | return styles.RenderError(fmt.Sprintf("Error: %v", m.err)) | 231 | + | phaseDurations := m.orchestrator.GetPhaseDurations() |
| 232 | + | return views.RenderSuccess(m, m.installEndTime, phaseDurations) | |||
| 234 | 233 | ||||
| 235 | - | case StateComplete: | 234 | + | case StateResultsError: |
| 236 | - | return styles.RenderSuccess("Installation complete! You can now reboot.") | 235 | + | phaseName := "unknown" |
| 236 | + | if phase := m.orchestrator.CurrentPhase(); phase != nil { | |||
| 237 | + | phaseName = phase.Name() | |||
| 238 | + | } | |||
| 239 | + | return views.RenderError(m, m.err, phaseName, m.installEndTime) | |||
| 240 | + | ||||
| 241 | + | case StateComplete, StateError: | |||
| 242 | + | if m.err != nil { | |||
| 243 | + | return styles.RenderError(fmt.Sprintf("Error: %v", m.err)) | |||
| 244 | + | } | |||
| 245 | + | return styles.RenderSuccess("Installation complete!") | |||
| 237 | 246 | ||||
| 238 | default: | 247 | default: | ||
| 239 | return "Unknown state" | 248 | return "Unknown state" | ||
| @@ -303,25 +312,14 @@ func (m *Model) handleFormComplete() tea.Cmd { | |||||
| 303 | } | 312 | } | ||
| 304 | } | 313 | } | ||
| 305 | 314 | ||||
| 306 | - | // renderConfirmation renders the confirmation screen | 315 | + | // Getter methods for views package |
| 307 | - | func (m *Model) renderConfirmation() string { | 316 | + | func (m *Model) Width() int { return m.width } |
| 308 | - | s := styles.TitleStyle.Render("Confirm Installation") + "\n\n" | 317 | + | func (m *Model) Version() string { return m.version } |
| 309 | - | s += fmt.Sprintf("Hostname: %s\n", m.config.Hostname) | 318 | + | func (m *Model) Config() *config.Config { return m.config } |
| 310 | - | s += fmt.Sprintf("Username: %s\n", m.config.Username) | 319 | + | func (m *Model) CurrentForm() *huh.Form { return m.currentForm } |
| 311 | - | s += fmt.Sprintf("Disk: %s\n", m.config.TargetDisk) | 320 | + | func (m *Model) Spinner() spinner.Model { return m.spinner } |
| 312 | - | s += fmt.Sprintf("Kernel: %s\n", m.config.KernelChoice) | 321 | + | func (m *Model) Output() *components.OutputViewer { return m.output } |
| 313 | - | s += fmt.Sprintf("Encryption: %s\n", m.config.EncryptionType) | 322 | + | func (m *Model) RenderPhaseHeader() string { return m.renderPhaseHeader() } |
| 314 | - | ||||
| 315 | - | // Show AMD P-State mode if configured | |||
| 316 | - | if m.config.AMDPState != "" { | |||
| 317 | - | s += fmt.Sprintf("AMD P-State: %s\n", m.config.AMDPState) | |||
| 318 | - | } | |||
| 319 | - | ||||
| 320 | - | s += "\n" | |||
| 321 | - | s += styles.WarningStyle.Render("WARNING: This will erase all data on "+m.config.TargetDisk) + "\n\n" | |||
| 322 | - | s += styles.HelpStyle.Render("Press ENTER to continue, Ctrl+C to cancel") | |||
| 323 | - | return s | |||
| 324 | - | } | |||
| 325 | 323 | ||||
| 326 | // executeNextPhase executes the next pending phase | 324 | // executeNextPhase executes the next pending phase | ||
| 327 | func (m *Model) executeNextPhase() tea.Cmd { | 325 | func (m *Model) executeNextPhase() tea.Cmd { | ||
| @@ -347,15 +345,6 @@ type phaseCompleteMsg struct { | |||||
| 347 | err error | 345 | err error | ||
| 348 | } | 346 | } | ||
| 349 | 347 | ||||
| 350 | - | // centerText centers text within a given width using lipgloss | |||
| 351 | - | func centerText(text string, width int) string { | |||
| 352 | - | return lipgloss.NewStyle(). | |||
| 353 | - | Width(width). | |||
| 354 | - | AlignHorizontal(lipgloss.Center). | |||
| 355 | - | Render(text) | |||
| 356 | - | } | |||
| 357 | - | ||||
| 358 | - | ||||
| 359 | // renderPhaseHeader renders phase progress header | 348 | // renderPhaseHeader renders phase progress header | ||
| 360 | func (m *Model) renderPhaseHeader() string { | 349 | func (m *Model) renderPhaseHeader() string { | ||
| 361 | var stepNum, stepName string | 350 | var stepNum, stepName string | ||
A internal/ui/model/model.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package model | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/archup/internal/config" | |
| 5 | + | "github.com/bnema/archup/internal/ui/components" | |
| 6 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 7 | + | "github.com/charmbracelet/huh" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // UI is an interface that views use to access model data | |
| 11 | + | type UI interface { | |
| 12 | + | Width() int | |
| 13 | + | Version() string | |
| 14 | + | Config() *config.Config | |
| 15 | + | CurrentForm() *huh.Form | |
| 16 | + | Spinner() spinner.Model | |
| 17 | + | Output() *components.OutputViewer | |
| 18 | + | RenderPhaseHeader() string | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package model | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/archup/internal/config" | |||
| 5 | + | "github.com/bnema/archup/internal/ui/components" | |||
| 6 | + | "github.com/charmbracelet/bubbles/spinner" | |||
| 7 | + | "github.com/charmbracelet/huh" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // UI is an interface that views use to access model data | |||
| 11 | + | type UI interface { | |||
| 12 | + | Width() int | |||
| 13 | + | Version() string | |||
| 14 | + | Config() *config.Config | |||
| 15 | + | CurrentForm() *huh.Form | |||
| 16 | + | Spinner() spinner.Model | |||
| 17 | + | Output() *components.OutputViewer | |||
| 18 | + | RenderPhaseHeader() string | |||
| 19 | + | } | |||
M internal/ui/sections.go +10 -0
| @@ -1,5 +1,15 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | + | import "github.com/charmbracelet/lipgloss" | |
| 4 | + | ||
| 5 | + | // CenterText centers text within a given width using lipgloss | |
| 6 | + | func CenterText(text string, width int) string { | |
| 7 | + | return lipgloss.NewStyle(). | |
| 8 | + | Width(width). | |
| 9 | + | AlignHorizontal(lipgloss.Center). | |
| 10 | + | Render(text) | |
| 11 | + | } | |
| 12 | + | ||
| 3 | 13 | // Form section descriptions (DRY: centralized text) | |
| 4 | 14 | const ( | |
| 5 | 15 | // Welcome | |
| @@ -1,5 +1,15 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | + | import "github.com/charmbracelet/lipgloss" | |||
| 4 | + | ||||
| 5 | + | // CenterText centers text within a given width using lipgloss | |||
| 6 | + | func CenterText(text string, width int) string { | |||
| 7 | + | return lipgloss.NewStyle(). | |||
| 8 | + | Width(width). | |||
| 9 | + | AlignHorizontal(lipgloss.Center). | |||
| 10 | + | Render(text) | |||
| 11 | + | } | |||
| 12 | + | ||||
| 3 | // Form section descriptions (DRY: centralized text) | 13 | // Form section descriptions (DRY: centralized text) | ||
| 4 | const ( | 14 | const ( | ||
| 5 | // Welcome | 15 | // Welcome | ||
A internal/ui/styles/containers.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import "github.com/charmbracelet/lipgloss" | |
| 4 | + | ||
| 5 | + | // Container styles for consistent layout | |
| 6 | + | var ( | |
| 7 | + | // CenteredContainerStyle - Used for welcome, complete, error screens | |
| 8 | + | CenteredContainerStyle = lipgloss.NewStyle(). | |
| 9 | + | MaxWidth(80). | |
| 10 | + | Padding(0, 2). | |
| 11 | + | AlignHorizontal(lipgloss.Center) | |
| 12 | + | ||
| 13 | + | // FormContainerStyle - Used for all forms | |
| 14 | + | FormContainerStyle = lipgloss.NewStyle(). | |
| 15 | + | MaxWidth(80). | |
| 16 | + | Padding(0, 2). | |
| 17 | + | AlignHorizontal(lipgloss.Left) | |
| 18 | + | ) | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import "github.com/charmbracelet/lipgloss" | |||
| 4 | + | ||||
| 5 | + | // Container styles for consistent layout | |||
| 6 | + | var ( | |||
| 7 | + | // CenteredContainerStyle - Used for welcome, complete, error screens | |||
| 8 | + | CenteredContainerStyle = lipgloss.NewStyle(). | |||
| 9 | + | MaxWidth(80). | |||
| 10 | + | Padding(0, 2). | |||
| 11 | + | AlignHorizontal(lipgloss.Center) | |||
| 12 | + | ||||
| 13 | + | // FormContainerStyle - Used for all forms | |||
| 14 | + | FormContainerStyle = lipgloss.NewStyle(). | |||
| 15 | + | MaxWidth(80). | |||
| 16 | + | Padding(0, 2). | |||
| 17 | + | AlignHorizontal(lipgloss.Left) | |||
| 18 | + | ) | |||
A internal/ui/views/confirmation.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package views | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/archup/internal/ui/model" | |
| 7 | + | "github.com/bnema/archup/internal/ui/styles" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // RenderConfirmation renders the confirmation screen (left-aligned) | |
| 11 | + | func RenderConfirmation(m model.UI) string { | |
| 12 | + | cfg := m.Config() | |
| 13 | + | ||
| 14 | + | s := styles.TitleStyle.Render("Confirm Installation") + "\n\n" | |
| 15 | + | s += fmt.Sprintf("Hostname: %s\n", cfg.Hostname) | |
| 16 | + | s += fmt.Sprintf("Username: %s\n", cfg.Username) | |
| 17 | + | s += fmt.Sprintf("Disk: %s\n", cfg.TargetDisk) | |
| 18 | + | s += fmt.Sprintf("Kernel: %s\n", cfg.KernelChoice) | |
| 19 | + | s += fmt.Sprintf("Encryption: %s\n", cfg.EncryptionType) | |
| 20 | + | ||
| 21 | + | if cfg.AMDPState != "" { | |
| 22 | + | s += fmt.Sprintf("AMD P-State: %s\n", cfg.AMDPState) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | s += "\n" | |
| 26 | + | s += styles.WarningStyle.Render("WARNING: This will erase all data on "+cfg.TargetDisk) + "\n\n" | |
| 27 | + | s += styles.HelpStyle.Render("Press ENTER to continue, Ctrl+C to cancel") | |
| 28 | + | ||
| 29 | + | return styles.FormContainerStyle.Render(s) | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package views | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/archup/internal/ui/model" | |||
| 7 | + | "github.com/bnema/archup/internal/ui/styles" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // RenderConfirmation renders the confirmation screen (left-aligned) | |||
| 11 | + | func RenderConfirmation(m model.UI) string { | |||
| 12 | + | cfg := m.Config() | |||
| 13 | + | ||||
| 14 | + | s := styles.TitleStyle.Render("Confirm Installation") + "\n\n" | |||
| 15 | + | s += fmt.Sprintf("Hostname: %s\n", cfg.Hostname) | |||
| 16 | + | s += fmt.Sprintf("Username: %s\n", cfg.Username) | |||
| 17 | + | s += fmt.Sprintf("Disk: %s\n", cfg.TargetDisk) | |||
| 18 | + | s += fmt.Sprintf("Kernel: %s\n", cfg.KernelChoice) | |||
| 19 | + | s += fmt.Sprintf("Encryption: %s\n", cfg.EncryptionType) | |||
| 20 | + | ||||
| 21 | + | if cfg.AMDPState != "" { | |||
| 22 | + | s += fmt.Sprintf("AMD P-State: %s\n", cfg.AMDPState) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | s += "\n" | |||
| 26 | + | s += styles.WarningStyle.Render("WARNING: This will erase all data on "+cfg.TargetDisk) + "\n\n" | |||
| 27 | + | s += styles.HelpStyle.Render("Press ENTER to continue, Ctrl+C to cancel") | |||
| 28 | + | ||||
| 29 | + | return styles.FormContainerStyle.Render(s) | |||
| 30 | + | } | |||
A internal/ui/views/execution.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | package views | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/archup/internal/ui/model" | |
| 5 | + | "github.com/bnema/archup/internal/ui/styles" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // RenderExecuting renders the installation progress view | |
| 9 | + | func RenderExecuting(m model.UI) string { | |
| 10 | + | title := m.Spinner().View() + " Installation in progress..." | |
| 11 | + | return styles.TitleStyle.Render(title) + "\n\n" + m.Output().View() | |
| 12 | + | } | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | package views | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/archup/internal/ui/model" | |||
| 5 | + | "github.com/bnema/archup/internal/ui/styles" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // RenderExecuting renders the installation progress view | |||
| 9 | + | func RenderExecuting(m model.UI) string { | |||
| 10 | + | title := m.Spinner().View() + " Installation in progress..." | |||
| 11 | + | return styles.TitleStyle.Render(title) + "\n\n" + m.Output().View() | |||
| 12 | + | } | |||
A internal/ui/views/forms.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package views | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/archup/internal/ui/model" | |
| 5 | + | "github.com/bnema/archup/internal/ui/styles" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // RenderForm renders any form view (left-aligned with padding) | |
| 9 | + | func RenderForm(m model.UI) string { | |
| 10 | + | if m.CurrentForm() == nil { | |
| 11 | + | return "Loading form..." | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | header := CenterText(m.RenderPhaseHeader(), m.Width()) | |
| 15 | + | form := m.CurrentForm().View() | |
| 16 | + | ||
| 17 | + | return styles.FormContainerStyle.Render(header + "\n\n" + form) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package views | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/archup/internal/ui/model" | |||
| 5 | + | "github.com/bnema/archup/internal/ui/styles" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // RenderForm renders any form view (left-aligned with padding) | |||
| 9 | + | func RenderForm(m model.UI) string { | |||
| 10 | + | if m.CurrentForm() == nil { | |||
| 11 | + | return "Loading form..." | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | header := CenterText(m.RenderPhaseHeader(), m.Width()) | |||
| 15 | + | form := m.CurrentForm().View() | |||
| 16 | + | ||||
| 17 | + | return styles.FormContainerStyle.Render(header + "\n\n" + form) | |||
| 18 | + | } | |||
A internal/ui/views/helpers.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package views | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/charmbracelet/lipgloss" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer." | |
| 12 | + | ||
| 13 | + | logoASCII = ` ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░ | |
| 14 | + | ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗ | |
| 15 | + | ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝ | |
| 16 | + | ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░ | |
| 17 | + | ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░ | |
| 18 | + | ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░` | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Logo represents the ArchUp logo display | |
| 22 | + | type Logo struct { | |
| 23 | + | width int | |
| 24 | + | height int | |
| 25 | + | version string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewLogo creates a new Logo instance | |
| 29 | + | func NewLogo(version string) *Logo { | |
| 30 | + | lines := strings.Split(logoASCII, "\n") | |
| 31 | + | width := 0 | |
| 32 | + | for _, line := range lines { | |
| 33 | + | if len(line) > width { | |
| 34 | + | width = len(line) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | if version == "" { | |
| 39 | + | version = "dev" | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | return &Logo{ | |
| 43 | + | width: width, | |
| 44 | + | height: len(lines), | |
| 45 | + | version: version, | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // RenderCentered renders the logo centered using lipgloss alignment | |
| 50 | + | func (l *Logo) RenderCentered(termWidth int) string { | |
| 51 | + | return l.Render() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Render renders the logo | |
| 55 | + | func (l *Logo) Render() string { | |
| 56 | + | lines := strings.Split(logoASCII, "\n") | |
| 57 | + | var rendered strings.Builder | |
| 58 | + | ||
| 59 | + | for _, line := range lines { | |
| 60 | + | if len(line) == 0 { | |
| 61 | + | rendered.WriteString("\n") | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | rendered.WriteString(line) | |
| 66 | + | rendered.WriteString("\n") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | rendered.WriteString("\n") | |
| 70 | + | rendered.WriteString(fmt.Sprintf(" %s\n", l.version)) | |
| 71 | + | ||
| 72 | + | return rendered.String() | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // CenterText centers text within a given width using lipgloss | |
| 76 | + | func CenterText(text string, width int) string { | |
| 77 | + | return lipgloss.NewStyle(). | |
| 78 | + | Width(width). | |
| 79 | + | AlignHorizontal(lipgloss.Center). | |
| 80 | + | Render(text) | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package views | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/charmbracelet/lipgloss" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer." | |||
| 12 | + | ||||
| 13 | + | logoASCII = ` ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░ | |||
| 14 | + | ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗ | |||
| 15 | + | ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝ | |||
| 16 | + | ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░ | |||
| 17 | + | ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░ | |||
| 18 | + | ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░` | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Logo represents the ArchUp logo display | |||
| 22 | + | type Logo struct { | |||
| 23 | + | width int | |||
| 24 | + | height int | |||
| 25 | + | version string | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewLogo creates a new Logo instance | |||
| 29 | + | func NewLogo(version string) *Logo { | |||
| 30 | + | lines := strings.Split(logoASCII, "\n") | |||
| 31 | + | width := 0 | |||
| 32 | + | for _, line := range lines { | |||
| 33 | + | if len(line) > width { | |||
| 34 | + | width = len(line) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | if version == "" { | |||
| 39 | + | version = "dev" | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | return &Logo{ | |||
| 43 | + | width: width, | |||
| 44 | + | height: len(lines), | |||
| 45 | + | version: version, | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // RenderCentered renders the logo centered using lipgloss alignment | |||
| 50 | + | func (l *Logo) RenderCentered(termWidth int) string { | |||
| 51 | + | return l.Render() | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Render renders the logo | |||
| 55 | + | func (l *Logo) Render() string { | |||
| 56 | + | lines := strings.Split(logoASCII, "\n") | |||
| 57 | + | var rendered strings.Builder | |||
| 58 | + | ||||
| 59 | + | for _, line := range lines { | |||
| 60 | + | if len(line) == 0 { | |||
| 61 | + | rendered.WriteString("\n") | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | rendered.WriteString(line) | |||
| 66 | + | rendered.WriteString("\n") | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | rendered.WriteString("\n") | |||
| 70 | + | rendered.WriteString(fmt.Sprintf(" %s\n", l.version)) | |||
| 71 | + | ||||
| 72 | + | return rendered.String() | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // CenterText centers text within a given width using lipgloss | |||
| 76 | + | func CenterText(text string, width int) string { | |||
| 77 | + | return lipgloss.NewStyle(). | |||
| 78 | + | Width(width). | |||
| 79 | + | AlignHorizontal(lipgloss.Center). | |||
| 80 | + | Render(text) | |||
| 81 | + | } | |||
A internal/ui/views/results.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package views | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/archup/internal/ui/model" | |
| 8 | + | "github.com/bnema/archup/internal/ui/styles" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // RenderSuccess renders the success screen with timing breakdown (centered) | |
| 12 | + | func RenderSuccess(m model.UI, duration time.Duration, phaseDurations map[string]time.Duration) string { | |
| 13 | + | cfg := m.Config() | |
| 14 | + | ||
| 15 | + | s := styles.SuccessStyle.Render("✓ Installation Complete!") + "\n\n" | |
| 16 | + | s += "Installation Summary:\n" | |
| 17 | + | s += fmt.Sprintf(" Hostname: %s\n", cfg.Hostname) | |
| 18 | + | s += fmt.Sprintf(" Disk: %s\n", cfg.TargetDisk) | |
| 19 | + | s += fmt.Sprintf(" Kernel: %s\n", cfg.KernelChoice) | |
| 20 | + | s += fmt.Sprintf(" Total Time: %s\n\n", formatDuration(duration)) | |
| 21 | + | ||
| 22 | + | s += "Phase Breakdown:\n" | |
| 23 | + | for phase, dur := range phaseDurations { | |
| 24 | + | s += fmt.Sprintf(" %s: %s\n", phase, formatDuration(dur)) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | s += "\nNext Steps:\n" | |
| 28 | + | s += " 1. Remove installation media\n" | |
| 29 | + | s += " 2. Reboot the system\n" | |
| 30 | + | s += " 3. First-boot setup will run automatically\n\n" | |
| 31 | + | s += styles.HelpStyle.Render("Press ENTER to exit") | |
| 32 | + | ||
| 33 | + | return styles.CenteredContainerStyle.Render(s) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // RenderError renders the error screen with log tail (centered) | |
| 37 | + | func RenderError(m model.UI, err error, phaseName string, duration time.Duration) string { | |
| 38 | + | s := styles.ErrorStyle.Render("✗ Installation Failed") + "\n\n" | |
| 39 | + | s += fmt.Sprintf("Phase: %s\n", phaseName) | |
| 40 | + | s += fmt.Sprintf("Error: %v\n", err) | |
| 41 | + | s += fmt.Sprintf("Duration before failure: %s\n\n", formatDuration(duration)) | |
| 42 | + | ||
| 43 | + | s += "Recent log entries:\n" | |
| 44 | + | // TODO: Add log tail here once LogViewer is implemented | |
| 45 | + | ||
| 46 | + | s += "\nLog file: " + m.Config().LogPath + "\n" | |
| 47 | + | s += "Report issues: https://github.com/bnema/archup/issues\n\n" | |
| 48 | + | s += styles.HelpStyle.Render("Press ENTER to exit") | |
| 49 | + | ||
| 50 | + | return styles.CenteredContainerStyle.Render(s) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func formatDuration(d time.Duration) string { | |
| 54 | + | if d < time.Minute { | |
| 55 | + | return fmt.Sprintf("%ds", int(d.Seconds())) | |
| 56 | + | } | |
| 57 | + | return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) | |
| 58 | + | } | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package views | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/archup/internal/ui/model" | |||
| 8 | + | "github.com/bnema/archup/internal/ui/styles" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // RenderSuccess renders the success screen with timing breakdown (centered) | |||
| 12 | + | func RenderSuccess(m model.UI, duration time.Duration, phaseDurations map[string]time.Duration) string { | |||
| 13 | + | cfg := m.Config() | |||
| 14 | + | ||||
| 15 | + | s := styles.SuccessStyle.Render("✓ Installation Complete!") + "\n\n" | |||
| 16 | + | s += "Installation Summary:\n" | |||
| 17 | + | s += fmt.Sprintf(" Hostname: %s\n", cfg.Hostname) | |||
| 18 | + | s += fmt.Sprintf(" Disk: %s\n", cfg.TargetDisk) | |||
| 19 | + | s += fmt.Sprintf(" Kernel: %s\n", cfg.KernelChoice) | |||
| 20 | + | s += fmt.Sprintf(" Total Time: %s\n\n", formatDuration(duration)) | |||
| 21 | + | ||||
| 22 | + | s += "Phase Breakdown:\n" | |||
| 23 | + | for phase, dur := range phaseDurations { | |||
| 24 | + | s += fmt.Sprintf(" %s: %s\n", phase, formatDuration(dur)) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | s += "\nNext Steps:\n" | |||
| 28 | + | s += " 1. Remove installation media\n" | |||
| 29 | + | s += " 2. Reboot the system\n" | |||
| 30 | + | s += " 3. First-boot setup will run automatically\n\n" | |||
| 31 | + | s += styles.HelpStyle.Render("Press ENTER to exit") | |||
| 32 | + | ||||
| 33 | + | return styles.CenteredContainerStyle.Render(s) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // RenderError renders the error screen with log tail (centered) | |||
| 37 | + | func RenderError(m model.UI, err error, phaseName string, duration time.Duration) string { | |||
| 38 | + | s := styles.ErrorStyle.Render("✗ Installation Failed") + "\n\n" | |||
| 39 | + | s += fmt.Sprintf("Phase: %s\n", phaseName) | |||
| 40 | + | s += fmt.Sprintf("Error: %v\n", err) | |||
| 41 | + | s += fmt.Sprintf("Duration before failure: %s\n\n", formatDuration(duration)) | |||
| 42 | + | ||||
| 43 | + | s += "Recent log entries:\n" | |||
| 44 | + | // TODO: Add log tail here once LogViewer is implemented | |||
| 45 | + | ||||
| 46 | + | s += "\nLog file: " + m.Config().LogPath + "\n" | |||
| 47 | + | s += "Report issues: https://github.com/bnema/archup/issues\n\n" | |||
| 48 | + | s += styles.HelpStyle.Render("Press ENTER to exit") | |||
| 49 | + | ||||
| 50 | + | return styles.CenteredContainerStyle.Render(s) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func formatDuration(d time.Duration) string { | |||
| 54 | + | if d < time.Minute { | |||
| 55 | + | return fmt.Sprintf("%ds", int(d.Seconds())) | |||
| 56 | + | } | |||
| 57 | + | return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) | |||
| 58 | + | } | |||
A internal/ui/views/welcome.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package views | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/archup/internal/ui/model" | |
| 5 | + | "github.com/bnema/archup/internal/ui/styles" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // RenderWelcome renders the welcome logo screen (centered) | |
| 9 | + | func RenderWelcome(m model.UI) string { | |
| 10 | + | logo := NewLogo(m.Version()) | |
| 11 | + | content := logo.RenderCentered(m.Width()) + "\n\n\n" + | |
| 12 | + | CenterText(WelcomeTagline, m.Width()) + "\n\n" + | |
| 13 | + | CenterText(styles.HelpStyle.Render("Press ENTER to start installation"), m.Width()) | |
| 14 | + | ||
| 15 | + | return styles.CenteredContainerStyle.Render(content) | |
| 16 | + | } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package views | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/archup/internal/ui/model" | |||
| 5 | + | "github.com/bnema/archup/internal/ui/styles" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // RenderWelcome renders the welcome logo screen (centered) | |||
| 9 | + | func RenderWelcome(m model.UI) string { | |||
| 10 | + | logo := NewLogo(m.Version()) | |||
| 11 | + | content := logo.RenderCentered(m.Width()) + "\n\n\n" + | |||
| 12 | + | CenterText(WelcomeTagline, m.Width()) + "\n\n" + | |||
| 13 | + | CenterText(styles.HelpStyle.Render("Press ENTER to start installation"), m.Width()) | |||
| 14 | + | ||||
| 15 | + | return styles.CenteredContainerStyle.Render(content) | |||
| 16 | + | } | |||