Files
games/cmd/downloader/main.go
Ryan Hamamura f4d5a52cf9
All checks were successful
CI / Deploy / test (pull_request) Successful in 20s
CI / Deploy / lint (pull_request) Successful in 34s
CI / Deploy / deploy (pull_request) Has been skipped
Switch to datastar-pro and stop tracking downloaded libs
Datastar-pro is fetched from a private Gitea repo (ryan/vendor-libs)
using VENDOR_TOKEN for CI/Docker builds, with a local fallback from
../optional/ for development. DaisyUI is pinned to v5.5.19 instead of
tracking latest. Downloaded files are now gitignored and fetched at
build time via 'task download', which is a dependency of both build
and live tasks.
2026-03-11 11:33:38 -10:00

318 lines
7.5 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"github.com/ryanhamamura/games/assets"
)
func main() {
if err := run(); err != nil {
slog.Error("failure", "error", err)
os.Exit(1)
}
}
// Pinned dependency versions — update these to upgrade.
const (
datastarVersion = "v1.0.0-RC.8" // Pro build — fetched from private Gitea repo
daisyuiVersion = "v5.5.19"
)
// dependencies tracks pinned versions alongside their GitHub coordinates
// so the version check can look up the latest release for each.
var dependencies = []dependency{
{name: "datastar", owner: "starfederation", repo: "datastar", pinnedVersion: datastarVersion},
{name: "daisyui", owner: "saadeghi", repo: "daisyui", pinnedVersion: daisyuiVersion},
}
type dependency struct {
name string
owner string
repo string
pinnedVersion string
}
// datastar-pro sources, in order of preference.
const (
giteaRawURL = "https://gitea.adriatica.io/ryan/vendor-libs/raw/branch/main/datastar/datastar.js"
localFallbackPath = "../optional/web/resources/static/datastar/datastar.js"
)
func run() error {
jsDir := assets.DirectoryPath + "/js/datastar"
cssDir := assets.DirectoryPath + "/css/daisyui"
daisyuiBase := "https://github.com/saadeghi/daisyui/releases/download/" + daisyuiVersion + "/"
downloads := map[string]string{
daisyuiBase + "daisyui.js": cssDir + "/daisyui.js",
daisyuiBase + "daisyui-theme.js": cssDir + "/daisyui-theme.js",
}
directories := []string{jsDir, cssDir}
if err := removeDirectories(directories); err != nil {
return err
}
if err := createDirectories(directories); err != nil {
return err
}
if err := acquireDatastar(jsDir + "/datastar.js"); err != nil {
return err
}
if err := download(downloads); err != nil {
return err
}
checkForUpdates()
return nil
}
// acquireDatastar fetches datastar-pro from the private Gitea repo when
// GITEA_TOKEN is set, otherwise copies from the local optional project.
func acquireDatastar(dest string) error {
if token := os.Getenv("VENDOR_TOKEN"); token != "" {
slog.Info("downloading datastar-pro from private repo...")
return downloadWithAuth(giteaRawURL, dest, token)
}
slog.Info("copying datastar-pro from local fallback...", "src", localFallbackPath)
return copyFile(localFallbackPath, dest)
}
func copyFile(src, dest string) error {
in, err := os.Open(src) //nolint:gosec // paths are hardcoded constants
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer in.Close() //nolint:errcheck
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
if err != nil {
return fmt.Errorf("create %s: %w", dest, err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close() //nolint:errcheck
return fmt.Errorf("copy to %s: %w", dest, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close %s: %w", dest, err)
}
return nil
}
func downloadWithAuth(rawURL, dest, token string) error {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return fmt.Errorf("create request for %s: %w", rawURL, err)
}
req.Header.Set("Authorization", "token "+token)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL is built from compile-time constants
if err != nil {
return fmt.Errorf("GET %s: %w", rawURL, err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: status %s", rawURL, resp.Status)
}
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
if err != nil {
return fmt.Errorf("create %s: %w", dest, err)
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close() //nolint:errcheck
return fmt.Errorf("write %s: %w", dest, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close %s: %w", dest, err)
}
return nil
}
// checkForUpdates queries the GitHub releases API for each dependency
// and logs a notice if a newer version is available. Failures are
// logged but never cause the download to fail.
func checkForUpdates() {
var wg sync.WaitGroup
for _, dep := range dependencies {
wg.Go(func() {
latest, err := latestGitHubRelease(dep.owner, dep.repo)
if err != nil {
slog.Warn("could not check for updates", "dependency", dep.name, "error", err)
return
}
if latest != dep.pinnedVersion {
slog.Warn("newer version available",
"dependency", dep.name,
"pinned", dep.pinnedVersion,
"latest", latest,
)
}
})
}
wg.Wait()
}
// githubRelease is the minimal subset of the GitHub releases API response we need.
type githubRelease struct {
TagName string `json:"tag_name"`
}
func latestGitHubRelease(owner, repo string) (string, error) {
u := &url.URL{
Scheme: "https",
Host: "api.github.com",
Path: fmt.Sprintf("/repos/%s/%s/releases/latest", owner, repo),
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req) //nolint:gosec
if err != nil {
return "", fmt.Errorf("fetching release: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %s", resp.Status)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("decoding response: %w", err)
}
return release.TagName, nil
}
func removeDirectories(dirs []string) error {
var wg sync.WaitGroup
errCh := make(chan error, len(dirs))
for _, path := range dirs {
wg.Go(func() {
if err := os.RemoveAll(path); err != nil {
errCh <- fmt.Errorf("remove directory %s: %w", path, err)
}
})
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func createDirectories(dirs []string) error {
var wg sync.WaitGroup
errCh := make(chan error, len(dirs))
for _, path := range dirs {
wg.Go(func() {
if err := os.MkdirAll(path, 0755); err != nil {
errCh <- fmt.Errorf("create directory %s: %w", path, err)
}
})
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func download(files map[string]string) error {
var wg sync.WaitGroup
errCh := make(chan error, len(files))
for url, dest := range files {
wg.Go(func() {
base := filepath.Base(dest)
slog.Info("downloading...", "file", base, "url", url)
if err := downloadFile(url, dest); err != nil {
errCh <- fmt.Errorf("download %s: %w", base, err)
} else {
slog.Info("finished", "file", base)
}
})
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func downloadFile(rawURL, dest string) error {
resp, err := http.Get(rawURL) //nolint:gosec,noctx // static URLs, simple tool
if err != nil {
return fmt.Errorf("GET %s: %w", rawURL, err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: status %s", rawURL, resp.Status)
}
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
if err != nil {
return fmt.Errorf("create %s: %w", dest, err)
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close() //nolint:errcheck
return fmt.Errorf("write %s: %w", dest, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close %s: %w", dest, err)
}
return nil
}