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