Files
Vantz Stockwell 651a35d4be
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s
docs(reference): import Dune: Awakening server-manager references
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.

- icehunter/    dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
                closest analog to our agent's Dune docker control plane (compose
                lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/  Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
                daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
                Hyper-V self-host path + game-config schema

See docs/reference-repos/README.md for the full index + how we use each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:08:05 -04:00

644 lines
22 KiB
Go

package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// exitSetup prints a pause prompt on Windows, then exits with the given code.
func exitSetup(code int) {
if runtime.GOOS == "windows" {
fmt.Println()
fmt.Println("Press Enter to close...")
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
}
os.Exit(code)
}
func runSetup() {
r := bufio.NewReader(os.Stdin)
ask := func(label, def string) string {
if def != "" {
fmt.Printf(" %s [%s]: ", label, def)
} else {
fmt.Printf(" %s: ", label)
}
line, _ := r.ReadString('\n')
line = strings.TrimSpace(line)
if line == "" {
return def
}
return line
}
ok := func(msg string) { fmt.Printf(" ✓ %s\n", msg) }
fail := func(msg string) { fmt.Printf(" ✗ %s\n", msg) }
fmt.Println()
fmt.Println("=== dune-admin setup ===")
fmt.Println()
// ── 1. Control plane ───────────────────────────────────────────────────────
fmt.Println("Control plane:")
fmt.Println(" kubectl — Kubernetes (k3s) via SSH (current default)")
fmt.Println(" docker — Docker containers (docker CLI with named containers)")
fmt.Println(" local — Generic local (configurable shell commands)")
fmt.Println(" amp — CubeCoders AMP with podman game-server container")
fmt.Println()
ctrl := ask("Control plane", "kubectl")
if ctrl != "kubectl" && ctrl != "docker" && ctrl != "local" && ctrl != "amp" {
ctrl = "kubectl"
}
fmt.Println()
var cfg appConfig
cfg.Control = ctrl
switch ctrl {
case "kubectl":
runKubectlSetup(ask, ok, fail, &cfg)
case "docker":
runDockerSetup(ask, ok, fail, &cfg)
case "local":
runLocalSetup(ask, ok, fail, &cfg)
case "amp":
runAmpSetup(ask, ok, fail, &cfg)
}
// ── Embedded market bot ────────────────────────────────────────────────────
runMarketBotSetup(ask, ok, &cfg)
// ── Common: listen address ─────────────────────────────────────────────────
fmt.Println("Server config:")
defaultListenAddr := listenAddr
if ctrl == "amp" && (defaultListenAddr == "" || defaultListenAddr == ":8080") {
// AMP web panel commonly binds :8080 on host installs.
defaultListenAddr = ":18080"
}
cfg.ListenAddr = ask("HTTP listen address", defaultListenAddr)
fmt.Println()
// ── Write config ───────────────────────────────────────────────────────────
writeSetupConfig(ok, fail, cfg)
fmt.Println("Setup complete.")
fmt.Println()
fmt.Println(" Run: dune-admin")
fmt.Println()
}
// ── kubectl setup flow ────────────────────────────────────────────────────────
func setupKubectlSSHKey(ask func(string, string) string, ok, fail func(string)) string {
// SSH key
fmt.Println("Checking for SSH key...")
keyPath := resolveKeyPath()
if _, err := os.Stat(keyPath); err != nil {
fail("SSH key not found (checked ~/.dune-admin/sshKey, next to binary, ./sshKey)")
fmt.Println()
sshKeyPath = ask("Path to SSH private key", "")
if sshKeyPath == "" {
fmt.Fprintln(os.Stderr, "SSH key is required. Aborting.")
exitSetup(1)
}
if _, err := os.Stat(sshKeyPath); err != nil {
fmt.Fprintf(os.Stderr, "Key not found at %s. Aborting.\n", sshKeyPath)
exitSetup(1)
}
keyPath = sshKeyPath
} else {
ok("SSH key: " + keyPath)
sshKeyPath = keyPath
}
fmt.Println()
return keyPath
}
func setupKubectlSSHConnection(
ask func(string, string) string,
ok, fail func(string),
keyPath string,
) *sshExecutor {
// SSH connection details
fmt.Println("SSH connection:")
sshHost = ask("VM host:port", envOr("SSH_HOST", "192.168.0.72:22"))
sshUser = ask("SSH user", envOr("SSH_USER", "dune"))
fmt.Println()
// Dial
fmt.Printf("Connecting via SSH to %s...\n", sshHost)
client, err := dialSSH(sshHost, sshUser, keyPath)
if err != nil {
fail("SSH failed: " + err.Error())
fmt.Println()
fmt.Printf(" Attempted: user=%s host=%s key=%s\n", sshUser, sshHost, keyPath)
fmt.Println()
fmt.Println(" Make sure:")
fmt.Println(" - The VM is reachable at the given host:port")
fmt.Println(" - The correct SSH private key is specified")
fmt.Println(" - That key's public key is in ~/.ssh/authorized_keys on the VM")
fmt.Println(" - The SSH user matches the account on the VM (default: dune)")
fmt.Println(" - The SSH user has passwordless sudo for kubectl")
exitSetup(1)
}
ok("SSH connected")
fmt.Println()
globalSSH = client
return &sshExecutor{client: client}
}
func setupKubectlDiscoverDBPod(exec *sshExecutor, ok, fail func(string), cfg *appConfig) {
// Discover DB pod
fmt.Println("Discovering database pod...")
ns, pod, podIP, err := discoverDBPod(exec)
if err != nil {
fail("Pod discovery failed: " + err.Error())
fmt.Println()
fmt.Println(" Make sure the SSH user can run: sudo kubectl get pods -A")
exitSetup(1)
}
globalPodNS = ns
globalPod = pod
globalPodIP = podIP
cfg.ControlNamespace = ns
controlNS = ns
ok("Database pod: " + pod)
fmt.Println()
}
func selectBattlegroup(battlegroups []string, ask func(string, string) string) string {
if len(battlegroups) == 0 {
return ""
}
chosen := battlegroups[0]
if len(battlegroups) == 1 {
return chosen
}
fmt.Println(" Available battlegroups:")
for i, bg := range battlegroups {
fmt.Printf(" [%d] %s\n", i+1, bg)
}
fmt.Println()
idxStr := ask(fmt.Sprintf("Which battlegroup? [1-%d]", len(battlegroups)), "1")
idx := 1
_, _ = fmt.Sscanf(idxStr, "%d", &idx)
if idx >= 1 && idx <= len(battlegroups) {
chosen = battlegroups[idx-1]
}
return chosen
}
func setupKubectlDiscoverDBCredentials(
exec *sshExecutor,
ask func(string, string) string,
ok, fail func(string),
cfg *appConfig,
) (string, string) {
// Discover DB password
fmt.Println("Discovering database password...")
discoveredUser := "postgres"
discoveredPass := ""
var battlegroups []string
if bg := battlegroupFromPod(globalPod); bg != "" {
battlegroups = []string{bg}
} else {
battlegroups = listBattlegroups(exec)
}
if len(battlegroups) == 0 {
fmt.Println(" Could not determine battlegroup name")
} else {
chosen := selectBattlegroup(battlegroups, ask)
yamlPath := fmt.Sprintf("~/.dune/%s.yaml", chosen)
if u, pass := extractPasswordFromYAML(exec, yamlPath); pass != "" {
discoveredUser = u
discoveredPass = pass
ok(fmt.Sprintf("Password found in %s (user: %s)", yamlPath, u))
} else {
fail("No password found in " + yamlPath)
}
}
if discoveredPass == "" {
fmt.Println()
fmt.Println(" Could not auto-discover the database password.")
discoveredUser = ask("Database user", "postgres")
discoveredPass = ask("Database password", "")
if discoveredPass == "" {
fmt.Fprintln(os.Stderr, "Database password is required. Aborting.")
exitSetup(1)
}
}
fmt.Println()
return discoveredUser, discoveredPass
}
func setupKubectlConnectDB(
discoveredUser, discoveredPass string,
exec *sshExecutor,
cfg *appConfig,
ok, fail func(string),
) {
// Connect to DB
fmt.Println("Connecting to database...")
dbUser = discoveredUser
dbPass = discoveredPass
pool, err := connectDB(context.Background(), discoveredUser, discoveredPass)
if err != nil {
fail("DB connect failed: " + err.Error())
fmt.Println()
fmt.Printf(" The password may be wrong. Delete %s and re-run to try again.\n", configPath())
exitSetup(1)
}
globalDB = pool
globalExecutor = exec
globalControl = newControlPlane("kubectl", *cfg)
ok("Database connected as: " + dbUser)
fmt.Println()
}
func runKubectlSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) {
keyPath := setupKubectlSSHKey(ask, ok, fail)
exec := setupKubectlSSHConnection(ask, ok, fail, keyPath)
setupKubectlDiscoverDBPod(exec, ok, fail, cfg)
discoveredUser, discoveredPass := setupKubectlDiscoverDBCredentials(exec, ask, ok, fail, cfg)
setupKubectlConnectDB(discoveredUser, discoveredPass, exec, cfg, ok, fail)
cfg.ControlNamespace = globalPodNS
controlNS = globalPodNS
if abs, err := filepath.Abs(keyPath); err == nil {
keyPath = abs
}
cfg.SSHHost = sshHost
cfg.SSHUser = sshUser
cfg.SSHKey = keyPath
cfg.DBHost = "127.0.0.1"
cfg.DBPort = dbPort
cfg.DBUser = dbUser
cfg.DBPass = dbPass
cfg.DBName = dbName
cfg.DBSchema = dbSchema
cfg.ScripCurrency = scripCurrencyID
}
// ── docker setup flow ─────────────────────────────────────────────────────────
func runDockerSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) {
fmt.Println("Docker container names:")
cfg.DockerGameserver = ask("Game server container name", "dune-gameserver")
cfg.DockerBrokerGame = ask("mq-game broker container name (optional)", "")
cfg.DockerBrokerAdmin = ask("mq-admin broker container name (optional)", "")
fmt.Println()
// Test docker access
exec := &localExecutor{}
out, err := exec.Exec(fmt.Sprintf("docker inspect --format '{{.State.Status}}' %s 2>&1", cfg.DockerGameserver))
if err != nil {
fail(fmt.Sprintf("docker inspect failed: %s", out))
fmt.Println(" Make sure Docker is running and the container name is correct.")
fmt.Println(" Continuing anyway...")
} else {
ok(fmt.Sprintf("Container %s is %s", cfg.DockerGameserver, strings.TrimSpace(out)))
}
fmt.Println()
fmt.Println("Database connection:")
cfg.DBHost = ask("DB host (Docker DNS or IP)", "database")
cfg.DBPort = dbPort
portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort))
_, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort)
cfg.DBUser = ask("DB user", envOr("DB_USER", "dune"))
cfg.DBPass = ask("DB password", "")
if cfg.DBPass == "" {
fmt.Fprintln(os.Stderr, "Database password is required. Aborting.")
exitSetup(1)
}
cfg.DBName = ask("DB name", envOr("DB_NAME", "dune"))
cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune"))
fmt.Println()
fmt.Println("Connecting to database...")
dbHost = cfg.DBHost
dbPort = cfg.DBPort
dbUser = cfg.DBUser
dbPass = cfg.DBPass
dbName = cfg.DBName
dbSchema = cfg.DBSchema
globalExecutor = exec
pool, err := connectDBDirect(context.Background(), *cfg)
if err != nil {
fail("DB connect failed: " + err.Error())
exitSetup(1)
}
globalDB = pool
globalControl = newControlPlane("docker", *cfg)
ok("Database connected as: " + cfg.DBUser)
fmt.Println()
cfg.ScripCurrency = scripCurrencyID
}
// ── local setup flow ──────────────────────────────────────────────────────────
func runLocalSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) {
fmt.Println("Database connection:")
cfg.DBHost = ask("DB host", "127.0.0.1")
cfg.DBPort = dbPort
portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort))
_, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort)
cfg.DBUser = ask("DB user", envOr("DB_USER", "dune"))
cfg.DBPass = ask("DB password", "")
if cfg.DBPass == "" {
fmt.Fprintln(os.Stderr, "Database password is required. Aborting.")
exitSetup(1)
}
cfg.DBName = ask("DB name", envOr("DB_NAME", "dune"))
cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune"))
fmt.Println()
fmt.Println("Server control commands (optional — leave blank to skip):")
cfg.CmdStart = ask("Start command (e.g. 'amp start dune')", "")
cfg.CmdStop = ask("Stop command", "")
cfg.CmdRestart = ask("Restart command", "")
cfg.CmdStatus = ask("Status command", "")
fmt.Println()
fmt.Println("Connecting to database...")
dbHost = cfg.DBHost
dbPort = cfg.DBPort
dbUser = cfg.DBUser
dbPass = cfg.DBPass
dbName = cfg.DBName
dbSchema = cfg.DBSchema
exec := &localExecutor{}
globalExecutor = exec
pool, err := connectDBDirect(context.Background(), *cfg)
if err != nil {
fail("DB connect failed: " + err.Error())
exitSetup(1)
}
globalDB = pool
globalControl = newControlPlane("local", *cfg)
ok("Database connected as: " + cfg.DBUser)
fmt.Println()
cfg.ScripCurrency = scripCurrencyID
}
// ── amp setup flow ────────────────────────────────────────────────────────────
type ampSetupDefaults struct {
instance string
user string
topology string
selected *ampInstance
}
func detectAmpSetupDefaults(ask func(string, string) string) ampSetupDefaults {
d := ampSetupDefaults{instance: "DuneAwakening01", user: "amp", topology: "container"}
instances, detectedUser, _ := detectAmpInstances()
if len(instances) == 0 {
return d
}
fmt.Printf("Detected %d AMP instance(s):\n", len(instances))
for i, inst := range instances {
fmt.Printf(" %d) %s\n", i+1, summarizeInstance(inst))
}
choice := 1
if len(instances) > 1 {
pickStr := ask("Pick instance [1]", "1")
if n, err := strconv.Atoi(strings.TrimSpace(pickStr)); err == nil && n >= 1 && n <= len(instances) {
choice = n
}
}
d.selected = &instances[choice-1]
d.instance = d.selected.Name
if d.selected.InContainer {
d.topology = "container"
} else {
d.topology = "native"
}
if detectedUser != "" {
d.user = detectedUser
}
fmt.Println()
return d
}
// runAmpSetup configures the AMP control plane (CubeCoders AMP). AMP supports
// two deployment topologies — game server in a podman container, or running
// natively on the host as the AMP user. All paths/names are configurable.
//
// The wizard auto-detects AMP instances via `ampinstmgr -l` when available
// and pre-fills prompts with discovered values. Falls back to historical
// hardcoded defaults (DuneAwakening01, /AMP/duneawakening/...) when
// detection isn't possible — every prompt is still overridable.
func runAmpSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) {
d := detectAmpSetupDefaults(ask)
defaultInstance := d.instance
defaultUser := d.user
defaultTopology := d.topology
selected := d.selected
fmt.Println("AMP instance:")
cfg.AmpInstance = ask("Instance name (ampinstmgr instance)", defaultInstance)
cfg.AmpUser = ask("OS user that runs AMP", defaultUser)
fmt.Println()
fmt.Println("AMP topology:")
fmt.Println(" container — game server runs inside the AMP container (podman or docker)")
fmt.Println(" native — game server runs directly on the host as the AMP user")
topology := ask("Topology [container/native]", defaultTopology)
useContainer := topology != "native"
cfg.AmpUseContainer = &useContainer
// ── Game install root: try to probe the container instead of hardcoding
// `/AMP/duneawakening`. Falls back to the historical default if the
// probe can't answer (container not running, non-standard layout, etc.)
gameRoot := "/AMP/duneawakening"
if useContainer && selected != nil && selected.InContainer {
probeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
container := "AMP_" + cfg.AmpInstance
if root, _ := probeGameRoot(probeCtx, cfg.AmpUser, container); root != "" {
gameRoot = root
fmt.Printf("Detected game install root: %s\n", gameRoot)
}
cancel()
}
gameFolder := filepath.Base(gameRoot) // e.g. "duneawakening"
defaultLogPath := gameRoot + "/logs"
defaultIniDir := fmt.Sprintf("/home/%s/.ampdata/instances/%s/%s/server/state",
cfg.AmpUser, cfg.AmpInstance, gameFolder)
// The game's stock Default*.ini ship under the extracted game-server tree,
// the same in container and native topologies.
defaultDefaultIniDir := gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Config"
if !useContainer {
// Native topology: game files live under <game-root>/extracted/...
defaultLogPath = gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Saved/Logs"
defaultIniDir = gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Saved/Config/LinuxServer"
}
if useContainer {
defaultContainer := "AMP_" + cfg.AmpInstance
cfg.AmpContainer = ask("Container name", defaultContainer)
cfg.AmpContainerRuntime = ask("Container runtime [podman/docker]", "podman")
}
cfg.AmpLogPath = ask("Log directory", defaultLogPath)
cfg.DirectorURL = ask("Battlegroup Director URL (optional)", "http://127.0.0.1:11717")
fmt.Println()
// INI paths are fully determined by the AMP instance layout, so we derive
// them instead of asking. The instance state dir holds UserOverrides.ini
// (where dune-admin writes game settings) and ue5-saved/UserSettings (where
// runtime auto-discovery finds UserGame.ini / UserEngine.ini).
cfg.ServerIniDir = defaultIniDir
cfg.DefaultIniDir = defaultDefaultIniDir
fmt.Println("INI directories (auto-derived from the AMP instance):")
fmt.Printf(" instance state: %s\n", cfg.ServerIniDir)
fmt.Printf(" game defaults: %s\n", cfg.DefaultIniDir)
fmt.Println()
fmt.Println("RabbitMQ broker (used by capture mode AND live RMQ commands):")
var defaultBrokerPrefix string
if useContainer {
runtime := cfg.AmpContainerRuntime
if runtime == "" {
runtime = "podman"
}
defaultBrokerPrefix = fmt.Sprintf("sudo -i -u %s %s exec %s", cfg.AmpUser, runtime, cfg.AmpContainer)
} else {
defaultBrokerPrefix = fmt.Sprintf("sudo -i -u %s", cfg.AmpUser)
}
cfg.BrokerExecPrefix = ask("Broker exec prefix", defaultBrokerPrefix)
fmt.Println()
fmt.Println("Database connection:")
cfg.DBHost = ask("DB host", "127.0.0.1")
cfg.DBPort = dbPort
portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort))
_, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort)
cfg.DBUser = ask("DB user", envOr("DB_USER", "dune"))
cfg.DBPass = ask("DB password", "")
if cfg.DBPass == "" {
fmt.Fprintln(os.Stderr, "Database password is required. Aborting.")
exitSetup(1)
}
cfg.DBName = ask("DB name", envOr("DB_NAME", "dune"))
cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune"))
fmt.Println()
fmt.Println("Connecting to database...")
dbHost = cfg.DBHost
dbPort = cfg.DBPort
dbUser = cfg.DBUser
dbPass = cfg.DBPass
dbName = cfg.DBName
dbSchema = cfg.DBSchema
exec := &localExecutor{}
globalExecutor = &ampExecutor{Executor: exec, ampUser: cfg.AmpUser}
pool, err := connectDBDirect(context.Background(), *cfg)
if err != nil {
fail("DB connect failed: " + err.Error())
exitSetup(1)
}
globalDB = pool
globalControl = newControlPlane("amp", *cfg)
ok("Database connected as: " + cfg.DBUser)
fmt.Println()
fmt.Println("Reminder: dune-admin writes game settings to UserOverrides.ini and engine")
fmt.Println("settings to UserEngine.ini as " + cfg.AmpUser + " (AMP owns UserGame.ini itself).")
fmt.Println("Example /etc/sudoers.d/dune-admin entry:")
fmt.Printf(" %s ALL=(%s) NOPASSWD: /usr/bin/tee %s/UserOverrides.ini, /usr/bin/tee %s/ue5-saved/UserSettings/UserEngine.ini\n",
envOr("USER", "dune-admin"), cfg.AmpUser, cfg.ServerIniDir, cfg.ServerIniDir)
fmt.Println(" (If the service user does not own the INI files, also add: /usr/bin/cat)")
fmt.Println()
cfg.ScripCurrency = scripCurrencyID
}
// ── Market bot setup ───────────────────────────────────────────────────────────
func runMarketBotSetup(ask func(string, string) string, ok func(string), cfg *appConfig) {
fmt.Println("Embedded market bot:")
enabled := strings.ToLower(strings.TrimSpace(ask("Enable embedded market bot [yes/no]", "yes")))
enabledBool := enabled != "n" && enabled != "no" && enabled != "false" && enabled != "0"
cfg.MarketBotEnabled = &enabledBool
if !enabledBool {
ok("Embedded market bot disabled")
// Offer remote proxy config.
remoteURL := strings.TrimSpace(ask("Remote bot URL (leave blank to skip)", ""))
if remoteURL != "" {
cfg.MarketBotRemoteURL = remoteURL
cfg.MarketBotRemoteToken = strings.TrimSpace(ask("Remote bot API token", ""))
ok("Remote market bot proxy configured")
}
fmt.Println()
return
}
cfg.MarketBotCacheDB = ask("Bot cache database path", filepath.Join(configDir(), "market-bot-cache.db"))
cfg.MarketBotItemData = ask("Bot item-data.json path (optional)", "")
parseFloat := func(input string, fallback float64) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(input), 64)
if err != nil || v <= 0 {
return fallback
}
return v
}
parseInt := func(input string, fallback int) int {
v, err := strconv.Atoi(strings.TrimSpace(input))
if err != nil || v <= 0 {
return fallback
}
return v
}
cfg.MarketBotBuyInt = ask("Buy tick interval", "5m")
cfg.MarketBotListInt = ask("List tick interval", "30m")
cfg.MarketBotThresh = parseFloat(ask("Buy threshold multiplier", "1.05"), 1.05)
cfg.MarketBotMaxBuys = parseInt(ask("Max buys per tick", "50"), 50)
ok("Embedded market bot configured")
fmt.Println()
}
// ── Write config ──────────────────────────────────────────────────────────────
func writeSetupConfig(ok, fail func(string), cfg appConfig) {
cfgDir := configDir()
if err := os.MkdirAll(cfgDir, 0700); err != nil {
fail("Failed to create config directory: " + err.Error())
exitSetup(1)
}
cfgData, err := yaml.Marshal(cfg)
if err != nil {
fail("Failed to marshal config: " + err.Error())
exitSetup(1)
}
cfgFile := configPath()
if err := os.WriteFile(cfgFile, cfgData, 0600); err != nil {
fail("Failed to write config: " + err.Error())
exitSetup(1)
}
ok("Config written to " + cfgFile)
fmt.Println()
}