xetup/internal/runner/runner.go
X9 Dev 1198de3c49 feat: Go TUI launcher (xetup.exe) + spec page new-request section
- embed.go: root package exposes Scripts/Assets embed.FS
- internal/config: Config struct, Load/Save/Default
- internal/runner: Step list, Runner with context cancel, log streaming
- internal/tui: bubbletea model - huh form (phase 1) + live log view (phase 2) + summary (phase 3)
- cmd/xetup/main.go: main binary, extracts embedded content to tmpdir, runs TUI
- Builds to 5.2 MB xetup.exe (GOOS=windows GOARCH=amd64)

spec/index.html:
- arch-xetup section: mark 5 items flag-done (code now exists)
- Add "Nove nastaveni" section linked to Forgejo issue #15
- Add sidebar link for new-requests
- Add CSS + JS for request widget (loads/posts to issue #15 comments)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:35:22 +02:00

257 lines
7 KiB
Go

// Package runner executes PowerShell deployment scripts and streams log output.
package runner
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Step describes a single deployment step.
type Step struct {
ID string // e.g. "adminAccount"
Num string // display number e.g. "00"
Name string
ScriptName string // e.g. "00-admin-account.ps1"
Enabled bool
}
// AllSteps returns the ordered list of deployment steps.
func AllSteps() []Step {
return []Step{
{ID: "adminAccount", Num: "00", Name: "Admin ucet", ScriptName: "00-admin-account.ps1"},
{ID: "bloatware", Num: "01", Name: "Bloatware removal", ScriptName: "01-bloatware.ps1"},
{ID: "software", Num: "02", Name: "Software (winget)", ScriptName: "02-software.ps1"},
{ID: "systemRegistry", Num: "03", Name: "System Registry (HKLM)", ScriptName: "03-system-registry.ps1"},
{ID: "defaultProfile", Num: "04", Name: "Default Profile", ScriptName: "04-default-profile.ps1"},
{ID: "personalization", Num: "05", Name: "Personalizace", ScriptName: "05-personalization.ps1"},
{ID: "scheduledTasks", Num: "06", Name: "Scheduled Tasks", ScriptName: "06-scheduled-tasks.ps1"},
{ID: "backinfo", Num: "07", Name: "BackInfo", ScriptName: "07-backinfo.ps1"},
{ID: "activation", Num: "08", Name: "Windows aktivace", ScriptName: "08-activation.ps1"},
{ID: "network", Num: "09", Name: "Network discovery", ScriptName: "10-network.ps1"},
{ID: "pcIdentity", Num: "10", Name: "PC identita", ScriptName: "09-pc-identity.ps1"},
}
}
// RunConfig holds runtime parameters passed to each script.
type RunConfig struct {
ScriptsDir string
ConfigPath string
LogFile string
ProfileType string
}
// Result is the outcome of a single step.
type Result struct {
Step Step
Status string // "OK", "ERROR", "SKIPPED"
Elapsed time.Duration
}
// LogLine is a single output line from a running script.
type LogLine struct {
StepID string
Text string
Level string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
}
// Runner executes deployment steps sequentially.
type Runner struct {
cfg RunConfig
onLog func(LogLine)
onResult func(Result)
cancel context.CancelFunc
}
// New creates a Runner. onLog is called for each output line, onResult after each step.
func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
}
// Run executes enabled steps sequentially. Blocks until done or context cancelled.
func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
ctx, cancel := context.WithCancel(ctx)
r.cancel = cancel
defer cancel()
// Write config JSON to temp file so scripts can read it
cfgArg := r.cfg.ConfigPath
var results []Result
for _, step := range steps {
if !step.Enabled {
res := Result{Step: step, Status: "SKIPPED"}
r.onResult(res)
results = append(results, res)
continue
}
start := time.Now()
err := r.runScript(ctx, step, cfgArg)
elapsed := time.Since(start)
status := "OK"
if err != nil {
if ctx.Err() != nil {
status = "CANCELLED"
} else {
status = "ERROR"
}
}
res := Result{Step: step, Status: status, Elapsed: elapsed}
r.onResult(res)
results = append(results, res)
if ctx.Err() != nil {
break
}
}
return results
}
// Stop cancels the running deployment.
func (r *Runner) Stop() {
if r.cancel != nil {
r.cancel()
}
}
func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)
// Build argument list
args := []string{
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-File", scriptPath,
"-LogFile", r.cfg.LogFile,
}
// Pass config object as JSON string (script reads it inline)
if cfgArg != "" {
args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
}
// ProfileType for step 04
if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
args = append(args, "-ProfileType", r.cfg.ProfileType)
}
cmd := exec.CommandContext(ctx, "powershell.exe", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = cmd.Stdout // merge stderr into stdout
if err := cmd.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
r.onLog(LogLine{
StepID: step.ID,
Text: line,
Level: parseLevel(line),
})
}
return cmd.Wait()
}
// parseLevel extracts the log level from lines formatted as "[HH:mm:ss] [LEVEL] message".
func parseLevel(line string) string {
if strings.Contains(line, "] [OK]") {
return "OK"
}
if strings.Contains(line, "] [ERROR]") {
return "ERROR"
}
if strings.Contains(line, "] [WARN]") {
return "WARN"
}
if strings.Contains(line, "] [STEP]") {
return "STEP"
}
return "INFO"
}
// ExtractScripts unpacks embedded scripts to a temp directory.
// Returns the directory path. Caller is responsible for cleanup.
func ExtractScripts(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
entries, err := fs.ReadDir("scripts")
if err != nil {
return fmt.Errorf("read embedded scripts: %w", err)
}
scriptsDir := filepath.Join(tmpDir, "scripts")
if err := os.MkdirAll(scriptsDir, 0755); err != nil {
return err
}
for _, e := range entries {
if e.IsDir() {
continue
}
data, err := fs.ReadFile(filepath.Join("scripts", e.Name()))
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(scriptsDir, e.Name()), data, 0644); err != nil {
return err
}
}
return nil
}
// ExtractAssets unpacks embedded assets to tmpDir/assets.
func ExtractAssets(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
return extractDir(fs, "assets", tmpDir)
}
func extractDir(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, src, dstBase string) error {
entries, err := fs.ReadDir(src)
if err != nil {
return err
}
dst := filepath.Join(dstBase, src)
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
for _, e := range entries {
srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dstBase, srcPath)
if e.IsDir() {
if err := extractDir(fs, srcPath, dstBase); err != nil {
return err
}
continue
}
data, err := fs.ReadFile(srcPath)
if err != nil {
return err
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
return err
}
}
return nil
}
// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {
path := filepath.Join(tmpDir, "config-runtime.json")
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
return path, os.WriteFile(path, data, 0644)
}