package main import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "flag" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "strconv" "text/tabwriter" "time" ) func main() { home, err := os.UserHomeDir() if err != nil { log.Fatalf("Could not locate user home dir: %v", err) } defaultConfigPath := filepath.Join(home, ".local", "share", "vssm", "config.json") configFlag := flag.String("config", defaultConfigPath, "Explicit path targeting a custom vssm config.json profile") flag.Usage = printUsage flag.Parse() args := flag.Args() if len(args) < 1 { printUsage() return } configExplicitlySet := false flag.Visit(func(f *flag.Flag) { if f.Name == "config" { configExplicitlySet = true } }) if !configExplicitlySet { if envConfig := os.Getenv("VSSM_CONFIG_PATH"); envConfig != "" { configFlag = &envConfig } } absConfigPath, err := filepath.Abs(*configFlag) if err != nil { log.Fatalf("Failed to resolve absolute configuration path target: %v", err) } if configExplicitlySet { if err := os.Setenv("VSSM_CONFIG_PATH", absConfigPath); err != nil { log.Fatalf("Warning: could not set VSSM_CONFIG_PATH: %v", err) } } cfg, err := LoadOrCreateConfig(absConfigPath) if err != nil { log.Fatalf("Initialization failed: %v", err) } listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) fmt.Printf("Got listen address from config: %s\n", listenAddress) subCommand := args[0] switch subCommand { case "daemon": if isDaemonRunning(cfg) { log.Fatalf("Cannot start daemon as it is already running. Stop it first (`pkill vssm`).") } fmt.Printf("Initializing VSSM Daemon [Config: %s]...\n", absConfigPath) if err := StartDaemon(cfg, absConfigPath); err != nil { log.Fatalf("Daemon runtime fatal error: %v", err) } case "generate-token": if isDaemonRunning(cfg) { log.Fatalf("Cannot generate a new token while the daemon is running. Stop it first (`pkill vssm`).") } token, err := generateToken() if err != nil { log.Fatalf("Failed to generate token: %v", err) } overwritten := cfg.Daemon.AuthToken != "" cfg.Daemon.AuthToken = token data, err := json.MarshalIndent(cfg, "", " ") if err != nil { log.Fatalf("Failed to marshal config: %v", err) } if err := os.WriteFile(absConfigPath, data, 0644); err != nil { log.Fatalf("Failed to write config: %v", err) } if overwritten { fmt.Println("Existing auth token was overwritten.") } fmt.Printf("New auth token: %s\n", token) case "create": if len(args) < 4 { log.Fatalf("Usage: vssm create ") } name := args[1] version := args[2] port := args[3] sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/create?name=%s&version=%s&port=%s", url.QueryEscape(name), url.QueryEscape(version), url.QueryEscape(port)), nil) case "start": if len(args) < 2 { log.Fatalf("Usage: vssm start ") } name := args[1] sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/start?name=%s", url.QueryEscape(name)), nil) case "stop": if len(args) < 2 { log.Fatalf("Usage: vssm stop ") } name := args[1] sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/stop?name=%s", url.QueryEscape(name)), nil) case "cmd": if len(args) < 3 { log.Fatalf("Usage: vssm cmd \"\"") } name := args[1] serverCmd := args[2] payload := CommandPayload{Command: serverCmd} body, _ := json.Marshal(payload) sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/command?name=%s", url.QueryEscape(name)), bytes.NewBuffer(body)) case "list", "status": fetchAndPrintStatus(cfg) case "show-config": fmt.Printf("%v", cfg.Storage.AppDataDir) case "delete": if len(args) < 2 { log.Fatalf("Usage: vssm delete ") } name := args[1] deleteCmd := flag.NewFlagSet("delete", flag.ExitOnError) purge := deleteCmd.Bool("purge", false, "Delete instance files from disk") deleteCmd.Parse(args[2:]) sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/delete?name=%s&purge=%s", url.QueryEscape(name), url.QueryEscape(strconv.FormatBool(*purge))), nil) case "modify": if len(args) < 4 { log.Fatalf("Usage: vssm modify ") } name := args[1] newName := args[2] newPort := args[3] sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/modify?name=%s&newName=%s&newPort=%s", url.QueryEscape(name), url.QueryEscape(newName), url.QueryEscape(newPort)), nil) default: printUsage() } } func sendIPCRequest(cfg *AppConfig, method string, path string, body io.Reader) { listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) targetUrl := fmt.Sprintf("http://%s%s", listenAddress, path) fmt.Printf("Listen Addr: %s; Target URL: %s\n", listenAddress, targetUrl) req, err := http.NewRequest(method, targetUrl, body) if err != nil { log.Fatalf("Failed to construct IPC request: %v", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } if cfg.Daemon.AuthToken != "" { req.Header.Set("Authorization", "Bearer "+cfg.Daemon.AuthToken) } resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalf("IPC connection failed. Is the VSSM daemon running? Error: %v", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { log.Fatalf("Error from daemon: %s", string(respBody)) } fmt.Println(string(respBody)) } func fetchAndPrintStatus(cfg *AppConfig) { listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) targetUrl := fmt.Sprintf("http://%s/instances/list", listenAddress) req, err := http.NewRequest("GET", targetUrl, nil) if err != nil { log.Fatalf("Failed to construct status request: %v", err) } if cfg.Daemon.AuthToken != "" { req.Header.Set("Authorization", "Bearer "+cfg.Daemon.AuthToken) } resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalf("IPC connection failed. Is the VSSM daemon running? Error: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) log.Fatalf("Error from daemon: %s", string(respBody)) } var instances []InstanceStatusResponse if err := json.NewDecoder(resp.Body).Decode(&instances); err != nil { log.Fatalf("Failed to decode daemon status matrix: %v", err) } if len(instances) == 0 { fmt.Println("No instances configured yet. Use 'create' to create one.") return } w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) fmt.Fprintln(w, "INSTANCE NAME\tVERSION\tPORT\tSTATUS") fmt.Fprintln(w, "-------------\t-------\t----\t------") for _, inst := range instances { fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", inst.Name, inst.Version, inst.Port, inst.Status) } w.Flush() } func generateToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } func isDaemonRunning(cfg *AppConfig) bool { listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) client := http.Client{Timeout: 500 * time.Millisecond} resp, err := client.Get(fmt.Sprintf("http://%s/instances/list", listenAddress)) if err != nil { return false } resp.Body.Close() return true } func printUsage() { fmt.Println("VintageStory Server Manager") fmt.Println("\nGlobal Options:") w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) fmt.Fprintln(w, " --config \tExplicitly target a non-default config structure file location") w.Flush() fmt.Println("\nUsage Commands:") w = tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) fmt.Fprintln(w, " vssm daemon\tStarts the daemon") fmt.Fprintln(w, " vssm generate-token\tGenerates a new auth token and stores it in the config") fmt.Fprintln(w, " vssm create \tCreates the instance configuration and profile") fmt.Fprintln(w, " vssm delete [--purge]\tDeletes an instance profile, optionally deleting all files") fmt.Fprintln(w, " vssm start \tLaunches an existing server instance from a stored profile") fmt.Fprintln(w, " vssm stop \tGracefully shuts down a server instance") fmt.Fprintln(w, " vssm cmd \"\"\tSends a command to an instance") fmt.Fprintln(w, " vssm modify \tRenames an instance and/or changes its port") fmt.Fprintln(w, " vssm list\tDisplays a list of all instances and their status") w.Flush() }