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 }