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
37 changes: 35 additions & 2 deletions cmd/asobi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/widgrensit/asobi-cli/internal/config"
"github.com/widgrensit/asobi-cli/internal/deploy"
"github.com/widgrensit/asobi-cli/internal/scaffold"
"github.com/widgrensit/asobi-cli/internal/template"
)

const defaultSaasURL = "https://console.asobi.dev"
Expand Down Expand Up @@ -85,6 +86,8 @@ Usage:
asobi logout Clear stored credentials
asobi whoami Show current credential info
asobi init [dir] Scaffold a starter Lua game
asobi init [dir] --template <engine>
Scaffold a runnable demo (defold|godot|unity)
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 @@ -661,16 +664,46 @@ func cmdUse() {
// --- Init ---

func cmdInit() {
args := os.Args[2:]
engine, args := extractFlag(args, "--template")

dir := "."
if len(os.Args) >= 3 && !strings.HasPrefix(os.Args[2], "--") {
dir = os.Args[2]
for _, a := range args {
if !strings.HasPrefix(a, "--") {
dir = a
break
}
}
if dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
fatal("create %s: %v", dir, err)
}
}

if engine != "" {
t, ok := template.Get(engine)
if !ok {
fatal("unknown template %q; available: %s", engine, strings.Join(template.Engines(), ", "))
}
fmt.Printf("Fetching the %s template into %s ...\n", t.Name, dir)
created, err := template.Fetch(engine, dir)
if err != nil {
fatal("init: %v", err)
}
fmt.Printf("Scaffolded the %s game template in %s\n", t.Name, dir)
for _, f := range created {
fmt.Printf(" created %s\n", f)
}
fmt.Println("\nNext steps:")
n := 1
step := func(s string) { fmt.Printf(" %d. %s\n", n, s); n++ }
if dir != "." {
step("cd " + dir)
}
step("Open README.md - it walks through starting the backend and opening the project in " + t.Name + ".")
return
}

created, err := scaffold.Init(dir)
if err != nil {
fatal("init: %v", err)
Expand Down
213 changes: 213 additions & 0 deletions internal/template/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Package template scaffolds a runnable client+backend game project by fetching
// one of the asobi demo repositories at a pinned commit. The demos are the single
// source of truth (no parallel templates to drift); each carries its own README
// documenting how to run the backend and open the project in its engine.
package template

import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)

// Template is a demo repository pinned to an immutable release tag. Bump Ref to
// adopt a newer demo; the demos auto-cut tags via their release workflow on merge
// to main, so a stable tag always exists to pin to.
type Template struct {
Repo string
Ref string
Name string
}

var registry = map[string]Template{
"defold": {Repo: "asobi-defold-demo", Ref: "v0.1.0", Name: "Defold"},
"godot": {Repo: "asobi-godot-demo", Ref: "v0.1.0", Name: "Godot"},
"unity": {Repo: "asobi-unity-demo", Ref: "v0.1.0", Name: "Unity"},
}

const (
maxFiles = 20000
maxBytes = 500 << 20 // 500 MiB, generous for a Unity demo
)

// Engines returns the known template engine keys, sorted.
func Engines() []string {
keys := make([]string, 0, len(registry))
for k := range registry {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

// Get returns the template for an engine and whether it is known.
func Get(engine string) (Template, bool) {
t, ok := registry[engine]
return t, ok
}

// Fetch downloads the template for engine and extracts it into dir. dir must be
// empty. It returns the sorted top-level entries created.
func Fetch(engine, dir string) ([]string, error) {
t, ok := registry[engine]
if !ok {
return nil, fmt.Errorf("unknown template %q; available: %s", engine, strings.Join(Engines(), ", "))
}
if err := requireEmptyDir(dir); err != nil {
return nil, err
}

url := fmt.Sprintf("https://github.com/widgrensit/%s/archive/%s.tar.gz", t.Repo, t.Ref)
client := &http.Client{Timeout: 2 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("download %s template: %w", t.Name, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download %s template: %s returned %s", t.Name, url, resp.Status)
}

return extractTarGz(resp.Body, dir)
}

// requireEmptyDir returns an error unless dir does not exist or is an empty
// directory. Extracting a template over existing files would clobber them.
func requireEmptyDir(dir string) error {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read %s: %w", dir, err)
}
if len(entries) > 0 {
return fmt.Errorf("%s is not empty; a template needs a fresh directory (try: asobi init mygame --template <engine>)", dir)
}
return nil
}

// extractTarGz extracts a GitHub archive tarball into dir, stripping the single
// top-level "<repo>-<ref>/" directory. It writes only regular files and
// directories: symlinks, hardlinks, devices and paths escaping dir are refused,
// so a hostile archive cannot write outside the target tree.
func extractTarGz(r io.Reader, dir string) ([]string, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gunzip template: %w", err)
}
defer gz.Close()

tr := tar.NewReader(gz)
top := map[string]struct{}{}
var files, total int64
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read template archive: %w", err)
}

rel := stripLeadingComponent(hdr.Name)
if rel == "" {
continue
}
dest, err := safeJoin(dir, rel)
if err != nil {
return nil, err
}
if i := strings.IndexByte(rel, '/'); i >= 0 {
top[rel[:i]] = struct{}{}
} else {
top[rel] = struct{}{}
}

switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(dest, 0o755); err != nil {
return nil, fmt.Errorf("create %s: %w", rel, err)
}
case tar.TypeReg:
files++
if files > maxFiles {
return nil, fmt.Errorf("template archive has too many files (>%d)", maxFiles)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return nil, fmt.Errorf("create dir for %s: %w", rel, err)
}
n, err := writeFile(dest, tr, maxBytes-total)
if err != nil {
return nil, fmt.Errorf("write %s: %w", rel, err)
}
total += n
default:
// Skip symlinks, hardlinks, devices, fifos: never materialise a link
// from a downloaded archive.
continue
}
}

names := make([]string, 0, len(top))
for n := range top {
names = append(names, n)
}
sort.Strings(names)
return names, nil
}

func writeFile(dest string, r io.Reader, budget int64) (int64, error) {
if budget <= 0 {
return 0, fmt.Errorf("template archive exceeds size limit (%d bytes)", int64(maxBytes))
}
f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return 0, err
}
defer f.Close()
n, err := io.Copy(f, io.LimitReader(r, budget))
if err != nil {
return n, err
}
return n, nil
}

// stripLeadingComponent drops the first path element (the archive's
// "<repo>-<ref>/" wrapper) and returns a clean slash path.
func stripLeadingComponent(name string) string {
name = strings.TrimPrefix(filepath.ToSlash(name), "./")
i := strings.IndexByte(name, '/')
if i < 0 {
return ""
}
return strings.Trim(name[i+1:], "/")
}

// safeJoin joins rel onto dir, refusing absolute paths or any that escape dir.
func safeJoin(dir, rel string) (string, error) {
clean := filepath.Clean(filepath.FromSlash(rel))
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("unsafe path in template archive: %q", rel)
}
dest := filepath.Join(dir, clean)
absDir, err := filepath.Abs(dir)
if err != nil {
return "", err
}
absDest, err := filepath.Abs(dest)
if err != nil {
return "", err
}
if absDest != absDir && !strings.HasPrefix(absDest, absDir+string(filepath.Separator)) {
return "", fmt.Errorf("unsafe path in template archive: %q", rel)
}
return dest, nil
}
Loading