diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 9352605..9fba9a3 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -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" @@ -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 + Scaffold a runnable demo (defold|godot|unity) asobi games List your tenant's games asobi use Set the active game asobi create [--size xs|s|m|l] [--game ] Create an environment @@ -661,9 +664,15 @@ 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 { @@ -671,6 +680,30 @@ func cmdInit() { } } + 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) diff --git a/internal/template/template.go b/internal/template/template.go new file mode 100644 index 0000000..aaee780 --- /dev/null +++ b/internal/template/template.go @@ -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 )", dir) + } + return nil +} + +// extractTarGz extracts a GitHub archive tarball into dir, stripping the single +// top-level "-/" 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 +// "-/" 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 +} diff --git a/internal/template/template_test.go b/internal/template/template_test.go new file mode 100644 index 0000000..70e7225 --- /dev/null +++ b/internal/template/template_test.go @@ -0,0 +1,172 @@ +package template + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +type entry struct { + name string + typ byte + body string + link string +} + +func makeTarGz(t *testing.T, entries []entry) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, e := range entries { + hdr := &tar.Header{Name: e.name, Typeflag: e.typ, Mode: 0o644} + switch e.typ { + case tar.TypeDir: + hdr.Mode = 0o755 + case tar.TypeReg: + hdr.Size = int64(len(e.body)) + case tar.TypeSymlink: + hdr.Linkname = e.link + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write header %s: %v", e.name, err) + } + if e.typ == tar.TypeReg { + if _, err := tw.Write([]byte(e.body)); err != nil { + t.Fatalf("write body %s: %v", e.name, err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestEnginesKnown(t *testing.T) { + got := Engines() + want := []string{"defold", "godot", "unity"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Engines() = %v, want %v", got, want) + } + for _, e := range want { + if _, ok := Get(e); !ok { + t.Errorf("Get(%q) not found", e) + } + } + if _, ok := Get("bogus"); ok { + t.Error("Get(bogus) should not be found") + } +} + +func TestExtractStripsPrefixAndWrites(t *testing.T) { + data := makeTarGz(t, []entry{ + {name: "asobi-defold-demo-abc/", typ: tar.TypeDir}, + {name: "asobi-defold-demo-abc/README.md", typ: tar.TypeReg, body: "hi"}, + {name: "asobi-defold-demo-abc/lua/", typ: tar.TypeDir}, + {name: "asobi-defold-demo-abc/lua/match.lua", typ: tar.TypeReg, body: "return 1"}, + }) + dir := t.TempDir() + top, err := extractTarGz(bytes.NewReader(data), dir) + if err != nil { + t.Fatalf("extract: %v", err) + } + if want := []string{"README.md", "lua"}; !reflect.DeepEqual(top, want) { + t.Errorf("top entries = %v, want %v", top, want) + } + if b, err := os.ReadFile(filepath.Join(dir, "README.md")); err != nil || string(b) != "hi" { + t.Errorf("README.md = %q, %v", b, err) + } + if _, err := os.Stat(filepath.Join(dir, "lua", "match.lua")); err != nil { + t.Errorf("lua/match.lua missing: %v", err) + } +} + +func TestExtractRejectsTraversal(t *testing.T) { + data := makeTarGz(t, []entry{ + {name: "repo-abc/", typ: tar.TypeDir}, + {name: "repo-abc/../evil.txt", typ: tar.TypeReg, body: "pwned"}, + }) + parent := t.TempDir() + dir := filepath.Join(parent, "proj") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := extractTarGz(bytes.NewReader(data), dir); err == nil { + t.Fatal("expected traversal to be rejected") + } else if !strings.Contains(err.Error(), "unsafe path") { + t.Errorf("error = %v, want unsafe path", err) + } + if _, err := os.Stat(filepath.Join(parent, "evil.txt")); !os.IsNotExist(err) { + t.Error("traversal wrote a file outside the target dir") + } +} + +func TestExtractSkipsSymlinks(t *testing.T) { + data := makeTarGz(t, []entry{ + {name: "repo-abc/", typ: tar.TypeDir}, + {name: "repo-abc/link", typ: tar.TypeSymlink, link: "/etc/passwd"}, + {name: "repo-abc/real.txt", typ: tar.TypeReg, body: "ok"}, + }) + dir := t.TempDir() + if _, err := extractTarGz(bytes.NewReader(data), dir); err != nil { + t.Fatalf("extract: %v", err) + } + if _, err := os.Lstat(filepath.Join(dir, "link")); !os.IsNotExist(err) { + t.Error("symlink from archive should not be materialised") + } + if _, err := os.Stat(filepath.Join(dir, "real.txt")); err != nil { + t.Errorf("regular file after skipped symlink missing: %v", err) + } +} + +func TestRequireEmptyDir(t *testing.T) { + if err := requireEmptyDir(filepath.Join(t.TempDir(), "does-not-exist")); err != nil { + t.Errorf("missing dir should be allowed: %v", err) + } + empty := t.TempDir() + if err := requireEmptyDir(empty); err != nil { + t.Errorf("empty dir should be allowed: %v", err) + } + nonEmpty := t.TempDir() + if err := os.WriteFile(filepath.Join(nonEmpty, "x"), []byte("y"), 0o644); err != nil { + t.Fatal(err) + } + if err := requireEmptyDir(nonEmpty); err == nil { + t.Error("non-empty dir should be refused") + } +} + +func TestFetchUnknownEngine(t *testing.T) { + if _, err := Fetch("nope", t.TempDir()); err == nil || !strings.Contains(err.Error(), "unknown template") { + t.Fatalf("Fetch(nope) err = %v, want unknown template", err) + } +} + +// TestFetchE2E actually downloads a pinned template. It is network-bound and off +// by default; run with ASOBI_TEMPLATE_E2E=1. It is the anti-drift guard: it +// fails if a pinned demo no longer extracts into a plausible project. +func TestFetchE2E(t *testing.T) { + if os.Getenv("ASOBI_TEMPLATE_E2E") == "" { + t.Skip("set ASOBI_TEMPLATE_E2E=1 to run the network fetch test") + } + dir := t.TempDir() + top, err := Fetch("defold", dir) + if err != nil { + t.Fatalf("Fetch(defold): %v", err) + } + if len(top) == 0 { + t.Fatal("Fetch(defold) created nothing") + } + if _, err := os.Stat(filepath.Join(dir, "game.project")); err != nil { + t.Errorf("defold template missing game.project: %v", err) + } +}