detached 4afd200

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
23
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"
128
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)"
5144
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
5391 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 help1+# ArchUp Go Installer Makefile
2+# Builds the Go-based installer with proper version injection
2 3
3-# Default target4+# 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 checks9+# Build settings
14-check: check-syntax check-shellcheck10+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 -n14+
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- done24+build:
29- @echo "✓ Syntax check passed"25+ @echo "Building $(BINARY_NAME) $(VERSION)..."
30-26+ @mkdir -p $(BUILD_DIR)
31-# Run shellcheck27+ 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- fi37+
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 files45+## 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.log93+ @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{
183183 "progress_box.png",
184184 }
185185
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+
186205 // Config holds the installation configuration
187206 // This mirrors the shell script's config format for compatibility
188207 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 configuration205 // Config holds the installation configuration
187 // This mirrors the shell script's config format for compatibility206 // 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
22
33 import (
44 "fmt"
5+ "time"
56
67 "github.com/bnema/archup/internal/config"
78 )
89
910 // Orchestrator manages the execution of installation phases
1011 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
1619 }
1720
1821 // NewOrchestrator creates a new phase orchestrator
1922 func NewOrchestrator(cfg *config.Config, logPath string) *Orchestrator {
2023 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),
2631 }
2732 }
2833
@@ -75,7 +80,6 @@ func (o *Orchestrator) ProgressChannel() <-chan ProgressUpdate {
7580
7681 // ExecutePhase runs a specific phase
7782 func (o *Orchestrator) ExecutePhase(phase Phase) error {
78- // Update current index
7983 for i, p := range o.phases {
8084 if p.Name() == phase.Name() {
8185 o.currentIdx = i
@@ -83,19 +87,21 @@ func (o *Orchestrator) ExecutePhase(phase Phase) error {
8387 }
8488 }
8589
86- // Pre-check
90+ startTime := time.Now()
91+
8792 phase.SetStatus(StatusRunning)
8893 if err := phase.PreCheck(); err != nil {
8994 phase.SetStatus(StatusFailed)
9095 return fmt.Errorf("pre-check failed: %w", err)
9196 }
9297
93- // Execute
9498 result := phase.Execute(o.progressCh)
99+ duration := time.Since(startTime)
100+ o.phaseDurations[phase.Name()] = duration
101+
95102 if !result.Success {
96103 phase.SetStatus(StatusFailed)
97104
98- // Attempt rollback
99105 if rbErr := phase.Rollback(); rbErr != nil {
100106 return fmt.Errorf("execution failed: %v, rollback failed: %v", result.Error, rbErr)
101107 }
@@ -103,7 +109,6 @@ func (o *Orchestrator) ExecutePhase(phase Phase) error {
103109 return fmt.Errorf("execution failed: %w", result.Error)
104110 }
105111
106- // Post-check
107112 if err := phase.PostCheck(); err != nil {
108113 phase.SetStatus(StatusFailed)
109114 return fmt.Errorf("post-check failed: %w", err)
@@ -185,3 +190,13 @@ func (o *Orchestrator) HasFailed() bool {
185190 }
186191 return false
187192 }
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 phases10 // Orchestrator manages the execution of installation phases
10 type Orchestrator struct {11 type Orchestrator struct {
11- phases []Phase12+ phases []Phase
12- currentIdx int13+ currentIdx int
13- config *config.Config14+ config *config.Config
14- logPath string15+ logPath string
15- progressCh chan ProgressUpdate16+ progressCh chan ProgressUpdate
17+ installStartTime time.Time
18+ phaseDurations map[string]time.Duration
16 }19 }
17 20
18 // NewOrchestrator creates a new phase orchestrator21 // 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 phase81 // 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 = i85 o.currentIdx = i
@@ -83,19 +87,21 @@ func (o *Orchestrator) ExecutePhase(phase Phase) error {
83 }87 }
84 }88 }
85 89
86- // Pre-check90+ 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 false191 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 {
4646 type ProgressUpdate struct {
4747 PhaseName string
4848 Step string
49- OutputLine string // For streaming command output (e.g., pacman)
49+ OutputLine string
5050 Current int
5151 Total int
5252 IsComplete bool
@@ -94,7 +94,7 @@ type BasePhase struct {
9494 description string
9595 status PhaseStatus
9696 config *config.Config
97- logger *logger.Logger // Logger with dry-run support
97+ logger *logger.Logger
9898 }
9999
100100 // NewBasePhase creates a new base phase
@@ -46,7 +46,7 @@ type PhaseResult struct {
46 type ProgressUpdate struct {46 type ProgressUpdate struct {
47 PhaseName string47 PhaseName string
48 Step string48 Step string
49- OutputLine string // For streaming command output (e.g., pacman)49+ OutputLine string
50 Current int50 Current int
51 Total int51 Total int
52 IsComplete bool52 IsComplete bool
@@ -94,7 +94,7 @@ type BasePhase struct {
94 description string94 description string
95 status PhaseStatus95 status PhaseStatus
96 config *config.Config96 config *config.Config
97- logger *logger.Logger // Logger with dry-run support97+ logger *logger.Logger
98 }98 }
99 99
100 // NewBasePhase creates a new base phase100 // 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
8585 return PhaseResult{Success: false, Error: err}
8686 }
8787
88- // Step 5: Post-boot setup (skipped for now)
88+ // Step 5: Post-boot setup
8989 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+ }
9296
9397 // Step 6: Pacman hooks
9498 currentStep++
@@ -642,3 +646,120 @@ func (p *PostInstallPhase) Rollback() error {
642646 func (p *PostInstallPhase) CanSkip() bool {
643647 return false
644648 }
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 hooks97 // 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 false647 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)
7878
7979 // CreateForm creates a form with accessibility settings and custom theme
8080 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
8584 }
8685 if formWidth < 40 {
87- formWidth = 40 // Minimum width
86+ formWidth = 40
8887 }
8988
9089 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 theme79 // 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 / 10082+ 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 width86+ 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 {
7272 return rendered.String()
7373 }
7474
75-// RenderCentered renders the logo centered horizontally within the terminal using lipgloss
75+// RenderCentered renders the logo centered using lipgloss alignment
7676 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()
9178 }
9279
9380 // 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 lipgloss75+// 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 width80 // Width returns the logo width
M internal/ui/model.go +52 -63
@@ -2,6 +2,7 @@ package ui
22
33 import (
44 "fmt"
5+ "time"
56
67 "github.com/bnema/archup/internal/config"
78 "github.com/bnema/archup/internal/logger"
@@ -9,10 +10,10 @@ import (
910 "github.com/bnema/archup/internal/system"
1011 "github.com/bnema/archup/internal/ui/components"
1112 "github.com/bnema/archup/internal/ui/styles"
13+ "github.com/bnema/archup/internal/ui/views"
1214 "github.com/charmbracelet/bubbles/spinner"
1315 tea "github.com/charmbracelet/bubbletea"
1416 "github.com/charmbracelet/huh"
15- "github.com/charmbracelet/lipgloss"
1617 )
1718
1819 // ModelState represents the current UI state
@@ -23,28 +24,32 @@ const (
2324 StatePreflightForm
2425 StateDiskForm
2526 StateOptionsForm
26- StateAMDPStatePrompt // NEW: AMD P-State selection (shown only for AMD CPUs)
27+ StateAMDPStatePrompt
2728 StateConfirmation
2829 StateExecuting
2930 StateComplete
31+ StateResultsSuccess
32+ StateResultsError
3033 StateError
3134 )
3235
3336 // Model is the main Bubbletea application model
3437 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
4853 }
4954
5055 // NewModel creates a new installer model
@@ -71,6 +76,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger
7176
7277 // Init initializes the model
7378 func (m *Model) Init() tea.Cmd {
79+ m.installStartTime = time.Now()
7480 return m.spinner.Tick
7581 }
7682
@@ -142,7 +148,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
142148 case msg.err != nil:
143149 m.logger.Error("Phase execution failed", "error", msg.err)
144150 m.err = msg.err
145- m.state = StateError
151+ m.installEndTime = time.Since(m.installStartTime)
152+ m.state = StateResultsError
146153 return m, tea.Quit
147154 }
148155
@@ -150,11 +157,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
150157 switch {
151158 case m.orchestrator.IsComplete():
152159 m.logger.Info("All phases completed successfully")
153- m.state = StateComplete
160+ m.installEndTime = time.Since(m.installStartTime)
161+ m.state = StateResultsSuccess
154162 return m, tea.Quit
155163 default:
156164 m.logger.Info("Continuing to next phase")
157- // Continue with next phase
158165 return m, m.executeNextPhase()
159166 }
160167
@@ -209,31 +216,33 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
209216 func (m *Model) View() string {
210217 switch m.state {
211218 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)
216220
217221 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)
224223
225224 case StateConfirmation:
226- return m.renderConfirmation()
225+ return views.RenderConfirmation(m)
227226
228227 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)
231229
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)
234233
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!")
237246
238247 default:
239248 return "Unknown state"
@@ -303,25 +312,14 @@ func (m *Model) handleFormComplete() tea.Cmd {
303312 }
304313 }
305314
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() }
325323
326324 // executeNextPhase executes the next pending phase
327325 func (m *Model) executeNextPhase() tea.Cmd {
@@ -347,15 +345,6 @@ type phaseCompleteMsg struct {
347345 err error
348346 }
349347
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-
359348 // renderPhaseHeader renders phase progress header
360349 func (m *Model) renderPhaseHeader() string {
361350 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 state19 // ModelState represents the current UI state
@@ -23,28 +24,32 @@ const (
23 StatePreflightForm24 StatePreflightForm
24 StateDiskForm25 StateDiskForm
25 StateOptionsForm26 StateOptionsForm
26- StateAMDPStatePrompt // NEW: AMD P-State selection (shown only for AMD CPUs)27+ StateAMDPStatePrompt
27 StateConfirmation28 StateConfirmation
28 StateExecuting29 StateExecuting
29 StateComplete30 StateComplete
31+ StateResultsSuccess
32+ StateResultsError
30 StateError33 StateError
31 )34 )
32 35
33 // Model is the main Bubbletea application model36 // Model is the main Bubbletea application model
34 type Model struct {37 type Model struct {
35- state ModelState38+ state ModelState
36- orchestrator *phases.Orchestrator39+ orchestrator *phases.Orchestrator
37- config *config.Config40+ config *config.Config
38- currentForm *huh.Form41+ currentForm *huh.Form
39- formBuilder *components.FormBuilder42+ formBuilder *components.FormBuilder
40- output *components.OutputViewer43+ output *components.OutputViewer
41- logger *logger.Logger44+ logger *logger.Logger
42- spinner spinner.Model45+ spinner spinner.Model
43- version string46+ version string
44- width int47+ width int
45- height int48+ height int
46- err error49+ err error
47- cpuInfo *system.CPUInfo // Detected CPU information50+ cpuInfo *system.CPUInfo
51+ installStartTime time.Time
52+ installEndTime time.Duration
48 }53 }
49 54
50 // NewModel creates a new installer model55 // 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 model77 // 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.Tick80 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.err150 m.err = msg.err
145- m.state = StateError151+ m.installEndTime = time.Since(m.installStartTime)
152+ m.state = StateResultsError
146 return m, tea.Quit153 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 = StateComplete160+ m.installEndTime = time.Since(m.installStartTime)
161+ m.state = StateResultsSuccess
154 return m, tea.Quit162 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 screen315+// 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 phase324 // 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 error345 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 header348 // renderPhaseHeader renders phase progress header
360 func (m *Model) renderPhaseHeader() string {349 func (m *Model) renderPhaseHeader() string {
361 var stepNum, stepName string350 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 @@
11 package ui
22
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+
313 // Form section descriptions (DRY: centralized text)
414 const (
515 // Welcome
@@ -1,5 +1,15 @@
1 package ui1 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 // Welcome15 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close