Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions cmd/asobi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -59,6 +60,8 @@ func main() {
cmdEnvs()
case "init":
cmdInit()
case "dev":
cmdDev()
case "destroy":
cmdDestroy()
case "env":
Expand Down Expand Up @@ -88,6 +91,8 @@ Usage:
asobi init [dir] Scaffold a starter Lua game
asobi init [dir] --template <engine>
Scaffold a runnable demo (defold|godot|unity)
asobi dev [--port N] [--dir <lua>]
Run a local backend for your Lua game (Docker)
asobi games List your tenant's games
asobi use <slug> Set the active game
asobi create <name> [--size xs|s|m|l] [--game <slug>] Create an environment
Expand Down Expand Up @@ -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
Expand Down
158 changes: 158 additions & 0 deletions internal/dev/dev.go
Original file line number Diff line number Diff line change
@@ -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 <path>")
}

// 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)
}
61 changes: 61 additions & 0 deletions internal/dev/dev_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}