From 1198de3c49d56a9bd3a4f23707b76f9259417f82 Mon Sep 17 00:00:00 2001 From: X9 Dev Date: Thu, 16 Apr 2026 10:35:22 +0200 Subject: [PATCH] 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 --- cmd/xetup/main.go | 69 +++++++ embed.go | 16 ++ go.mod | 32 ++++ go.sum | 55 ++++++ internal/config/config.go | 115 +++++++++++ internal/runner/runner.go | 257 +++++++++++++++++++++++++ internal/tui/tui.go | 389 ++++++++++++++++++++++++++++++++++++++ web/spec/index.html | 143 +++++++++++++- 8 files changed, 1070 insertions(+), 6 deletions(-) create mode 100644 cmd/xetup/main.go create mode 100644 embed.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/runner/runner.go create mode 100644 internal/tui/tui.go diff --git a/cmd/xetup/main.go b/cmd/xetup/main.go new file mode 100644 index 0000000..a887225 --- /dev/null +++ b/cmd/xetup/main.go @@ -0,0 +1,69 @@ +// Command xetup is the interactive TUI launcher for Windows deployment. +// It embeds all PowerShell scripts and assets into a single self-contained +// binary, extracts them to a temp directory at runtime, collects configuration +// via an interactive form, and streams live log output while the scripts run. +// +// Cross-compile for Windows: +// +// GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o xetup.exe ./cmd/xetup +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" + + xetupembed "git.xetup.x9.cz/x9/xetup" + "git.xetup.x9.cz/x9/xetup/internal/config" + "git.xetup.x9.cz/x9/xetup/internal/runner" + "git.xetup.x9.cz/x9/xetup/internal/tui" +) + +func main() { + // Load config (silently falls back to defaults when missing) + cfgPath := config.ConfigPath() + cfg, err := config.Load(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err) + } + + // Create a temp working directory; cleaned up on exit + tmpDir, err := os.MkdirTemp("", "xetup-*") + if err != nil { + log.Fatalf("Cannot create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Extract embedded scripts + if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil { + log.Fatalf("Failed to extract scripts: %v", err) + } + + // Extract embedded assets + if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil { + log.Fatalf("Failed to extract assets: %v", err) + } + + // Write runtime config JSON so PowerShell scripts can read it + cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir) + if err != nil { + log.Fatalf("Failed to write runtime config: %v", err) + } + + runCfg := runner.RunConfig{ + ScriptsDir: filepath.Join(tmpDir, "scripts"), + ConfigPath: cfgRuntimePath, + LogFile: `C:\Windows\Setup\Scripts\Deploy.log`, + ProfileType: cfg.Deployment.ProfileType, + } + + // Launch the TUI + m := tui.NewModel(cfg, runCfg) + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + log.Fatalf("TUI error: %v", err) + } +} diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..ae352ff --- /dev/null +++ b/embed.go @@ -0,0 +1,16 @@ +// Package xetup exposes embedded PowerShell scripts and assets for use by +// cmd/xetup. Placing the embed declarations here (at the module root) gives +// the //go:embed directives a clear, stable relative path to the content. +package xetup + +import "embed" + +// Scripts holds all PowerShell scripts from the scripts/ directory. +// +//go:embed scripts +var Scripts embed.FS + +// Assets holds all deployment assets from the assets/ directory. +// +//go:embed assets +var Assets embed.FS diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7acf944 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module git.xetup.x9.cz/x9/xetup + +go 1.24.0 + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..235f2a6 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a7c7b71 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,115 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Config mirrors config.json structure. +type Config struct { + Deployment Deployment `json:"deployment"` + AdminAccount AdminAccount `json:"adminAccount"` + Activation Activation `json:"activation"` + Software Software `json:"software"` + Steps map[string]bool `json:"steps"` +} + +type Deployment struct { + PCName string `json:"pcName"` + PCDescription string `json:"pcDescription"` + Timezone string `json:"timezone"` + ProfileType string `json:"profileType"` // default | admin | user +} + +type AdminAccount struct { + Username string `json:"username"` +} + +type Activation struct { + ProductKey string `json:"productKey"` + KMSServer string `json:"kmsServer"` +} + +type SoftwareItem struct { + Name string `json:"name"` + WingetID string `json:"wingetId"` +} + +type Software struct { + Install []SoftwareItem `json:"install"` +} + +// DefaultConfig returns a config with sensible defaults. +func DefaultConfig() Config { + return Config{ + Deployment: Deployment{ + Timezone: "Central Europe Standard Time", + ProfileType: "default", + }, + AdminAccount: AdminAccount{ + Username: "adminx9", + }, + Activation: Activation{ + ProductKey: "", + }, + Software: Software{ + Install: []SoftwareItem{ + {Name: "7-Zip", WingetID: "7zip.7zip"}, + {Name: "Adobe Acrobat Reader 64-bit", WingetID: "Adobe.Acrobat.Reader.64-bit"}, + {Name: "OpenVPN Connect", WingetID: "OpenVPNTechnologies.OpenVPNConnect"}, + }, + }, + Steps: map[string]bool{ + "adminAccount": true, + "bloatware": true, + "software": true, + "systemRegistry": true, + "defaultProfile": true, + "personalization": true, + "scheduledTasks": true, + "backinfo": true, + "activation": true, + "network": true, + "pcIdentity": true, + }, + } +} + +// Load reads config.json from the given path. +// If the file does not exist, returns DefaultConfig without error. +func Load(path string) (Config, error) { + cfg := DefaultConfig() + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return cfg, nil + } + if err != nil { + return cfg, err + } + if err := json.Unmarshal(data, &cfg); err != nil { + return cfg, err + } + return cfg, nil +} + +// Save writes config to the given path (creates directories if needed). +func Save(cfg Config, path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// ConfigPath returns the default config.json path (next to the executable). +func ConfigPath() string { + exe, err := os.Executable() + if err != nil { + return "config.json" + } + return filepath.Join(filepath.Dir(exe), "config.json") +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..773b9a8 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,257 @@ +// 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) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..a63d458 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,389 @@ +// Package tui implements the interactive terminal UI for xetup. +// Phase 1 – configuration form (huh embedded in bubbletea). +// Phase 2 – live log view while PowerShell scripts run. +// Phase 3 – final summary. +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "git.xetup.x9.cz/x9/xetup/internal/config" + "git.xetup.x9.cz/x9/xetup/internal/runner" +) + +// -------------------------------------------------------------------------- +// Phase +// -------------------------------------------------------------------------- + +type phase int + +const ( + phaseForm phase = iota + phaseRun + phaseDone +) + +// -------------------------------------------------------------------------- +// Async message types (runner → bubbletea) +// -------------------------------------------------------------------------- + +type logMsg runner.LogLine +type resultMsg runner.Result +type doneMsg []runner.Result + +// -------------------------------------------------------------------------- +// Model +// -------------------------------------------------------------------------- + +// Model is the top-level bubbletea model. +type Model struct { + phase phase + form *huh.Form + runCfg runner.RunConfig + cfg config.Config + + // form-bound values (huh writes here via pointer) + pcName string + pcDesc string + productKey string + profileType string + selectedStepIDs []string + + // runner state + r *runner.Runner + cancel context.CancelFunc + msgCh chan tea.Msg + + logs []runner.LogLine + results []runner.Result + + // terminal dimensions + width int + height int +} + +// NewModel returns the initial model pre-populated from cfg. +func NewModel(cfg config.Config, runCfg runner.RunConfig) Model { + m := Model{ + cfg: cfg, + runCfg: runCfg, + pcName: cfg.Deployment.PCName, + pcDesc: cfg.Deployment.PCDescription, + productKey: cfg.Activation.ProductKey, + profileType: cfg.Deployment.ProfileType, + width: 80, + height: 24, + } + m.form = buildForm(&m) + return m +} + +// -------------------------------------------------------------------------- +// Form construction +// -------------------------------------------------------------------------- + +func buildForm(m *Model) *huh.Form { + allSteps := runner.AllSteps() + opts := make([]huh.Option[string], len(allSteps)) + for i, s := range allSteps { + opts[i] = huh.NewOption(s.Num+" - "+s.Name, s.ID).Selected(true) + } + + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Jmeno pocitace"). + Placeholder("napr. NB-KLIENT-01 (prazdne = neprejmenovat)"). + Value(&m.pcName), + huh.NewInput(). + Title("Popis pocitace"). + Placeholder("napr. PC recepce"). + Value(&m.pcDesc), + huh.NewInput(). + Title("Product Key"). + Placeholder("prazdne = OA3 / GVLK fallback"). + Value(&m.productKey), + huh.NewSelect[string](). + Title("Profil"). + Options( + huh.NewOption("Default", "default"), + huh.NewOption("Admin (Explorer, PS, Edge)", "admin"), + huh.NewOption("User (Explorer, Edge)", "user"), + ). + Value(&m.profileType), + ), + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Kroky k provedeni (mezernikem odzaskrtnout)"). + Options(opts...). + Value(&m.selectedStepIDs), + ), + ) +} + +// -------------------------------------------------------------------------- +// bubbletea interface +// -------------------------------------------------------------------------- + +// Init implements tea.Model. +func (m Model) Init() tea.Cmd { + return m.form.Init() +} + +// Update implements tea.Model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + if m.cancel != nil { + m.cancel() + } + return m, tea.Quit + } + if m.phase == phaseDone { + return m, tea.Quit + } + + // --- async runner messages --- + + case logMsg: + m.logs = append(m.logs, runner.LogLine(msg)) + return m, readMsg(m.msgCh) + + case resultMsg: + m.results = append(m.results, runner.Result(msg)) + return m, readMsg(m.msgCh) + + case doneMsg: + m.results = []runner.Result(msg) + m.phase = phaseDone + return m, nil + } + + // --- form phase --- + if m.phase == phaseForm { + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + switch m.form.State { + case huh.StateCompleted: + return m.startRun() + case huh.StateAborted: + return m, tea.Quit + } + return m, cmd + } + + return m, nil +} + +// View implements tea.Model. +func (m Model) View() string { + switch m.phase { + case phaseForm: + return m.viewForm() + case phaseRun: + return m.viewRun() + case phaseDone: + return m.viewDone() + } + return "" +} + +// -------------------------------------------------------------------------- +// Phase transitions +// -------------------------------------------------------------------------- + +// startRun applies form values and kicks off the runner goroutine. +func (m Model) startRun() (Model, tea.Cmd) { + // persist form values into config + m.cfg.Deployment.PCName = m.pcName + m.cfg.Deployment.PCDescription = m.pcDesc + m.cfg.Activation.ProductKey = m.productKey + m.cfg.Deployment.ProfileType = m.profileType + m.runCfg.ProfileType = m.profileType + + // build step list with Enabled flag from the user's checklist + selected := make(map[string]bool, len(m.selectedStepIDs)) + for _, id := range m.selectedStepIDs { + selected[id] = true + } + allSteps := runner.AllSteps() + steps := make([]runner.Step, len(allSteps)) + for i, s := range allSteps { + s.Enabled = selected[s.ID] + steps[i] = s + } + + // set up channel and start runner + ch := make(chan tea.Msg, 200) + m.msgCh = ch + m.phase = phaseRun + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + rc := m.runCfg + r := runner.New( + rc, + func(l runner.LogLine) { ch <- logMsg(l) }, + func(res runner.Result) { ch <- resultMsg(res) }, + ) + m.r = r + + go func() { + results := r.Run(ctx, steps) + ch <- doneMsg(results) + }() + + return m, readMsg(ch) +} + +// readMsg returns a tea.Cmd that blocks until the next message arrives on ch. +func readMsg(ch <-chan tea.Msg) tea.Cmd { + return func() tea.Msg { + return <-ch + } +} + +// -------------------------------------------------------------------------- +// Styles +// -------------------------------------------------------------------------- + +var ( + sTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#58a6ff")) + sOK = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043")) + sError = lipgloss.NewStyle().Foreground(lipgloss.Color("#da3633")) + sWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("#d29922")) + sStep = lipgloss.NewStyle().Foreground(lipgloss.Color("#58a6ff")) + sMuted = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + sBold = lipgloss.NewStyle().Bold(true) +) + +// -------------------------------------------------------------------------- +// Views +// -------------------------------------------------------------------------- + +func (m Model) viewForm() string { + header := sTitle.Render("xetup") + " " + sMuted.Render("Windows deployment – vyplnte konfiguraci") + return header + "\n\n" + m.form.View() +} + +func (m Model) viewRun() string { + allSteps := runner.AllSteps() + doneCount := len(m.results) + totalCount := len(allSteps) + + var sb strings.Builder + + // header bar + sb.WriteString(sTitle.Render("xetup") + " ") + if doneCount < totalCount { + s := allSteps[doneCount] + sb.WriteString(sBold.Render(fmt.Sprintf("krok %d/%d", doneCount+1, totalCount))) + sb.WriteString(" " + s.Num + " - " + s.Name) + } else { + sb.WriteString(sBold.Render("dokoncuji...")) + } + sb.WriteByte('\n') + sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90))) + sb.WriteByte('\n') + + // log lines – show last N to fit terminal + maxLines := m.height - 5 + if maxLines < 5 { + maxLines = 5 + } + start := 0 + if len(m.logs) > maxLines { + start = len(m.logs) - maxLines + } + for _, l := range m.logs[start:] { + sb.WriteString(styledLogLine(l)) + sb.WriteByte('\n') + } + + sb.WriteString(sMuted.Render("\nCtrl+C pro zastaveni")) + return sb.String() +} + +func (m Model) viewDone() string { + var sb strings.Builder + + sb.WriteString(sTitle.Render("xetup ") + sBold.Render("hotovo") + "\n") + sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)) + "\n\n") + + okCount, errCount, skipCount := 0, 0, 0 + for _, res := range m.results { + var prefix string + switch res.Status { + case "OK": + okCount++ + prefix = sOK.Render(" OK ") + case "ERROR": + errCount++ + prefix = sError.Render(" ERROR ") + default: + skipCount++ + prefix = sMuted.Render(" SKIPPED ") + } + sb.WriteString(prefix + res.Step.Num + " - " + res.Step.Name) + if res.Elapsed > 0 { + sb.WriteString(sMuted.Render(fmt.Sprintf(" (%s)", res.Elapsed.Round(time.Second)))) + } + sb.WriteByte('\n') + } + + sb.WriteString("\n" + strings.Repeat("─", clamp(m.width, 20, 90)) + "\n") + summary := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", okCount, errCount, skipCount) + if errCount > 0 { + sb.WriteString(sError.Render(summary)) + } else { + sb.WriteString(sOK.Render(summary)) + } + sb.WriteString(sMuted.Render("\n\nStisknete libovolnou klavesu pro ukonceni...")) + return sb.String() +} + +// -------------------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------------------- + +func styledLogLine(l runner.LogLine) string { + switch l.Level { + case "OK": + return sOK.Render(l.Text) + case "ERROR": + return sError.Render(l.Text) + case "WARN": + return sWarn.Render(l.Text) + case "STEP": + return sStep.Render(l.Text) + default: + return sMuted.Render(l.Text) + } +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/web/spec/index.html b/web/spec/index.html index 06c6e3b..38e79d6 100644 --- a/web/spec/index.html +++ b/web/spec/index.html @@ -419,6 +419,44 @@ .comment-submit:disabled { opacity: .4; cursor: default; } .comment-error { font-size: .75rem; color: var(--red); } + /* ---- NEW REQUEST WIDGET ---- */ + .req-list { + margin-bottom: .8rem; + } + .req-item { + padding: .55rem .7rem; + border: 1px solid var(--border); + border-radius: 7px; + margin-bottom: .45rem; + font-size: .83rem; + line-height: 1.5; + background: var(--card2); + } + .req-item-meta { font-size: .72rem; color: var(--muted); margin-bottom: .2rem; } + .req-item-body { color: var(--text); white-space: pre-wrap; word-break: break-word; } + .req-form { display: flex; flex-direction: column; gap: .45rem; } + .req-name { + width: 220px; + background: var(--bg); border: 1px solid var(--border); border-radius: 5px; + color: var(--text); font-size: .8rem; padding: .35rem .5rem; font-family: inherit; + } + .req-text { + width: 100%; + background: var(--bg); border: 1px solid var(--border); border-radius: 5px; + color: var(--text); font-size: .82rem; padding: .4rem .5rem; + resize: vertical; min-height: 70px; font-family: inherit; line-height: 1.4; + } + .req-name:focus, .req-text:focus { outline: none; border-color: var(--accent-bright); } + .req-submit { + align-self: flex-start; + background: var(--accent-bright); color: #fff; border: none; + border-radius: 5px; padding: .32rem .9rem; font-size: .8rem; cursor: pointer; + transition: opacity .15s; + } + .req-submit:hover { opacity: .85; } + .req-submit:disabled { opacity: .4; cursor: default; } + .req-error { font-size: .75rem; color: var(--red); margin-top: .1rem; } + footer { border-top: 1px solid var(--border); padding: 1.2rem 2rem; @@ -475,6 +513,8 @@

Architektura

xetup.exe (Go TUI) spec.yaml + + + Novy pozadavek @@ -829,11 +869,11 @@
- - - - - + + + + + @@ -846,7 +886,7 @@ @@ -885,6 +925,34 @@ + + + + +
+
+ + + Novy pozadavek na automatizaci + Pozadavky +
+
+

+ Chcete automatizovat neco, co skript zatim neresi? + Napiste pozadavek sem – ulozi se do repozitare. + Technicky tym ho projde a zaradi do planu. +

+
+
Nacitam pozadavky...
+
+
+ + + + +
+
+
+ @@ -1189,6 +1257,69 @@ }); } + // ---- NEW-REQUEST WIDGET (issue #15) ---- + (function() { + const ISSUE = 15; + const listEl = document.getElementById('req-list'); + const nameEl = document.getElementById('req-name'); + const textEl = document.getElementById('req-text'); + const submitEl = document.getElementById('req-submit'); + const errorEl = document.getElementById('req-error'); + if (!listEl) return; + + function renderRequests(data) { + listEl.innerHTML = ''; + if (!data.length) { + listEl.innerHTML = '
Zatim zadne pozadavky. Bud prvni.
'; + return; + } + data.forEach(c => { + const m = c.body.match(/^\*\*(.+?)\*\*\n([\s\S]*)$/); + const name = m ? m[1] : (c.user.login === 'xetup-bot' ? 'anon' : c.user.login); + const text = m ? m[2] : c.body; + const el = document.createElement('div'); + el.className = 'req-item'; + el.innerHTML = + '
' + name.replace(/' + + '
' + text.replace(//g,'>') + '
'; + listEl.appendChild(el); + }); + } + + fetch(API + '/repos/' + REPO + '/issues/' + ISSUE + '/comments', { headers: HEADS }) + .then(r => r.json()) + .then(d => renderRequests(Array.isArray(d) ? d : [])) + .catch(() => { listEl.innerHTML = '
Chyba nacitani.
'; }); + + submitEl.addEventListener('click', () => { + const text = textEl.value.trim(); + if (!text) { errorEl.textContent = 'Pozadavek nesmi byt prazdny.'; return; } + errorEl.textContent = ''; + submitEl.disabled = true; + submitEl.textContent = 'Odesilam...'; + const name = nameEl.value.trim() || 'anon'; + const body = '**' + name + '**\n' + text; + fetch(API + '/repos/' + REPO + '/issues/' + ISSUE + '/comments', { + method: 'POST', headers: HEADS, body: JSON.stringify({ body: body }) + }) + .then(r => { if (!r.ok) throw new Error(r.status); return r.json(); }) + .then(c => { + textEl.value = ''; nameEl.value = ''; + submitEl.disabled = false; + submitEl.textContent = 'Odeslat pozadavek'; + // re-fetch to show updated list + return fetch(API + '/repos/' + REPO + '/issues/' + ISSUE + '/comments', { headers: HEADS }); + }) + .then(r => r.json()) + .then(d => renderRequests(Array.isArray(d) ? d : [])) + .catch(() => { + errorEl.textContent = 'Chyba odeslani. Zkus znovu.'; + submitEl.disabled = false; + submitEl.textContent = 'Odeslat pozadavek'; + }); + }); + })(); + enhanceRows(); })();
Single binary (go:embed scripty + assets)Offline provoz, jedna stazitelna .exe
TUI form (huh/bubbletea): PC name, popis, product keyInteraktivni zadani dat technikem
Checklist kroku (on/off per-script) + ulozit do config.jsonOpakovatelne nasazeni u stejneho klienta
Live log output behem spousteni PS scriptuStdout z powershell.exe v realnem case
Finalni summary OK/ERRORNa konci nasazeni
Single binary (go:embed scripty + assets)embed.go + cmd/xetup/main.go; builduje se jako 5 MB .exe
TUI form (huh/bubbletea): PC name, popis, product keyinternal/tui/tui.go – huh form, 2 stranky
Checklist kroku (on/off per-script) + ulozit do config.jsonMultiSelect v TUI; internal/config/config.go
Live log output behem spousteni PS scriptuinternal/runner/runner.go; channel + bubbletea cmd
Finalni summary OK/ERRORviewDone() v tui.go
Self-update: stahnout novou verzi z xetup.x9.czOverit hash pred spustenim
config.json: per-klient preset (prefix jmena PC, SW, klic)Lezi vedle .exe na USB klienta
OpenVPN soubor + doménovy join + domén. uzivatel pro profilRozsireni TUI formulare v budoucnu