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 }