// 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) }