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:
parent
0cb00c4a46
commit
1198de3c49
8 changed files with 1070 additions and 6 deletions
69
cmd/xetup/main.go
Normal file
69
cmd/xetup/main.go
Normal 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
16
embed.go
Normal 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
32
go.mod
Normal 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
55
go.sum
Normal 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
115
internal/config/config.go
Normal 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
257
internal/runner/runner.go
Normal 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
389
internal/tui/tui.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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> – 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 – <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 – 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 – 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,'<') + ' · ' + timeAgo(c.created_at) + '</div>' +
|
||||||
|
'<div class="req-item-body">' + text.replace(/</g,'<').replace(/>/g,'>') + '</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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue