From 7cc8f188016baef8b76461991538415058893efb Mon Sep 17 00:00:00 2001 From: Ilya Groshev Date: Wed, 22 Apr 2026 19:22:18 +0300 Subject: [PATCH] Add wizard cli --- .gitignore | 2 + README.md | 11 + server/Makefile | 7 +- server/cmd/wizard/main.go | 722 ++++++++++++++++++++++++++++++++++++++ server/go.mod | 25 +- server/go.sum | 44 +++ 6 files changed, 808 insertions(+), 3 deletions(-) create mode 100644 server/cmd/wizard/main.go diff --git a/.gitignore b/.gitignore index 6313ba1..8296a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ server/auth-server server/claim-account server/octo-cdn server/dev +server/wizard +server/.wizard.json __pycache__/ diff --git a/README.md b/README.md index 42ada2a..c00db30 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,17 @@ Discord server: https://discord.gg/MZAf5aVkJG go install github.com/pressly/goose/v3/cmd/goose@latest ``` +### Quick Start (Wizard) + +The interactive wizard walks you through setup with a few simple questions — no flags or networking knowledge needed. It auto-detects the right IP address for your emulator or phone and launches all services. + +```bash +cd server +go run ./cmd/wizard +``` + +Your choices are saved so next time you just press Enter to relaunch with the same settings. + ### Regenerate protobuf stubs ```bash diff --git a/server/Makefile b/server/Makefile index be9f0f4..10b265c 100644 --- a/server/Makefile +++ b/server/Makefile @@ -3,13 +3,16 @@ # (generating all would put them in one package and cause name clashes). PROTO_USED = proto/banner.proto proto/battle.proto proto/bighunt.proto proto/cageornament.proto proto/character.proto proto/characterboard.proto proto/characterviewer.proto proto/companion.proto proto/config.proto proto/consumableitem.proto proto/contentsstory.proto proto/costume.proto proto/data.proto proto/deck.proto proto/dokan.proto proto/explore.proto proto/friend.proto proto/gacha.proto proto/gameplay.proto proto/gift.proto proto/gimmick.proto proto/labyrinth.proto proto/loginbonus.proto proto/material.proto proto/mission.proto proto/movie.proto proto/navicutin.proto proto/omikuji.proto proto/notification.proto proto/parts.proto proto/portalcage.proto proto/pvp.proto proto/quest.proto proto/reward.proto proto/shop.proto proto/sidestoryquest.proto proto/tutorial.proto proto/user.proto proto/weapon.proto +PROTOC ?= protoc +GOOSE ?= goose + EXE = ifeq ($(OS),Windows_NT) EXE = .exe endif proto: - protoc -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server + $(PROTOC) -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server @echo "Generated in gen/proto/" build: @@ -36,7 +39,7 @@ ifeq ($(OS),Windows_NT) else mkdir -p db endif - goose -dir migrations -allow-missing sqlite3 db/game.db up + $(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up import: ifndef SNAPSHOT diff --git a/server/cmd/wizard/main.go b/server/cmd/wizard/main.go new file mode 100644 index 0000000..e963bf5 --- /dev/null +++ b/server/cmd/wizard/main.go @@ -0,0 +1,722 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + + "charm.land/huh/v2" + "charm.land/huh/v2/spinner" + "charm.land/lipgloss/v2" +) + +const banner = ` + _ _____ + | | _ _ _ _ __ _ _ _ |_ _|___ __ _ _ _ + | |_| || | ' \/ _` + "`" + ` | '_| | |/ -_)/ _` + "`" + ` | '_| + |____\_,_|_||_\__,_|_| |_|\___|\__,_|_| + +` + +const ( + configFile = ".wizard.json" + exitVal = "__exit__" +) + +type config struct { + IP string `json:"ip"` + Device string `json:"device"` + Detail string `json:"detail"` + Summary string `json:"summary"` +} + +func main() { + setupOnly := flag.Bool("setup-only", false, "show patching instructions and exit without building or launching") + flag.Parse() + + lipgloss.EnableLegacyWindowsANSI(os.Stdout) + lipgloss.EnableLegacyWindowsANSI(os.Stderr) + + fmt.Print(banner) + + if !*setupOnly { + validateAssets() + validateTools() + validateProtocIncludes() + runProtoc() + runMigrate() + downloadDeps() + } + + ip, cfg, firstRun := resolveIP() + + saveConfig(cfg) + + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(14) + addrStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + + fmt.Println() + fmt.Printf(" %s %s\n", labelStyle.Render("Game server:"), addrStyle.Render(ip+":8003")) + fmt.Printf(" %s %s\n", labelStyle.Render("CDN:"), addrStyle.Render(ip+":8080")) + fmt.Printf(" %s %s\n", labelStyle.Render("Auth:"), addrStyle.Render(ip+":3000")) + fmt.Println() + + if firstRun || *setupOnly { + showPatcherHint(ip, !*setupOnly) + } + + if *setupOnly { + return + } + + launchDev(ip) +} + +type assetCheck struct { + path string + dir bool +} + +var requiredAssets = []assetCheck{ + {"assets", true}, + {"assets/release/20240404193219.bin.e", false}, + {"assets/revisions/0/list.bin", false}, + {"assets/revisions/0/assetbundle", true}, + {"assets/revisions/0/resources", true}, +} + +func validateAssets() { + var missing []string + for _, a := range requiredAssets { + info, err := os.Stat(a.path) + if err != nil { + missing = append(missing, a.path) + continue + } + if a.dir && !info.IsDir() { + missing = append(missing, a.path+string(filepath.Separator)) + } else if !a.dir && info.IsDir() { + missing = append(missing, a.path) + } + } + + if len(missing) == 0 { + return + } + + errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) + pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + + var b strings.Builder + b.WriteString(errStyle.Render(" Required game assets are missing.")) + b.WriteString("\n\n") + for _, p := range missing { + b.WriteString(pathStyle.Render(" ✗ " + p)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again.")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Get them from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord: ") + hlStyle.Hyperlink("https://discord.com/invite/MZAf5aVkJG").Render("https://discord.com/invite/MZAf5aVkJG")) + b.WriteString("\n") + + fmt.Fprintln(os.Stderr, b.String()) + os.Exit(1) +} + +type toolReq struct { + bin string + install string // human-readable hint shown when tool must be installed manually + goInstall string // `go install` package path; non-empty means auto-installable +} + +var requiredTools = []toolReq{ + {"make", "https://www.gnu.org/software/make/", ""}, + {"protoc", "https://protobuf.dev/installation/", ""}, + {"protoc-gen-go", "", "google.golang.org/protobuf/cmd/protoc-gen-go@latest"}, + {"protoc-gen-go-grpc", "", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"}, + {"goose", "", "github.com/pressly/goose/v3/cmd/goose@latest"}, +} + +var toolPaths = map[string]string{} + +// findTool looks for a tool on PATH first, then falls back to the current +// directory (for Windows users who drop .exe files into server/). +func findTool(name string) (string, error) { + if p, err := exec.LookPath(name); err == nil { + return p, nil + } + local := name + if runtime.GOOS == "windows" { + local += ".exe" + } + if _, err := os.Stat(local); err == nil { + abs, err := filepath.Abs(local) + if err != nil { + return local, nil + } + return abs, nil + } + return "", fmt.Errorf("%s not found", name) +} + +func validateTools() { + var manual []toolReq + var installable []toolReq + + for _, t := range requiredTools { + if p, err := findTool(t.bin); err == nil { + toolPaths[t.bin] = p + } else if t.goInstall == "" { + manual = append(manual, t) + } else { + installable = append(installable, t) + } + } + + if len(manual) > 0 { + errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + var b strings.Builder + b.WriteString(errStyle.Render(" Required tools are not installed.")) + b.WriteString("\n\n") + for _, t := range manual { + b.WriteString(nameStyle.Render(fmt.Sprintf(" ✗ %-22s", t.bin)) + hintStyle.Render(t.install)) + b.WriteString("\n") + } + b.WriteString("\n") + + fmt.Fprintln(os.Stderr, b.String()) + os.Exit(1) + } + + for _, t := range installable { + fmt.Printf(" Installing %s...\n", t.bin) + cmd := exec.Command("go", "install", t.goInstall) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, " Failed to install %s: %v\n", t.bin, err) + os.Exit(1) + } + p, err := findTool(t.bin) + if err != nil { + fmt.Fprintf(os.Stderr, " %s installed but not found on PATH — is $(go env GOPATH)/bin in your PATH?\n", t.bin) + os.Exit(1) + } + toolPaths[t.bin] = p + } +} + +func validateProtocIncludes() { + if _, err := exec.LookPath("protoc"); err == nil { + return + } + // protoc is local (not on PATH) -- verify well-known types are present. + wkt := filepath.Join("google", "protobuf", "empty.proto") + if _, err := os.Stat(wkt); err == nil { + return + } + + errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) + pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + var b strings.Builder + b.WriteString(errStyle.Render(" protoc well-known types are missing.")) + b.WriteString("\n\n") + b.WriteString(pathStyle.Render(" ✗ " + wkt)) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Extract the google/ folder from the protoc release zip into server/.")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Download: https://github.com/protocolbuffers/protobuf/releases")) + b.WriteString("\n") + + fmt.Fprintln(os.Stderr, b.String()) + os.Exit(1) +} + +func runProtoc() { + _ = spinner.New().Title(" Running protoc...").Action(func() { + runQuiet(exec.Command(toolPaths["make"], "proto", "PROTOC="+toolPaths["protoc"]), "protoc generation") + }).Run() +} + +func runMigrate() { + _ = spinner.New().Title(" Running migrations...").Action(func() { + runQuiet(exec.Command(toolPaths["make"], "migrate", "GOOSE="+toolPaths["goose"]), "database migration") + }).Run() +} + +func downloadDeps() { + _ = spinner.New().Title(" Downloading dependencies...").Action(func() { + runQuiet(exec.Command("go", "mod", "download"), "dependency download") + }).Run() +} + +func runQuiet(cmd *exec.Cmd, label string) { + var buf strings.Builder + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + fmt.Fprintln(os.Stderr) + fmt.Fprint(os.Stderr, buf.String()) + fmt.Fprintf(os.Stderr, "\n %s failed: %v\n", label, err) + os.Exit(1) + } +} + +func resolveIP() (string, config, bool) { + if cfg, err := loadConfig(); err == nil { + ip, cfg, done := handleSavedConfig(cfg) + if done { + return ip, cfg, false + } + } + + ip, cfg := runWizard() + return ip, cfg, true +} + +func handleSavedConfig(cfg config) (string, config, bool) { + reuse := true + err := huh.NewConfirm(). + Title(fmt.Sprintf("Use same settings as last time? (%s — %s)", cfg.IP, cfg.Summary)). + Affirmative("Yes"). + Negative("No, reconfigure"). + Value(&reuse). + Run() + if err != nil { + os.Exit(1) + } + if !reuse { + return "", config{}, false + } + + if isLANBased(cfg) { + if ip, updated, ok := recheckLANIP(cfg); ok { + return ip, updated, true + } + return "", config{}, false + } + + return cfg.IP, cfg, true +} + +func isLANBased(cfg config) bool { + if cfg.Detail == "wifi" { + return true + } + switch cfg.Detail { + case "android-studio", "bluestacks", "genymotion": + return false + } + return cfg.Device == "emulator" +} + +func recheckLANIP(cfg config) (string, config, bool) { + current := detectLANIP() + if current == "" || current == cfg.IP { + return cfg.IP, cfg, true + } + + var action string + err := huh.NewSelect[string](). + Title(fmt.Sprintf("Your LAN IP changed: %s → %s", cfg.IP, current)). + Options( + huh.NewOption("Use new IP ("+current+")", "update"), + huh.NewOption("Keep saved IP ("+cfg.IP+")", "keep"), + huh.NewOption("Reconfigure from scratch", "reconfig"), + ). + Value(&action). + Run() + if err != nil { + os.Exit(1) + } + + switch action { + case "update": + warnRepatch(cfg.IP, current) + var ack bool + _ = huh.NewConfirm(). + Title("Continue launching the server?"). + Affirmative("Yes, start"). + Negative("No, exit"). + Value(&ack). + Run() + if !ack { + os.Exit(0) + } + cfg.IP = current + return current, cfg, true + case "keep": + return cfg.IP, cfg, true + default: + return "", config{}, false + } +} + +func warnRepatch(oldIP, newIP string) { + warnStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + + repoURL := "https://gitlab.com/walter-sparrow-group/lunar-scripts" + repoLink := hlStyle.Hyperlink(repoURL).Render(repoURL) + + var b strings.Builder + b.WriteString("\n") + b.WriteString(warnStyle.Render(" ⚠ Your APK was patched for the old IP.")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Re-patch using ") + hlStyle.Render("lunar_tear_patcher.ipynb") + dimStyle.Render(" in ") + hlStyle.Render("Google Colab") + dimStyle.Render(":")) + b.WriteString("\n\n") + b.WriteString(" " + repoLink) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Update the Configuration cell with the new addresses:")) + b.WriteString("\n\n") + b.WriteString(hlStyle.Render(fmt.Sprintf(" grpc_addr = \"%s:8003\"", newIP))) + b.WriteString("\n") + b.WriteString(hlStyle.Render(fmt.Sprintf(" http_addr = \"%s:8080\"", newIP))) + b.WriteString("\n") + b.WriteString(hlStyle.Render(fmt.Sprintf(" auth_host = \"%s:3000\"", newIP))) + b.WriteString("\n\n") + fmt.Print(b.String()) +} + +func showPatcherHint(ip string, askLaunch bool) { + headStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + + repoURL := "https://gitlab.com/walter-sparrow-group/lunar-scripts" + discordURL := "https://discord.com/invite/MZAf5aVkJG" + repoLink := hlStyle.Hyperlink(repoURL).Render(repoURL) + discordLink := hlStyle.Hyperlink(discordURL).Render(discordURL) + + var b strings.Builder + b.WriteString(headStyle.Render(" Next step: patch your APK")) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" The game client must be patched to connect to your server.")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Open ") + hlStyle.Render("lunar_tear_patcher.ipynb") + dimStyle.Render(" from the scripts repo in ") + hlStyle.Render("Google Colab") + dimStyle.Render(":")) + b.WriteString("\n\n") + b.WriteString(" " + repoLink) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Get the APK and master data links from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord:")) + b.WriteString("\n\n") + b.WriteString(" " + discordLink) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Set these in the notebook's Configuration cell:")) + b.WriteString("\n\n") + b.WriteString(hlStyle.Render(fmt.Sprintf(" grpc_addr = \"%s:8003\"", ip))) + b.WriteString("\n") + b.WriteString(hlStyle.Render(fmt.Sprintf(" http_addr = \"%s:8080\"", ip))) + b.WriteString("\n") + b.WriteString(hlStyle.Render(fmt.Sprintf(" auth_host = \"%s:3000\"", ip))) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Then run all cells — a patched APK will download automatically.")) + b.WriteString("\n\n") + fmt.Print(b.String()) + + if !askLaunch { + return + } + + launch := true + _ = huh.NewConfirm(). + Title("Launch the server?"). + Affirmative("Yes, start"). + Negative("No, exit"). + Value(&launch). + Run() + if !launch { + os.Exit(0) + } +} + +func runWizard() (string, config) { + var device, emu, conn string + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Where is the game running?"). + Options( + huh.NewOption("Android Emulator on this PC", "emulator"), + huh.NewOption("Phone / Tablet on the same network", "phone"), + huh.NewOption("Exit", exitVal), + ). + Value(&device), + ).WithShowHelp(true), + + huh.NewGroup( + huh.NewSelect[string](). + Title("Which emulator are you using? "). + Options( + huh.NewOption("Android Studio Emulator", "android-studio"), + huh.NewOption("BlueStacks", "bluestacks"), + huh.NewOption("Genymotion", "genymotion"), + huh.NewOption("Nox / LDPlayer / MEmu", "nox-ld-memu"), + huh.NewOption("Other / Not sure", "other"), + huh.NewOption("Exit", exitVal), + ). + Value(&emu), + ).WithHideFunc(func() bool { return device != "emulator" }).WithShowHelp(true), + + huh.NewGroup( + huh.NewSelect[string](). + Title("How is your phone connected to this PC?"). + Options( + huh.NewOption("Same Wi-Fi network", "wifi"), + huh.NewOption("Tailscale / ZeroTier / VPN", "vpn"), + huh.NewOption("Something else / I'll type the IP", "manual"), + huh.NewOption("Exit", exitVal), + ). + Value(&conn), + ).WithHideFunc(func() bool { return device != "phone" }).WithShowHelp(true), + ) + + if err := form.Run(); err != nil { + os.Exit(1) + } + + if device == exitVal || emu == exitVal || conn == exitVal { + os.Exit(0) + } + + return buildResult(device, emu, conn) +} + +func buildResult(device, emu, conn string) (string, config) { + if device == "emulator" { + return buildEmulatorResult(emu) + } + return buildPhoneResult(conn) +} + +func buildEmulatorResult(emu string) (string, config) { + switch emu { + case "android-studio", "bluestacks": + return "10.0.2.2", config{ + IP: "10.0.2.2", Device: "emulator", Detail: emu, + Summary: emu + " emulator", + } + case "genymotion": + return "10.0.3.2", config{ + IP: "10.0.3.2", Device: "emulator", Detail: emu, + Summary: "Genymotion emulator", + } + default: + ip := detectAndConfirmIP(detectLANIP(), "emulator (LAN IP)") + return ip, config{ + IP: ip, Device: "emulator", Detail: emu, + Summary: emu + " emulator (LAN IP)", + } + } +} + +func buildPhoneResult(conn string) (string, config) { + switch conn { + case "wifi": + ip := detectAndConfirmIP(detectLANIP(), "Wi-Fi") + return ip, config{IP: ip, Device: "phone", Detail: "wifi", Summary: "phone (Wi-Fi)"} + case "vpn": + ip := detectAndConfirmIP(detectVPNIP(), "VPN") + return ip, config{IP: ip, Device: "phone", Detail: "vpn", Summary: "phone (VPN)"} + default: + ip := promptIP("") + return ip, config{IP: ip, Device: "phone", Detail: "manual", Summary: "phone (manual IP)"} + } +} + +func detectAndConfirmIP(detected, label string) string { + if detected == "" { + fmt.Printf(" Could not auto-detect your %s IP address.\n", label) + return promptIP("") + } + return promptIP(detected) +} + +func promptIP(defaultVal string) string { + ip := defaultVal + + title := "Enter your PC's IP address" + if defaultVal != "" { + title = fmt.Sprintf("Enter your PC's IP address (detected: %s)", defaultVal) + } + + err := huh.NewInput(). + Title(title). + Description("Press Enter to accept, or type a different address."). + Validate(func(s string) error { + if net.ParseIP(strings.TrimSpace(s)) == nil { + return fmt.Errorf("not a valid IP address") + } + return nil + }). + Value(&ip). + Run() + if err != nil { + os.Exit(1) + } + return strings.TrimSpace(ip) +} + +func detectLANIP() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + + skipPrefixes := []string{"docker", "veth", "br-", "virbr", "tailscale", "zt", "tun", "utun"} + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + name := strings.ToLower(iface.Name) + skip := false + for _, prefix := range skipPrefixes { + if strings.HasPrefix(name, prefix) { + skip = true + break + } + } + if skip { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP.To4() + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + return ip.String() + } + } + return "" +} + +func detectVPNIP() string { + if path, err := exec.LookPath("tailscale"); err == nil { + out, err := exec.Command(path, "ip", "-4").Output() + if err == nil { + ip := strings.TrimSpace(string(out)) + if net.ParseIP(ip) != nil { + return ip + } + } + } + + _, tailscaleNet, _ := net.ParseCIDR("100.64.0.0/10") + vpnPrefixes := []string{"tailscale", "zt", "tun", "utun", "wg"} + + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 { + continue + } + name := strings.ToLower(iface.Name) + isVPN := false + for _, prefix := range vpnPrefixes { + if strings.HasPrefix(name, prefix) { + isVPN = true + break + } + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP.To4() + if ip == nil { + continue + } + if isVPN || tailscaleNet.Contains(ip) { + return ip.String() + } + } + } + return "" +} + +func loadConfig() (config, error) { + data, err := os.ReadFile(configFile) + if err != nil { + return config{}, err + } + var cfg config + if err := json.Unmarshal(data, &cfg); err != nil { + return config{}, err + } + if cfg.IP == "" { + return config{}, fmt.Errorf("empty config") + } + return cfg, nil +} + +func saveConfig(cfg config) { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return + } + _ = os.WriteFile(configFile, append(data, '\n'), 0644) +} + +func launchDev(ip string) { + cmd := exec.Command("go", "run", "./cmd/dev", + "--cdn.public-addr", ip+":8080", + "--grpc.public-addr", ip+":8003", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to start dev server: %v\n", err) + os.Exit(1) + } + + go func() { + <-sigCh + _ = cmd.Process.Signal(os.Interrupt) + }() + + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + } +} diff --git a/server/go.mod b/server/go.mod index 71c5ac6..4ef075b 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,6 +1,6 @@ module lunar-tear/server -go 1.25.0 +go 1.25.8 require ( github.com/google/uuid v1.6.0 @@ -16,13 +16,36 @@ require ( ) require ( + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + charm.land/huh/v2 v2.0.3 // indirect + charm.land/lipgloss/v2 v2.0.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/otel v1.40.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect modernc.org/libc v1.70.0 // indirect diff --git a/server/go.sum b/server/go.sum index 17f46ef..c1f5cc1 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,5 +1,37 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +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.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -18,8 +50,16 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.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-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= @@ -28,12 +68,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +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= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=