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
+}