From 466f9fde310d9572b741573848344d3c6c83a0d7 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 7 Jul 2026 10:33:54 +0200 Subject: [PATCH] feat: asobi dev - run a local backend for your Lua game One command boots Postgres + the asobi_lua runtime via Docker Compose, mounts your Lua (lua/ or game/), streams logs, and serves the API + WS on localhost. The no-account, no-key self-host loop: pairs with 'asobi init'. Thin orchestrator in internal/dev - writes a managed compose to .asobi/dev-compose.yml and shells out to 'docker compose up'. Only cmdDev imports it, so control-plane commands keep zero Docker dependency. Docker missing/stopped yields an actionable message; Ctrl+C lets Compose stop cleanly. Flags: --port (default 8084), --dir. Closes #30. --- cmd/asobi/main.go | 28 +++++++ internal/dev/dev.go | 158 +++++++++++++++++++++++++++++++++++++++ internal/dev/dev_test.go | 61 +++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 internal/dev/dev.go create mode 100644 internal/dev/dev_test.go diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 9fba9a3..e4a4453 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -14,6 +14,7 @@ import ( "github.com/widgrensit/asobi-cli/internal/client" "github.com/widgrensit/asobi-cli/internal/config" "github.com/widgrensit/asobi-cli/internal/deploy" + "github.com/widgrensit/asobi-cli/internal/dev" "github.com/widgrensit/asobi-cli/internal/scaffold" "github.com/widgrensit/asobi-cli/internal/template" ) @@ -59,6 +60,8 @@ func main() { cmdEnvs() case "init": cmdInit() + case "dev": + cmdDev() case "destroy": cmdDestroy() case "env": @@ -88,6 +91,8 @@ Usage: asobi init [dir] Scaffold a starter Lua game asobi init [dir] --template Scaffold a runnable demo (defold|godot|unity) + asobi dev [--port N] [--dir ] + Run a local backend for your Lua game (Docker) asobi games List your tenant's games asobi use Set the active game asobi create [--size xs|s|m|l] [--game ] Create an environment @@ -726,6 +731,29 @@ func cmdInit() { step("Connect a client - Defold quickstart: https://github.com/widgrensit/asobi-defold") } +func cmdDev() { + args := os.Args[2:] + portStr, args := extractFlag(args, "--port") + luaDir, _ := extractFlag(args, "--dir") + + port := 8084 + if portStr != "" { + n, err := strconv.Atoi(portStr) + if err != nil || n < 1 || n > 65535 { + fatal("--port must be a number 1-65535, got %q", portStr) + } + port = n + } + + root, err := os.Getwd() + if err != nil { + fatal("dev: %v", err) + } + if err := dev.Run(dev.Options{Root: root, LuaOverride: luaDir, Port: port}); err != nil { + fatal("%v", err) + } +} + // --- Game resolution helpers --- // extractFlag pulls a "--name value" pair out of args, returning the value diff --git a/internal/dev/dev.go b/internal/dev/dev.go new file mode 100644 index 0000000..142a24c --- /dev/null +++ b/internal/dev/dev.go @@ -0,0 +1,158 @@ +// Package dev runs a local Asobi backend for iterating on a Lua game. It is a +// thin orchestrator over Docker Compose: it writes a managed compose file and +// shells out to `docker compose up`. It is the no-account, no-key self-host side +// of the CLI - it never touches credentials or the control plane. Only cmdDev +// imports this package, so the control-plane commands keep zero Docker dependency. +package dev + +import ( + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" +) + +// Options configures a dev run. +type Options struct { + Root string // project directory (usually the working dir) + LuaOverride string // explicit Lua dir, from --dir; empty to auto-detect + Port int // host + container port for the API/WebSocket +} + +// Run resolves the Lua directory, checks Docker, writes a managed compose file, +// and runs `docker compose up` in the foreground until the user stops it. +func Run(opts Options) error { + luaDir, err := resolveLuaDir(opts.Root, opts.LuaOverride) + if err != nil { + return err + } + if err := ensureDocker(); err != nil { + return err + } + + absLua, err := filepath.Abs(filepath.Join(opts.Root, luaDir)) + if err != nil { + return err + } + composePath := filepath.Join(opts.Root, ".asobi", "dev-compose.yml") + if err := os.MkdirAll(filepath.Dir(composePath), 0o755); err != nil { + return fmt.Errorf("create .asobi: %w", err) + } + if err := os.WriteFile(composePath, []byte(renderCompose(absLua, opts.Port)), 0o644); err != nil { + return fmt.Errorf("write compose: %w", err) + } + + banner(luaDir, opts.Port, composePath) + return composeUp(composePath, opts.Root) +} + +// resolveLuaDir finds the Lua game directory: an explicit override, then lua/, +// then game/. It errors if none is a directory. +func resolveLuaDir(root, override string) (string, error) { + for _, c := range []string{override, "lua", "game"} { + if c == "" { + continue + } + if fi, err := os.Stat(filepath.Join(root, c)); err == nil && fi.IsDir() { + return c, nil + } + } + return "", fmt.Errorf("no Lua game found (looked for lua/ or game/). Run `asobi init` first, or pass --dir ") +} + +// ensureDocker verifies Docker and Compose v2 are installed and the daemon is +// up, returning an actionable message otherwise. +func ensureDocker() error { + if _, err := exec.LookPath("docker"); err != nil { + return fmt.Errorf("`asobi dev` needs Docker. Install Docker Desktop (macOS/Windows) or Docker Engine (Linux): https://docs.docker.com/get-docker/") + } + if out, err := exec.Command("docker", "compose", "version").CombinedOutput(); err != nil { + return fmt.Errorf("`asobi dev` needs Docker Compose v2 (`docker compose`): %s", strings.TrimSpace(string(out))) + } + if err := exec.Command("docker", "info").Run(); err != nil { + return fmt.Errorf("the Docker daemon isn't running - start Docker Desktop (or the docker service) and try again") + } + return nil +} + +func banner(luaDir string, port int, composePath string) { + fmt.Println("Starting your Asobi dev server - no account, no keys, all local.") + fmt.Printf(" Lua game: ./%s\n", luaDir) + fmt.Printf(" API + WS: http://localhost:%d (WebSocket at /ws)\n", port) + fmt.Printf(" Compose: %s (managed by asobi dev)\n", composePath) + fmt.Println() + fmt.Println("First boot pulls the asobi_lua image and runs migrations - give it a few seconds.") + fmt.Println("Edit files under your Lua directory and they hot-reload; no restart needed.") + fmt.Println("Connect a client or register a player: https://asobi.dev/docs/quickstart") + fmt.Println("Press Ctrl+C to stop.") + fmt.Println() +} + +// composeUp runs `docker compose up` in the foreground, streaming its logs. It +// suppresses the parent's default Ctrl+C handler so Compose can shut its +// containers down gracefully before this returns. +func composeUp(composePath, root string) error { + cmd := exec.Command("docker", "compose", "-p", "asobi-dev", "-f", composePath, "up") + cmd.Dir = root + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sig) + go func() { + for range sig { + } + }() + + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker compose up: %w", err) + } + return nil +} + +func renderCompose(absLuaDir string, port int) string { + return fmt.Sprintf(`# Managed by `+"`asobi dev`"+` - regenerated each run, safe to delete. +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: asobi_dev + volumes: + - asobi_dev_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d asobi_dev -h 127.0.0.1 -p 5432"] + interval: 3s + timeout: 5s + retries: 15 + start_period: 20s + + asobi: + image: ghcr.io/widgrensit/asobi_lua:latest + restart: on-failure + depends_on: + postgres: + condition: service_healthy + ports: + - "%d:%d" + volumes: + - "%s:/app/game:ro" + environment: + ASOBI_PORT: "%d" + ASOBI_NODE_HOST: "127.0.0.1" + ERLANG_COOKIE: asobi_dev + ASOBI_DB_HOST: postgres + ASOBI_DB_NAME: asobi_dev + ASOBI_DB_USER: postgres + ASOBI_DB_PASSWORD: postgres + +volumes: + asobi_dev_pgdata: +`, port, port, filepath.ToSlash(absLuaDir), port) +} diff --git a/internal/dev/dev_test.go b/internal/dev/dev_test.go new file mode 100644 index 0000000..ba96f27 --- /dev/null +++ b/internal/dev/dev_test.go @@ -0,0 +1,61 @@ +package dev + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveLuaDir(t *testing.T) { + t.Run("prefers lua over game", func(t *testing.T) { + root := t.TempDir() + mustMkdir(t, root, "lua") + mustMkdir(t, root, "game") + if got, err := resolveLuaDir(root, ""); err != nil || got != "lua" { + t.Fatalf("got %q, %v; want lua", got, err) + } + }) + t.Run("falls back to game", func(t *testing.T) { + root := t.TempDir() + mustMkdir(t, root, "game") + if got, err := resolveLuaDir(root, ""); err != nil || got != "game" { + t.Fatalf("got %q, %v; want game", got, err) + } + }) + t.Run("honours override", func(t *testing.T) { + root := t.TempDir() + mustMkdir(t, root, "server") + mustMkdir(t, root, "lua") + if got, err := resolveLuaDir(root, "server"); err != nil || got != "server" { + t.Fatalf("got %q, %v; want server", got, err) + } + }) + t.Run("errors when none", func(t *testing.T) { + if _, err := resolveLuaDir(t.TempDir(), ""); err == nil { + t.Fatal("expected error when no lua/ or game/") + } + }) +} + +func TestRenderCompose(t *testing.T) { + out := renderCompose("/home/dev/mygame/lua", 8084) + for _, want := range []string{ + `"8084:8084"`, + `ghcr.io/widgrensit/asobi_lua:latest`, + `"/home/dev/mygame/lua:/app/game:ro"`, + `ASOBI_PORT: "8084"`, + `condition: service_healthy`, + } { + if !strings.Contains(out, want) { + t.Errorf("compose missing %q\n---\n%s", want, out) + } + } +} + +func mustMkdir(t *testing.T, root, name string) { + t.Helper() + if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil { + t.Fatal(err) + } +}