mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-05 10:19:15 +00:00
* feat(packages): backend pip + npm update flow (#900) Extend Phase 1 update infrastructure to pip + npm sources. Register checkers/executors behind edition gate (Lite edition stays github-only). Per-source sentinel errors + stderr classifier; strict package-name validators reject @version suffix. Shared PackageLocker serializes install + update paths. HTTP response surfaces per-source availability from LookPath detection. Closes part of #900 (Phase 2a). * feat(packages): frontend multi-source updates UI (#900) Unified flat updates list with source pill (github/pip/npm) + filter dropdown. Summary bar shows per-source counts, hiding sources whose backend availability=false. 30 i18n keys with full en/vi/zh parity. Mobile-safe table (overflow-x-auto + min-w-[600px]). Part of #900 (Phase 2a). * test(packages): pip + npm integration e2e (#900) Optional real-runtime integration test behind `pipnpm_e2e` build tag. Skipped by default CI; exercises full check + apply cycle with real pip3/npm in Alpine container. Part of #900 (Phase 2a). * docs(packages): document pip + npm update flow (#900) Adds packages-pip-npm.md covering command matrix, exit codes, stderr error classes, pre-release handling, availability detection, runbook for EACCES/ERESOLVE/externally-managed, min versions, fixture regen. Cross-link from packages-github.md. Changelogs updated. Part of #900 (Phase 2a). * fix(packages): set exec bit on testdata npm/pip scripts
301 lines
9.7 KiB
Go
301 lines
9.7 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
// preReleaseRE matches common pre-release suffixes in tag names.
|
|
// Case-insensitive. Precedes golang.org/x/mod/semver.Prerelease which only
|
|
// recognises strict semver (v prefix + dash-separated ids).
|
|
var preReleaseRE = regexp.MustCompile(`(?i)-(alpha|beta|rc|pre|preview|dev|nightly|snapshot)`)
|
|
|
|
// isPreReleaseTag returns true when the tag likely denotes a pre-release.
|
|
// Double-gate: the caller combines this with GitHubRelease.Prerelease so a
|
|
// release later re-flagged at the API level is still treated correctly.
|
|
func isPreReleaseTag(tag string) bool {
|
|
return preReleaseRE.MatchString(tag)
|
|
}
|
|
|
|
// GitHubUpdateChecker implements UpdateChecker for "github" source.
|
|
// Holds a weak reference to the installer for manifest access and to the
|
|
// shared GitHubClient for HTTP + ETag-aware fetches.
|
|
type GitHubUpdateChecker struct {
|
|
Installer *GitHubInstaller
|
|
}
|
|
|
|
// NewGitHubUpdateChecker wires the checker to an existing installer.
|
|
func NewGitHubUpdateChecker(installer *GitHubInstaller) *GitHubUpdateChecker {
|
|
return &GitHubUpdateChecker{Installer: installer}
|
|
}
|
|
|
|
// Source returns "github".
|
|
func (c *GitHubUpdateChecker) Source() string { return "github" }
|
|
|
|
// Check iterates the GitHub manifest, polls each repo (ETag-aware) and returns
|
|
// a list of UpdateInfo for entries with a newer release available.
|
|
//
|
|
// Per red-team fixes:
|
|
// - C2: returns its own ETag map; registry merges under lock.
|
|
// - H3: non-semver fallback uses strings.Compare > 0 to prevent silent
|
|
// downgrade.
|
|
// - H4: distinct ETag keys for /releases/latest vs /releases?per_page (list).
|
|
// - M1: secondary rate-limit (403 Retry-After) aborts the remaining repos
|
|
// with a warning log; per-repo ctx-cancel aborts gracefully.
|
|
func (c *GitHubUpdateChecker) Check(ctx context.Context, knownETags map[string]string) UpdateCheckResult {
|
|
out := UpdateCheckResult{
|
|
Source: c.Source(),
|
|
ETags: make(map[string]string),
|
|
}
|
|
if c.Installer == nil || c.Installer.Client == nil {
|
|
out.Err = errors.New("github update checker: installer not configured")
|
|
return out
|
|
}
|
|
m, err := c.Installer.loadManifest()
|
|
if err != nil {
|
|
out.Err = fmt.Errorf("load manifest: %w", err)
|
|
return out
|
|
}
|
|
|
|
for idx := range m.Packages {
|
|
if ctx.Err() != nil {
|
|
out.Err = ctx.Err()
|
|
return out
|
|
}
|
|
entry := m.Packages[idx]
|
|
info, etags, err := c.checkEntry(ctx, entry, knownETags)
|
|
// Propagate etags even on per-entry errors (304 may still populate).
|
|
for k, v := range etags {
|
|
out.ETags[k] = v
|
|
}
|
|
if err != nil {
|
|
// Secondary rate limit aborts the whole sweep; other errors are
|
|
// per-repo and isolated.
|
|
if errors.Is(err, ErrGitHubSecondaryRateLimit) {
|
|
slog.Warn("security.github.secondary_ratelimit",
|
|
"repo", entry.Repo, "error", err)
|
|
out.Err = err
|
|
// Source is reachable (we got a rate-limit response) — mark available.
|
|
out.Available = true
|
|
return out
|
|
}
|
|
slog.Warn("skills.update.github: check entry failed",
|
|
"name", entry.Name, "repo", entry.Repo, "error", err)
|
|
continue
|
|
}
|
|
if info != nil {
|
|
out.Updates = append(out.Updates, *info)
|
|
}
|
|
}
|
|
// Manifest was loaded and at least one check cycle completed — source is available.
|
|
out.Available = true
|
|
return out
|
|
}
|
|
|
|
// checkEntry performs the conditional fetch + candidate selection for a
|
|
// single manifest entry. Returns (update, newETags, err).
|
|
// update==nil means "no update available" (may still populate etags from 304).
|
|
func (c *GitHubUpdateChecker) checkEntry(ctx context.Context, entry GitHubPackageEntry, known map[string]string) (*UpdateInfo, map[string]string, error) {
|
|
etags := make(map[string]string)
|
|
owner, repo, ok := splitOwnerRepo(entry.Repo)
|
|
if !ok {
|
|
return nil, etags, fmt.Errorf("invalid manifest entry repo: %q", entry.Repo)
|
|
}
|
|
|
|
latestKey := entry.Repo // "owner/repo"
|
|
listKey := entry.Repo + ":list" // distinct keyspace (H4)
|
|
|
|
// Always query /releases/latest (stable).
|
|
latest, newETag, notMod, err := c.Installer.Client.CondGetRelease(ctx, owner, repo, "", known[latestKey])
|
|
if err != nil && !errors.Is(err, ErrGitHubNotFound) {
|
|
return nil, etags, err
|
|
}
|
|
if newETag != "" {
|
|
etags[latestKey] = newETag
|
|
}
|
|
// 304 means cache still valid; still may have an older UpdateInfo carried
|
|
// forward — Phase 1 does not persist per-entry UpdateInfo across checks, so
|
|
// we skip silently (not a "new" update).
|
|
if notMod {
|
|
latest = nil
|
|
}
|
|
|
|
// If current is pre-release, also query the recent-releases list to find
|
|
// the newest candidate that may itself be pre-release.
|
|
var candidates []GitHubRelease
|
|
if latest != nil && !latest.Draft {
|
|
candidates = append(candidates, *latest)
|
|
}
|
|
currentIsPre := isPreReleaseTag(entry.Tag)
|
|
if currentIsPre {
|
|
list, listETag, listNotMod, lerr := c.Installer.Client.CondListReleases(ctx, owner, repo, 5, known[listKey])
|
|
if lerr != nil && !errors.Is(lerr, ErrGitHubNotFound) {
|
|
// Treat list failure as non-fatal — /latest result may suffice.
|
|
slog.Warn("skills.update.github: list releases failed",
|
|
"repo", entry.Repo, "error", lerr)
|
|
} else {
|
|
if listETag != "" {
|
|
etags[listKey] = listETag
|
|
}
|
|
if !listNotMod {
|
|
for _, rel := range list {
|
|
if rel.Draft {
|
|
continue
|
|
}
|
|
candidates = append(candidates, rel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return nil, etags, nil
|
|
}
|
|
|
|
// Pick the newest candidate with a DIFFERENT tag than current.
|
|
best := pickNewestRelease(entry.Tag, candidates)
|
|
if best == nil || best.TagName == entry.Tag {
|
|
return nil, etags, nil
|
|
}
|
|
|
|
// Resolve the matching asset for current runtime OS+arch so the executor
|
|
// can apply without a second fetch. If asset pick fails, skip but log —
|
|
// don't surface as "update available" when we can't apply it.
|
|
asset, aerr := SelectAsset(best.Assets, "linux", runtime.GOARCH)
|
|
if aerr != nil {
|
|
slog.Info("skills.update.github: update found but no compatible asset",
|
|
"repo", entry.Repo, "latest", best.TagName, "error", aerr)
|
|
return nil, etags, nil
|
|
}
|
|
|
|
// Opportunistically fetch the checksum map so the executor can verify
|
|
// without refetching. If absent, leave sha256 empty — executor falls back
|
|
// to its own publisher-checksum lookup (or warns).
|
|
assetSHA := findAssetSHA256(ctx, c.Installer.Client, best, asset.Name)
|
|
|
|
info := UpdateInfo{
|
|
Source: "github",
|
|
Name: entry.Name,
|
|
CurrentVersion: entry.Tag,
|
|
LatestVersion: best.TagName,
|
|
CheckedAt: time.Now().UTC(),
|
|
Meta: map[string]any{
|
|
"repo": entry.Repo,
|
|
"assetName": asset.Name,
|
|
"assetURL": asset.DownloadURL,
|
|
"assetSizeBytes": asset.SizeBytes,
|
|
"assetSHA256": assetSHA, // may be empty
|
|
"prerelease": best.Prerelease,
|
|
},
|
|
}
|
|
return &info, etags, nil
|
|
}
|
|
|
|
// findAssetSHA256 returns the publisher-provided SHA256 for the asset, or
|
|
// empty if no checksum file is present. Errors are logged and swallowed —
|
|
// the executor still verifies via its own download hash.
|
|
func findAssetSHA256(ctx context.Context, client *GitHubClient, rel *GitHubRelease, assetName string) string {
|
|
ca := FindChecksumAsset(rel, assetName)
|
|
if ca == nil {
|
|
return ""
|
|
}
|
|
path, _, err := client.DownloadAsset(ctx, ca.DownloadURL, 1<<20)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer os.Remove(path)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
sums, err := ParseChecksums(data)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return sums[assetName]
|
|
}
|
|
|
|
// pickNewestRelease returns the release with the highest version compared to
|
|
// `current`. Uses semver when possible (v-prefixed). Non-semver tags fall back
|
|
// to `strings.Compare(tag, current) > 0` to avoid silent downgrades (H3).
|
|
//
|
|
// Returns nil if no candidate is strictly greater than current.
|
|
func pickNewestRelease(current string, candidates []GitHubRelease) *GitHubRelease {
|
|
var best *GitHubRelease
|
|
currentSemver := ensureV(current)
|
|
currentIsValid := semver.IsValid(currentSemver)
|
|
|
|
for i := range candidates {
|
|
cand := &candidates[i]
|
|
if cand.TagName == current {
|
|
continue
|
|
}
|
|
if best == nil {
|
|
if isCandidateNewer(current, currentSemver, currentIsValid, cand.TagName) {
|
|
best = cand
|
|
}
|
|
continue
|
|
}
|
|
// Compare current best vs new candidate.
|
|
if isCandidateNewer(best.TagName, ensureV(best.TagName), semver.IsValid(ensureV(best.TagName)), cand.TagName) {
|
|
best = cand
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// isCandidateNewer returns true when candidate is strictly newer than current.
|
|
// Both-semver: semver.Compare.
|
|
// Both-non-semver: strings.Compare > 0 (lex).
|
|
// Mixed: valid-semver wins only if it orders > current interpreted as non-semver.
|
|
// On ambiguity, return false to prevent downgrades.
|
|
func isCandidateNewer(currentRaw, currentSemver string, currentIsValid bool, candidateRaw string) bool {
|
|
candSemver := ensureV(candidateRaw)
|
|
candValid := semver.IsValid(candSemver)
|
|
switch {
|
|
case currentIsValid && candValid:
|
|
return semver.Compare(candSemver, currentSemver) > 0
|
|
case !currentIsValid && !candValid:
|
|
return strings.Compare(candidateRaw, currentRaw) > 0
|
|
default:
|
|
// Mixed forms: flag but don't downgrade.
|
|
slog.Debug("skills.update.github: mixed-form tag comparison skipped",
|
|
"current", currentRaw, "candidate", candidateRaw)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ensureV returns tag with a "v" prefix if missing so semver.IsValid accepts
|
|
// forms like "1.2.3". Leaves non-numeric tags alone.
|
|
func ensureV(tag string) string {
|
|
if tag == "" {
|
|
return tag
|
|
}
|
|
if tag[0] == 'v' || tag[0] == 'V' {
|
|
return tag
|
|
}
|
|
// Quick numeric check: if first rune is a digit, add v.
|
|
if tag[0] >= '0' && tag[0] <= '9' {
|
|
return "v" + tag
|
|
}
|
|
return tag
|
|
}
|
|
|
|
// splitOwnerRepo splits "owner/repo" safely.
|
|
func splitOwnerRepo(s string) (string, string, bool) {
|
|
i := strings.IndexByte(s, '/')
|
|
if i <= 0 || i == len(s)-1 {
|
|
return "", "", false
|
|
}
|
|
return s[:i], s[i+1:], true
|
|
}
|