Files
ThothII/tools/tht/internal/pi/latest.go
T

224 lines
7.3 KiB
Go

package pi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
piRegistryURL = "https://registry.npmjs.org"
registryBodyLimit = 5 << 20
registryTimeout = 10 * time.Second
)
var packageNamePattern = regexp.MustCompile(`^(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$`)
// RegistryClient resolves a published, stable Pi package version without involving a host npm
// executable. It is deliberately narrow so the update transaction can resolve a version before
// it acquires a lifecycle lock or contacts Docker.
type RegistryClient interface {
LatestStable(ctx context.Context, packageName string) (string, error)
}
type npmRegistryClient struct {
client *http.Client
}
// NewNPMRegistryClient returns the bounded HTTPS client for the authoritative npm registry.
func NewNPMRegistryClient() RegistryClient {
return newNPMRegistryClient(nil)
}
func newNPMRegistryClient(client *http.Client) *npmRegistryClient {
if client == nil {
client = &http.Client{Timeout: registryTimeout}
}
bounded := *client
bounded.CheckRedirect = rejectRegistryRedirect
return &npmRegistryClient{client: &bounded}
}
func rejectRegistryRedirect(request *http.Request, _ []*http.Request) error {
return fmt.Errorf("Pi registry redirect to %s is not allowed", request.URL.Redacted())
}
// LatestStable returns the greatest released semantic version. npm's dist-tag is not trusted as
// it can point at a prerelease; selecting from the published versions keeps the default stable.
func (c *npmRegistryClient) LatestStable(ctx context.Context, packageName string) (string, error) {
if !packageNamePattern.MatchString(packageName) {
return "", errors.New("Pi package name is invalid")
}
endpoint, err := registryPackageURL(packageName)
if err != nil {
return "", err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", fmt.Errorf("create Pi registry request: %w", err)
}
request.Header.Set("Accept", "application/json")
response, err := c.client.Do(request)
if err != nil {
return "", fmt.Errorf("query Pi registry: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
return "", errors.New("Pi package was not found in the registry")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("Pi registry returned HTTP %d", response.StatusCode)
}
var payload struct {
Versions map[string]json.RawMessage `json:"versions"`
}
body, err := io.ReadAll(io.LimitReader(response.Body, registryBodyLimit+1))
if err != nil {
return "", fmt.Errorf("read Pi registry response: %w", err)
}
if len(body) > registryBodyLimit {
return "", errors.New("Pi registry response is too large")
}
decoder := json.NewDecoder(strings.NewReader(string(body)))
if err := decoder.Decode(&payload); err != nil {
return "", errors.New("Pi registry response is invalid")
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return "", errors.New("Pi registry response is invalid")
}
var selected semanticVersion
found := false
for rawVersion, rawMetadata := range payload.Versions {
if !json.Valid(rawMetadata) {
return "", errors.New("Pi registry response is invalid")
}
version, err := parseSemanticVersion(rawVersion)
if err != nil {
return "", errors.New("Pi registry response contains an invalid semantic version key")
}
if version.prerelease != "" {
continue
}
if !found || selected.less(version) {
selected, found = version, true
}
}
if !found {
return "", errors.New("Pi registry contains no stable semantic version")
}
return selected.raw, nil
}
func registryPackageURL(packageName string) (string, error) {
base, err := url.Parse(piRegistryURL)
if err != nil || base.Scheme != "https" || base.Host != "registry.npmjs.org" {
return "", errors.New("Pi registry endpoint is not an authoritative HTTPS URL")
}
base.Path = "/" + packageName
base.RawPath = "/" + url.PathEscape(packageName)
return base.String(), nil
}
// ReadRuntimePackageName obtains the exact Pi package from the dependency manifest copied by
// docker/core.Dockerfile. Version discovery therefore follows the runtime the image actually
// builds, rather than a second, independently maintained package name.
func ReadRuntimePackageName(projectDirectory string) (string, error) {
contents, err := os.ReadFile(filepath.Join(projectDirectory, "docker", "pi-runtime", "package.json"))
if err != nil {
return "", fmt.Errorf("read Pi runtime package manifest: %w", err)
}
var manifest struct {
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(contents, &manifest); err != nil {
return "", errors.New("Pi runtime package manifest is invalid")
}
const piDependency = "@earendil-works/pi-coding-agent"
if !packageNamePattern.MatchString(piDependency) || strings.TrimSpace(manifest.Dependencies[piDependency]) == "" {
return "", errors.New("Pi runtime package manifest does not declare the Pi dependency")
}
return piDependency, nil
}
// ResolveRequestedVersion validates an explicit version without a lookup, or resolves the latest
// published stable version before the caller starts any lifecycle mutation.
func ResolveRequestedVersion(ctx context.Context, requested string, packageName string, registry RegistryClient) (string, error) {
if strings.TrimSpace(requested) != "" {
version, err := parseSemanticVersion(requested)
if err != nil {
return "", err
}
return version.raw, nil
}
if registry == nil {
return "", errors.New("Pi registry client is unavailable")
}
version, err := registry.LatestStable(ctx, packageName)
if err != nil {
return "", err
}
parsed, err := parseSemanticVersion(version)
if err != nil || parsed.prerelease != "" {
return "", errors.New("Pi registry did not return a stable semantic version")
}
return parsed.raw, nil
}
type semanticVersion struct {
raw string
major string
minor string
patch string
prerelease string
}
var semanticVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
func parseSemanticVersion(raw string) (semanticVersion, error) {
match := semanticVersionPattern.FindStringSubmatch(raw)
if len(match) == 0 {
return semanticVersion{}, errors.New("Pi version must be a strict semantic version")
}
if match[4] != "" {
for _, identifier := range strings.Split(match[4], ".") {
if allDigits(identifier) && len(identifier) > 1 && identifier[0] == '0' {
return semanticVersion{}, errors.New("Pi version must be a strict semantic version")
}
}
}
return semanticVersion{raw: raw, major: match[1], minor: match[2], patch: match[3], prerelease: match[4]}, nil
}
func allDigits(value string) bool {
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return value != ""
}
func (left semanticVersion) less(right semanticVersion) bool {
for _, pair := range [][2]string{{left.major, right.major}, {left.minor, right.minor}, {left.patch, right.patch}} {
if len(pair[0]) != len(pair[1]) {
return len(pair[0]) < len(pair[1])
}
if pair[0] != pair[1] {
return pair[0] < pair[1]
}
}
return false
}