package main import ( "bytes" "encoding/json" "flag" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "strconv" "text/tabwriter" ) 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": 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 "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") } 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) resp, err := http.Get(targetUrl) 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 printUsage() { fmt.Println("VintageStory Server Manager") fmt.Println("\nGlobal Options:") fmt.Println(" --config Explicitly target a non-default config structure file location") fmt.Println("\nUsage Commands:") fmt.Println(" vssm daemon Starts the daemon") fmt.Println(" vssm create Creates the instance configuration and profile") fmt.Println(" vssm delete Deletes an instance profile, optionally deletes all files with the `--purge` flag") fmt.Println(" vssm start Launches an existing server instance from a stored profile") fmt.Println(" vssm stop Gracefully shuts down a server instance") fmt.Println(" vssm cmd \"\" Sends a command to an instance") fmt.Println(" vssm list Displays a list of all instances and their status") }