- 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>
257 lines
7 KiB
Go
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)
|
|
}
|