From afc3a33512c07955f12f90ca2e96159ba90668ef Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 14 Apr 2026 09:03:47 +0200 Subject: [PATCH 1/3] feat: ephemeral deploy, destroy, env list commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CLI support for ephemeral environment lifecycle: - asobi deploy --ephemeral [--name N] [--json] — create fresh env (1h TTL) - asobi destroy — delete env and revoke its keys (idempotent) - asobi env list [--ephemeral] [--json] — list envs for current game CI integration pattern (in README): wrap deploy with trap-based destroy so envs are cleaned up on test completion. Server-side reaper is the safety net if trap doesn't fire. Server-side: requires asobi_saas#22. Also: add 'asobi' build artefact to .gitignore. --- .gitignore | 1 + README.md | 23 +++++ cmd/asobi/main.go | 189 +++++++++++++++++++++++++++++++++++++++++- internal/auth/saas.go | 142 +++++++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 079d151..c214939 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ dist/ CLAUDE.md +asobi diff --git a/README.md b/README.md index baac60d..e8498c3 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,33 @@ asobi deploy game/ | `asobi logout` | Clear stored credentials | | `asobi whoami` | Show current session info | | `asobi deploy ` | Deploy Lua scripts to the engine | +| `asobi deploy --ephemeral [--name N] [--json]` | Create a fresh ephemeral env (1h TTL) and return env_id + api_key | +| `asobi destroy ` | Delete an environment and revoke its keys (idempotent) | +| `asobi env list [--ephemeral] [--json]` | List environments for the current game | | `asobi health` | Check engine health | | `asobi config set ` | Set manual config (`url`, `api_key`) | | `asobi config show` | Show current config | +## Ephemeral deploys (CI) + +For CI integration tests, use `--ephemeral` to create a fresh isolated env +that auto-deletes after 1 hour: + +```bash +DEPLOY=$(asobi deploy --ephemeral --json) +ENV_ID=$(echo "$DEPLOY" | jq -r .env_id) +ASOBI_API_KEY=$(echo "$DEPLOY" | jq -r .api_key) + +# Trap ensures cleanup even on failure +trap "asobi destroy $ENV_ID" EXIT + +# ... run tests against the ephemeral env ... +``` + +The 1-hour TTL is a safety net — if `trap` doesn't fire (runner timeout, +cancelled job), the server-side reaper deletes the env automatically within +5 minutes of expiry. No manual cleanup needed. + ### Login options ``` diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index d93e1cf..612fbaa 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -1,8 +1,12 @@ package main import ( + "encoding/json" "fmt" "os" + "strings" + "sync" + "time" "github.com/widgrensit/asobi-cli/internal/auth" "github.com/widgrensit/asobi-cli/internal/client" @@ -10,7 +14,7 @@ import ( "github.com/widgrensit/asobi-cli/internal/deploy" ) -const defaultSaasURL = "https://app.asobi.dev" +const defaultSaasURL = "https://app-dev.asobi.dev" func main() { if len(os.Args) < 2 { @@ -27,6 +31,10 @@ func main() { cmdWhoami() case "deploy": cmdDeploy() + case "destroy": + cmdDestroy() + case "env": + cmdEnv() case "health": cmdHealth() case "config": @@ -48,6 +56,10 @@ Usage: 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 @@ -134,8 +146,35 @@ func cmdWhoami() { func cmdDeploy() { dir := "." - if len(os.Args) > 2 { - dir = os.Args[2] + 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 ephemeral { + cmdDeployEphemeral(envName, jsonOut) + return } engineURL, apiKey := resolveDeployCredentials() @@ -152,15 +191,19 @@ func cmdDeploy() { 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) + + stop := startSpinner() result, err := c.Deploy(scripts) + stop() if err != nil { fatal("%v", err) } - fmt.Printf("\nDeployed %d scripts.\n", result.Deployed) + fmt.Printf("\r\033[KšŸ¦ Deployed %d scripts successfully!\n", result.Deployed) } func resolveDeployCredentials() (engineURL, apiKey string) { @@ -265,7 +308,145 @@ func cmdConfig() { } } +func startSpinner() func() { + frames := []string{ + "šŸ¦ Deploying. ", + "šŸ¦ Deploying.. ", + "šŸ¦ Deploying...", + } + var once sync.Once + done := make(chan struct{}) + go func() { + i := 0 + for { + select { + case <-done: + return + default: + fmt.Printf("\r%s", frames[i%len(frames)]) + i++ + time.Sleep(400 * time.Millisecond) + } + } + }() + return func() { + once.Do(func() { close(done) }) + } +} + func fatal(format string, args ...any) { fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) os.Exit(1) } + +// --- Ephemeral deploy --- + +func cmdDeployEphemeral(name string, jsonOut bool) { + creds, err := auth.LoadCredentials() + if err != nil || creds == nil || creds.AccessToken == "" { + fatal("not logged in. Run: asobi login") + } + + if !jsonOut { + fmt.Println("Creating ephemeral environment (1h TTL)...") + } + resp, err := auth.EphemeralDeploy(creds, name) + if err != nil { + fatal("ephemeral-deploy: %v", err) + } + + if jsonOut { + out, _ := json.Marshal(map[string]any{ + "env_id": resp.EnvID, + "api_key": resp.RawKey, + "expires_in": resp.ExpiresIn, + }) + fmt.Println(string(out)) + return + } + + fmt.Printf("\nšŸ¦ Ephemeral environment created!\n") + fmt.Printf(" env_id: %s\n", resp.EnvID) + fmt.Printf(" api_key: %s\n", resp.RawKey) + fmt.Printf(" expires_in: %ds (~%dm)\n", resp.ExpiresIn, resp.ExpiresIn/60) + fmt.Printf("\nTo destroy explicitly: asobi destroy %s\n", resp.EnvID) +} + +// --- Destroy --- + +func cmdDestroy() { + if len(os.Args) < 3 { + fatal("destroy requires an env_id\n\nUsage: asobi destroy ") + } + envID := os.Args[2] + + creds, err := auth.LoadCredentials() + if err != nil || creds == nil || creds.AccessToken == "" { + fatal("not logged in. Run: asobi login") + } + + if err := auth.Destroy(creds, envID); err != nil { + fatal("%v", err) + } + fmt.Printf("Destroyed %s\n", envID) +} + +// --- Env --- + +func cmdEnv() { + if len(os.Args) < 3 { + fmt.Println("Usage: asobi env list [--ephemeral] [--json]") + os.Exit(1) + } + + switch os.Args[2] { + case "list": + cmdEnvList() + default: + fatal("unknown env subcommand: %s", os.Args[2]) + } +} + +func cmdEnvList() { + ephemeral := false + jsonOut := false + for _, arg := range os.Args[3:] { + switch arg { + case "--ephemeral": + ephemeral = true + case "--json": + jsonOut = true + default: + fatal("unknown env list flag: %s", arg) + } + } + + creds, err := auth.LoadCredentials() + if err != nil || creds == nil || creds.AccessToken == "" { + fatal("not logged in. Run: asobi login") + } + + envs, err := auth.ListEnvs(creds, ephemeral) + if err != nil { + fatal("%v", err) + } + + if jsonOut { + out, _ := json.Marshal(envs) + fmt.Println(string(out)) + return + } + + if len(envs) == 0 { + fmt.Println("No environments.") + return + } + fmt.Printf("%-40s %-20s %-10s %-10s %s\n", "ID", "NAME", "STATUS", "EPHEMERAL", "EXPIRES") + for _, e := range envs { + eph := "no" + if e.IsEphemeral { + eph = "yes" + } + fmt.Printf("%-40s %-20s %-10s %-10s %s\n", e.ID, e.Name, e.Status, eph, e.ExpiresAt) + } +} diff --git a/internal/auth/saas.go b/internal/auth/saas.go index c1e8459..09a4442 100644 --- a/internal/auth/saas.go +++ b/internal/auth/saas.go @@ -15,6 +15,35 @@ type mintKeyResponse struct { Error string `json:"error,omitempty"` } +// EphemeralDeploy creates a fresh ephemeral environment + API key with 1h TTL. +type EphemeralDeployResponse struct { + EnvID string `json:"env_id"` + RawKey string `json:"raw_key"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error,omitempty"` +} + +// Environment is a single env returned by ListEnvs. +type Environment struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + IsEphemeral bool `json:"is_ephemeral"` + ExpiresAt string `json:"expires_at"` + InsertedAt string `json:"inserted_at"` +} + +type listEnvsResponse struct { + Environments []Environment `json:"environments"` + Error string `json:"error,omitempty"` +} + +type destroyResponse struct { + Status string `json:"status"` + EnvID string `json:"env_id"` + Error string `json:"error,omitempty"` +} + type refreshResponse struct { AccessToken string `json:"access_token,omitempty"` Error string `json:"error,omitempty"` @@ -95,3 +124,116 @@ func mustNewRequest(method, url string, body []byte) *http.Request { req.Header.Set("Content-Type", "application/json") return req } + +// EphemeralDeploy creates a fresh ephemeral env + API key with 1h TTL. +func EphemeralDeploy(creds *Credentials, name string) (*EphemeralDeployResponse, error) { + body, _ := json.Marshal(map[string]string{"name": name}) + req, err := http.NewRequest("POST", creds.SaasURL+"/internal/cli/ephemeral-deploy", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", 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, fmt.Errorf("POST /internal/cli/ephemeral-deploy: %w", err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 401 { + refreshed, refreshErr := RefreshAccessToken(creds) + if refreshErr != nil { + return nil, fmt.Errorf("access token expired and refresh failed: %w", refreshErr) + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return EphemeralDeploy(creds, name) + } + + var result EphemeralDeployResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("ephemeral-deploy failed (%d): %s", resp.StatusCode, result.Error) + } + return &result, nil +} + +// Destroy deletes an environment by ID. Idempotent. +func Destroy(creds *Credentials, envID string) error { + url := creds.SaasURL + "/internal/cli/environments/" + envID + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("DELETE /internal/cli/environments/%s: %w", envID, err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 401 { + refreshed, refreshErr := RefreshAccessToken(creds) + if refreshErr != nil { + return fmt.Errorf("access token expired and refresh failed: %w", refreshErr) + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return Destroy(creds, envID) + } + + var result destroyResponse + if err := json.Unmarshal(data, &result); err == nil && result.Error != "" { + return fmt.Errorf("destroy failed (%d): %s", resp.StatusCode, result.Error) + } + if resp.StatusCode != 200 { + return fmt.Errorf("destroy failed (%d): %s", resp.StatusCode, data) + } + return nil +} + +// ListEnvs returns all environments for the current game. +// If ephemeralOnly is true, only ephemeral envs are returned. +func ListEnvs(creds *Credentials, ephemeralOnly bool) ([]Environment, error) { + url := creds.SaasURL + "/internal/cli/environments" + if ephemeralOnly { + url += "?ephemeral=true" + } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("GET /internal/cli/environments: %w", err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 401 { + refreshed, refreshErr := RefreshAccessToken(creds) + if refreshErr != nil { + return nil, fmt.Errorf("access token expired and refresh failed: %w", refreshErr) + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return ListEnvs(creds, ephemeralOnly) + } + + var result listEnvsResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("list envs failed (%d): %s", resp.StatusCode, result.Error) + } + return result.Environments, nil +} From 5afd2d9c078543b652c4c42214a5e6028b462d05 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 14 Apr 2026 09:03:56 +0200 Subject: [PATCH 2/3] fix: increase deploy timeout to 5min, handle empty 2xx response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing local change. Larger Lua bundles can take longer than 30s to compile; bump timeout to 5min. Some engine versions return empty body on success — treat 2xx empty body as success. --- internal/client/client.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 8d13414..7b7006b 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -19,7 +19,7 @@ type Client struct { func New(cfg *config.Config) *Client { return &Client{ cfg: cfg, - http: &http.Client{Timeout: 30 * time.Second}, + http: &http.Client{Timeout: 300 * time.Second}, } } @@ -67,9 +67,16 @@ func (c *Client) Deploy(scripts []Script) (*DeployResponse, error) { return nil, fmt.Errorf("read response: %w", err) } + if len(data) == 0 { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return &DeployResponse{Deployed: len(scripts)}, nil + } + return nil, fmt.Errorf("deploy failed (%d): empty response", resp.StatusCode) + } + var result DeployResponse if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("parse response: %w", err) + return nil, fmt.Errorf("parse response (%d): %s", resp.StatusCode, string(data)) } if resp.StatusCode != 200 { From c939a72c871eef72213160be75ef11251bda08de Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 14 Apr 2026 09:13:39 +0200 Subject: [PATCH 3/3] ci: add Go CI workflow (vet, build, test) --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f46e885 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - name: Vet + run: go vet ./... + - name: Build + run: go build ./... + - name: Test + run: go test -race ./...