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 ./...
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
+}
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 {