All checks were successful
release / build-and-release (push) Successful in 21s
Steps pcIdentity and network had swapped Num+ScriptName values and were listed after 11/12 instead of before them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
373 lines
11 KiB
Go
373 lines
11 KiB
Go
// 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: "pcIdentity", Num: "09", Name: "PC identita", ScriptName: "09-pc-identity.ps1"},
|
||
{ID: "network", Num: "10", Name: "Network discovery", ScriptName: "10-network.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"},
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
onStepStart func(Step)
|
||
onResult func(Result)
|
||
cancel context.CancelFunc
|
||
}
|
||
|
||
// New creates a Runner. onLog is called for each output line, onResult after each step.
|
||
// onStepStart (optional) is called immediately before a step's script is launched.
|
||
func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
|
||
return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, 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
|
||
}
|
||
|
||
if r.onStepStart != nil {
|
||
r.onStepStart(step)
|
||
}
|
||
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)
|
||
}
|