// Package runner executes PowerShell deployment scripts and streams log output. package runner import ( "bufio" "context" "encoding/json" "fmt" "os" "os/exec" "path" "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: "dellUpdate", Num: "11", Name: "Dell Command | Update", ScriptName: "11-dell-update.ps1"}, {ID: "windowsUpdate", Num: "12", Name: "Windows Update", ScriptName: "12-windows-update.ps1"}, {ID: "network", Num: "09", Name: "Network discovery", ScriptName: "10-network.ps1"}, {ID: "pcIdentity", Num: "10", Name: "PC identita", ScriptName: "09-pc-identity.ps1"}, } } // Feature is a single toggleable sub-item within a deployment step. type Feature struct { ID string Label string } // StepFeatures returns per-step feature lists. Steps absent from this map // have no sub-features and are controlled at the step level only. func StepFeatures() map[string][]Feature { return map[string][]Feature{ "software": { {ID: "wingetInstalls", Label: "Instalace SW ze seznamu (winget)"}, {ID: "pdfDefault", Label: "Adobe Reader jako vychozi PDF"}, {ID: "ateraAgent", Label: "Atera RMM agent"}, }, "systemRegistry": { {ID: "systemTweaks", Label: "Windows tweaky (Widgets, GameDVR, Recall...)"}, {ID: "edgePolicies", Label: "Edge policies (tlacitka, vyhledavac, telemetrie)"}, {ID: "oneDriveUninstall", Label: "OneDrive uninstall (consumer pre-install)"}, {ID: "powercfg", Label: "Nastaveni napajeni (timeout AC/DC)"}, {ID: "proxyDisable", Label: "Zakaz WPAD proxy auto-detect"}, }, "defaultProfile": { {ID: "taskbarTweaks", Label: "Taskbar – zarovnani, tlacitka, layout XML"}, {ID: "startMenuTweaks", Label: "Start menu – cisteni pinu, Bing, Copilot"}, {ID: "explorerTweaks", Label: "Explorer – pripony, LaunchTo, ShowRecent"}, }, "dellUpdate": { {ID: "drivers", Label: "Dell drivery + firmware"}, {ID: "bios", Label: "Dell BIOS update"}, }, } } // SelectableItem is a single toggleable row in the TUI checklist. // It represents either a whole step (FeatureID == "") or a specific feature. type SelectableItem struct { Key string // "stepID" or "stepID.featureID" StepID string FeatureID string // empty for step-level items Label string Num string } // AllSelectableItems returns the flat ordered list of all TUI toggle rows. // Steps with features are expanded to individual feature rows. // Steps without features appear as a single step-level row. func AllSelectableItems() []SelectableItem { steps := AllSteps() features := StepFeatures() var items []SelectableItem for _, s := range steps { feats, hasFeatures := features[s.ID] if !hasFeatures { items = append(items, SelectableItem{ Key: s.ID, StepID: s.ID, Label: s.Num + " – " + s.Name, Num: s.Num, }) } else { for _, f := range feats { items = append(items, SelectableItem{ Key: s.ID + "." + f.ID, StepID: s.ID, FeatureID: f.ID, Label: s.Num + " – " + f.Label, Num: s.Num, }) } } } return items } // 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...) hideWindow(cmd) // prevent PS console window from appearing over the GUI 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() if skipPSNoiseLine(line) { continue } r.onLog(LogLine{ StepID: step.ID, Text: line, Level: parseLevel(line), }) } return cmd.Wait() } // skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log: // multi-line error blocks (At line:N, CategoryInfo, FullyQualifiedErrorId, etc.), // blank lines, and VERBOSE: prefix lines already handled by Write-Log. func skipPSNoiseLine(line string) bool { trimmed := strings.TrimSpace(line) if trimmed == "" { return true } for _, prefix := range []string{ "At line:", "+ CategoryInfo", "+ FullyQualifiedErrorId", "+ PositionMessage", "VERBOSE:", "DEBUG:", } { if strings.HasPrefix(trimmed, prefix) { return true } } // PS error continuation lines start with spaces + "+" or "~" if len(trimmed) > 0 && (trimmed[0] == '+' || strings.HasPrefix(trimmed, "~")) { return true } return false } // 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 } // embed.FS always uses forward slashes regardless of OS data, err := fs.ReadFile(path.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, filepath.FromSlash(src)) if err := os.MkdirAll(dst, 0755); err != nil { return err } for _, e := range entries { // embed.FS always uses forward slashes regardless of OS srcPath := path.Join(src, e.Name()) dstPath := filepath.Join(dstBase, filepath.FromSlash(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) }