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 <noreply@anthropic.com>
This commit is contained in:
X9 Dev 2026-04-16 10:35:22 +02:00
parent 0cb00c4a46
commit 1198de3c49
8 changed files with 1070 additions and 6 deletions

69
cmd/xetup/main.go Normal file
View file

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

16
embed.go Normal file
View file

@ -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

32
go.mod Normal file
View file

@ -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
)

55
go.sum Normal file
View file

@ -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=

115
internal/config/config.go Normal file
View file

@ -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")
}

257
internal/runner/runner.go Normal file
View file

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

389
internal/tui/tui.go Normal file
View file

@ -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
}

View file

@ -419,6 +419,44 @@
.comment-submit:disabled { opacity: .4; cursor: default; } .comment-submit:disabled { opacity: .4; cursor: default; }
.comment-error { font-size: .75rem; color: var(--red); } .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 { footer {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 1.2rem 2rem; padding: 1.2rem 2rem;
@ -475,6 +513,8 @@
<h4>Architektura</h4> <h4>Architektura</h4>
<a href="#arch-xetup">xetup.exe (Go TUI)</a> <a href="#arch-xetup">xetup.exe (Go TUI)</a>
<a href="#arch-spec">spec.yaml</a> <a href="#arch-spec">spec.yaml</a>
<hr class="sidebar-divider">
<a href="#new-requests">+ Novy pozadavek</a>
</aside> </aside>
<!-- CONTENT --> <!-- CONTENT -->
@ -829,11 +869,11 @@
</div> </div>
<div class="step-body"> <div class="step-body">
<table class="items"> <table class="items">
<tr class="flag-todo"><td>Single binary (go:embed scripty + assets)</td><td>Offline provoz, jedna stazitelna .exe</td></tr> <tr class="flag-done"><td>Single binary (go:embed scripty + assets)</td><td><code>embed.go</code> + <code>cmd/xetup/main.go</code>; builduje se jako 5 MB .exe</td></tr>
<tr class="flag-todo"><td>TUI form (huh/bubbletea): PC name, popis, product key</td><td>Interaktivni zadani dat technikem</td></tr> <tr class="flag-done"><td>TUI form (huh/bubbletea): PC name, popis, product key</td><td><code>internal/tui/tui.go</code> &ndash; huh form, 2 stranky</td></tr>
<tr class="flag-todo"><td>Checklist kroku (on/off per-script) + ulozit do config.json</td><td>Opakovatelne nasazeni u stejneho klienta</td></tr> <tr class="flag-done"><td>Checklist kroku (on/off per-script) + ulozit do config.json</td><td>MultiSelect v TUI; <code>internal/config/config.go</code></td></tr>
<tr class="flag-todo"><td>Live log output behem spousteni PS scriptu</td><td>Stdout z powershell.exe v realnem case</td></tr> <tr class="flag-done"><td>Live log output behem spousteni PS scriptu</td><td><code>internal/runner/runner.go</code>; channel + bubbletea cmd</td></tr>
<tr class="flag-todo"><td>Finalni summary OK/ERROR</td><td>Na konci nasazeni</td></tr> <tr class="flag-done"><td>Finalni summary OK/ERROR</td><td>viewDone() v tui.go</td></tr>
<tr class="flag-todo"><td>Self-update: stahnout novou verzi z xetup.x9.cz</td><td>Overit hash pred spustenim</td></tr> <tr class="flag-todo"><td>Self-update: stahnout novou verzi z xetup.x9.cz</td><td>Overit hash pred spustenim</td></tr>
<tr class="flag-future"><td>config.json: per-klient preset (prefix jmena PC, SW, klic)</td><td>Lezi vedle .exe na USB klienta</td></tr> <tr class="flag-future"><td>config.json: per-klient preset (prefix jmena PC, SW, klic)</td><td>Lezi vedle .exe na USB klienta</td></tr>
<tr class="flag-future"><td>OpenVPN soubor + doménovy join + domén. uzivatel pro profil</td><td>Rozsireni TUI formulare v budoucnu</td></tr> <tr class="flag-future"><td>OpenVPN soubor + doménovy join + domén. uzivatel pro profil</td><td>Rozsireni TUI formulare v budoucnu</td></tr>
@ -846,7 +886,7 @@
</div> </div>
</div> </div>
<div class="step-footer"> <div class="step-footer">
<span class="step-status">Status: navrh, zatim zadny kod</span> <span class="step-status">Status: v implementaci &ndash; <code>cmd/xetup/</code>, <code>internal/{config,runner,tui}/</code></span>
<div class="comment-widget" data-issue="11"></div> <div class="comment-widget" data-issue="11"></div>
</div> </div>
</div> </div>
@ -885,6 +925,34 @@
</div> </div>
</div> </div>
<!-- ============================================================ -->
<p class="section-label">Nove nastaveni &ndash; pozadavky</p>
<!-- NEW REQUESTS -->
<div class="step" id="new-requests">
<div class="step-header">
<span class="step-num" style="font-size:1.1rem">+</span>
<span class="step-title">Novy pozadavek na automatizaci</span>
<span class="badge badge-new">Pozadavky</span>
</div>
<div class="step-body">
<p style="color:var(--muted);font-size:.84rem;line-height:1.5;margin-bottom:1rem">
Chcete automatizovat neco, co skript zatim neresi?
Napiste pozadavek sem &ndash; ulozi se do repozitare.
Technicky tym ho projde a zaradi do planu.
</p>
<div class="req-list" id="req-list">
<div style="font-size:.8rem;color:var(--muted);font-style:italic">Nacitam pozadavky...</div>
</div>
<div class="req-form" id="req-form">
<input class="req-name" id="req-name" placeholder="Vase jmeno (volitelne)" maxlength="60">
<textarea class="req-text" id="req-text" placeholder="Co by mel automat delat? Popiste konkretne, idealne i proc." rows="3"></textarea>
<button class="req-submit" id="req-submit">Odeslat pozadavek</button>
<span class="req-error" id="req-error"></span>
</div>
</div>
</div>
</div><!-- /content --> </div><!-- /content -->
</div><!-- /layout --> </div><!-- /layout -->
@ -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 = '<div style="font-size:.8rem;color:var(--muted);font-style:italic;margin-bottom:.5rem">Zatim zadne pozadavky. Bud prvni.</div>';
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 =
'<div class="req-item-meta">' + name.replace(/</g,'&lt;') + ' &middot; ' + timeAgo(c.created_at) + '</div>' +
'<div class="req-item-body">' + text.replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</div>';
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 = '<div style="font-size:.8rem;color:var(--red)">Chyba nacitani.</div>'; });
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(); enhanceRows();
})(); })();
</script> </script>