diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 612fbaa..d9990cf 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -14,7 +14,7 @@ import ( "github.com/widgrensit/asobi-cli/internal/deploy" ) -const defaultSaasURL = "https://app-dev.asobi.dev" +const defaultSaasURL = "https://saas.asobi.dev" func main() { if len(os.Args) < 2 { @@ -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 + `) @@ -145,40 +156,16 @@ func cmdWhoami() { // --- Deploy --- func cmdDeploy() { - dir := "." - ephemeral := false - jsonOut := false - envName := "" - - args := os.Args[2:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--ephemeral": - ephemeral = true - case "--json": - jsonOut = true - case "--name": - if i+1 >= len(args) { - fatal("--name requires a value") - } - i++ - envName = args[i] - default: - if !strings.HasPrefix(args[i], "--") { - dir = args[i] - } else { - fatal("unknown deploy flag: %s", args[i]) - } - } + if len(os.Args) < 3 { + fatal("usage: asobi deploy [dir]") } - if ephemeral { - cmdDeployEphemeral(envName, jsonOut) - return + envName := os.Args[2] + dir := "." + if len(os.Args) >= 4 && !strings.HasPrefix(os.Args[3], "--") { + dir = os.Args[3] } - engineURL, apiKey := resolveDeployCredentials() - scripts, err := deploy.CollectScripts(dir) if err != nil { fatal("collect scripts: %v", err) @@ -187,23 +174,26 @@ func cmdDeploy() { fatal("no .lua files found in %s", dir) } - fmt.Printf("Deploying %d scripts to %s...\n", len(scripts), engineURL) + fmt.Printf("Deploying %d scripts to %s...\n", len(scripts), envName) for _, s := range scripts { fmt.Printf(" %s (%d bytes)\n", s.Path, len(s.Content)) } - fmt.Println() - cfg := &config.Config{URL: engineURL, APIKey: apiKey} - c := client.New(cfg) + bundle, err := deploy.ZipScripts(scripts) + if err != nil { + fatal("zip scripts: %v", err) + } + fmt.Printf("Bundle: %d bytes\n", len(bundle)) - stop := startSpinner() - result, err := c.Deploy(scripts) - stop() + creds := mustLoadCreds() + result, err := auth.DeployBundle(creds, envName, bundle) if err != nil { - fatal("%v", err) + fatal("deploy: %v", err) } - fmt.Printf("\r\033[K🦝 Deployed %d scripts successfully!\n", result.Deployed) + gen, _ := result["generation"].(float64) + sha, _ := result["sha256"].(string) + fmt.Printf("\nDeployed! generation=%d sha256=%s\n", int(gen), sha[:12]+"...") } func resolveDeployCredentials() (engineURL, apiKey string) { @@ -450,3 +440,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 +} diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index 7071609..9c7dfbb 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -1,6 +1,8 @@ package deploy import ( + "archive/zip" + "bytes" "fmt" "os" "path/filepath" @@ -43,3 +45,22 @@ func CollectScripts(dir string) ([]client.Script, error) { return scripts, nil } + +// ZipScripts creates a zip archive from collected scripts. +func ZipScripts(scripts []client.Script) ([]byte, error) { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for _, s := range scripts { + f, err := w.Create(s.Path) + if err != nil { + return nil, fmt.Errorf("zip create %s: %w", s.Path, err) + } + if _, err := f.Write([]byte(s.Content)); err != nil { + return nil, fmt.Errorf("zip write %s: %w", s.Path, err) + } + } + if err := w.Close(); err != nil { + return nil, fmt.Errorf("zip close: %w", err) + } + return buf.Bytes(), nil +}