224 lines
7.3 KiB
Go
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
|
|
}
|