From 8f0cc780d7b241e576345afd27d8c28248da2995 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 17 Apr 2026 21:55:40 +0200 Subject: [PATCH] feat: add environment management commands New commands for the flat environment model: asobi create [--size xs|s|m|l] asobi stop asobi start asobi delete asobi envs Talks to saas /internal/cli/envs/* endpoints. Uses existing login credentials (device-code flow). Old deploy/destroy/env commands kept for backward compat. --- cmd/asobi/main.go | 130 ++++++++++++++++++++++++++++++++---- internal/auth/saas.go | 151 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 12 deletions(-) diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 612fbaa..cc29f66 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -29,8 +29,18 @@ func main() { cmdLogout() case "whoami": cmdWhoami() + case "create": + cmdCreate() case "deploy": cmdDeploy() + case "stop": + cmdStop() + case "start": + cmdStart() + case "delete": + cmdDelete() + case "envs": + cmdEnvs() case "destroy": cmdDestroy() case "env": @@ -52,18 +62,19 @@ func usage() { fmt.Println(`asobi - Asobi game backend CLI Usage: - asobi login Login via browser (device-code flow) - asobi logout Clear stored credentials - asobi whoami Show current credential info - asobi deploy Deploy Lua scripts to the engine - asobi deploy --ephemeral Create a fresh ephemeral env (1h TTL) + deploy - asobi destroy Delete an environment and revoke its keys - asobi env list List environments for the current game - asobi env list --ephemeral List only ephemeral environments - asobi health Check engine health - asobi config set Set config (url, api_key, saas_url) - asobi config show Show current config - asobi help Show this help + asobi login Login via browser (device-code flow) + asobi logout Clear stored credentials + asobi whoami Show current credential info + asobi create [--size xs|s|m|l] Create an environment + asobi deploy [dir] Deploy Lua scripts to an environment + asobi stop Stop an environment + asobi start Start an environment + asobi delete Delete an environment + asobi envs List your environments + asobi health Check engine health + asobi config set Set config (url, api_key, saas_url) + asobi config show Show current config + asobi help Show this help Login options: --saas-url SaaS URL (default: ` + defaultSaasURL + `) @@ -450,3 +461,98 @@ func cmdEnvList() { fmt.Printf("%-40s %-20s %-10s %-10s %s\n", e.ID, e.Name, e.Status, eph, e.ExpiresAt) } } + +// --- New environment commands --- + +func cmdCreate() { + if len(os.Args) < 3 { + fatal("usage: asobi create [--size xs|s|m|l]") + } + name := os.Args[2] + size := "xs" + for i := 3; i < len(os.Args); i++ { + if os.Args[i] == "--size" && i+1 < len(os.Args) { + size = os.Args[i+1] + i++ + } + } + + creds := mustLoadCreds() + result, err := auth.CreateEnv(creds, name, size) + if err != nil { + fatal("create: %v", err) + } + fmt.Printf("Environment created: %s (size: %s)\n", name, size) + if env, ok := result["environment"].(map[string]interface{}); ok { + if id, ok := env["id"].(string); ok { + fmt.Printf(" id: %s\n", id) + } + } +} + +func cmdStop() { + if len(os.Args) < 3 { + fatal("usage: asobi stop ") + } + creds := mustLoadCreds() + if err := auth.EnvAction(creds, os.Args[2], "stop"); err != nil { + fatal("stop: %v", err) + } + fmt.Printf("Environment %s stopping\n", os.Args[2]) +} + +func cmdStart() { + if len(os.Args) < 3 { + fatal("usage: asobi start ") + } + creds := mustLoadCreds() + if err := auth.EnvAction(creds, os.Args[2], "start"); err != nil { + fatal("start: %v", err) + } + fmt.Printf("Environment %s starting\n", os.Args[2]) +} + +func cmdDelete() { + if len(os.Args) < 3 { + fatal("usage: asobi delete ") + } + creds := mustLoadCreds() + if err := auth.DeleteEnv(creds, os.Args[2]); err != nil { + fatal("delete: %v", err) + } + fmt.Printf("Environment %s deleted\n", os.Args[2]) +} + +func cmdEnvs() { + creds := mustLoadCreds() + envs, err := auth.ListEnvs2(creds) + if err != nil { + fatal("list: %v", err) + } + if len(envs) == 0 { + fmt.Println("No environments. Create one with: asobi create ") + return + } + fmt.Printf("%-20s %-6s %-15s %s\n", "NAME", "SIZE", "STATUS", "ENDPOINT") + for _, e := range envs { + name, _ := e["name"].(string) + size, _ := e["size"].(string) + if size == "" { + size, _ = e["resource_tier"].(string) + } + status, _ := e["provisioning_status"].(string) + endpoint, _ := e["endpoint_url"].(string) + if endpoint == "" { + endpoint = "-" + } + fmt.Printf("%-20s %-6s %-15s %s\n", name, strings.ToUpper(size), status, endpoint) + } +} + +func mustLoadCreds() *auth.Credentials { + creds, err := auth.LoadCredentials() + if err != nil { + fatal("not logged in. Run: asobi login") + } + return creds +} diff --git a/internal/auth/saas.go b/internal/auth/saas.go index 09a4442..4f3eedc 100644 --- a/internal/auth/saas.go +++ b/internal/auth/saas.go @@ -237,3 +237,154 @@ func ListEnvs(creds *Credentials, ephemeralOnly bool) ([]Environment, error) { } return result.Environments, nil } + +// CreateEnv creates a named environment with a given size. +func CreateEnv(creds *Credentials, name, size string) (map[string]interface{}, error) { + body, _ := json.Marshal(map[string]string{"name": name, "size": size}) + req, err := http.NewRequest("POST", creds.SaasURL+"/internal/cli/envs/create", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 401 { + refreshed, err := RefreshAccessToken(creds) + if err != nil { + return nil, err + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return CreateEnv(creds, name, size) + } + var result map[string]interface{} + _ = json.Unmarshal(data, &result) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("create failed (%d): %s", resp.StatusCode, data) + } + return result, nil +} + +// DeployBundle uploads a zip bundle to a named environment. +func DeployBundle(creds *Credentials, name string, bundle []byte) (map[string]interface{}, error) { + req, err := http.NewRequest("POST", creds.SaasURL+"/internal/cli/envs/"+name+"/deploy", bytes.NewReader(bundle)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/zip") + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 401 { + refreshed, err := RefreshAccessToken(creds) + if err != nil { + return nil, err + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return DeployBundle(creds, name, bundle) + } + var result map[string]interface{} + _ = json.Unmarshal(data, &result) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("deploy failed (%d): %s", resp.StatusCode, data) + } + return result, nil +} + +// EnvAction performs stop/start on a named environment. +func EnvAction(creds *Credentials, name, action string) error { + req, err := http.NewRequest("POST", creds.SaasURL+"/internal/cli/envs/"+name+"/"+action, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == 401 { + refreshed, err := RefreshAccessToken(creds) + if err != nil { + return err + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return EnvAction(creds, name, action) + } + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%s failed (%d): %s", action, resp.StatusCode, data) + } + return nil +} + +// DeleteEnv deletes a named environment. +func DeleteEnv(creds *Credentials, name string) error { + req, err := http.NewRequest("DELETE", creds.SaasURL+"/internal/cli/envs/"+name, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == 401 { + refreshed, err := RefreshAccessToken(creds) + if err != nil { + return err + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return DeleteEnv(creds, name) + } + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete failed (%d): %s", resp.StatusCode, data) + } + return nil +} + +// ListEnvs2 returns environments using the new flat model. +func ListEnvs2(creds *Credentials) ([]map[string]interface{}, error) { + req, err := http.NewRequest("GET", creds.SaasURL+"/internal/cli/envs", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 401 { + refreshed, err := RefreshAccessToken(creds) + if err != nil { + return nil, err + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return ListEnvs2(creds) + } + var result struct { + Environments []map[string]interface{} `json:"environments"` + } + _ = json.Unmarshal(data, &result) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("list failed (%d): %s", resp.StatusCode, data) + } + return result.Environments, nil +}