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.
318 lines
7.5 KiB
Go
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
|
|
}
|