feat(pi): update to the latest stable release by default
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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}
|
||||
}
|
||||
return &npmRegistryClient{client: client}
|
||||
}
|
||||
|
||||
// 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 || 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
|
||||
}
|
||||
Reference in New Issue
Block a user