diff --git a/modules/gitea/README.md b/modules/gitea/README.md new file mode 100644 index 00000000..6ef48150 --- /dev/null +++ b/modules/gitea/README.md @@ -0,0 +1,76 @@ +# gitea + +The forge, and the mesh's npm package registry: it claims the `git` and `npm-package-registry` seats. +Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`, +`tools/`, `provisioner/`). This README covers the Go bundle beside it. + +## The package registry's retention (novox/hq ADR 0251 §4, to-be 51) + +Every publish adds a version to the registry, and until this nothing removed one. The Go bundle +`cmd/npm-registry` says which versions each package holds, what keeps each one, and — asked with a why — +deletes the versions nothing keeps. + +| tool | | what | +|---|---|---| +| `npm_packages` | r | every package of the registry's owner, each version newest first with its publish time, its size and why it is kept | +| `npm_retention` | a | the versions retention would delete, and the bytes. **A dry run unless `dry_run` is false**; a real run needs `why` | + +**A version is kept when:** + +- a lockfile on the default branch of any repository on the forge names it — `package-lock.json` + (every lockfile version), `npm-shrinkwrap.json`, `yarn.lock` (classic and berry) and `pnpm-lock.yaml` + (by each resolved `name@version` or `name/version`), whatever the version's age; +- it is the highest published version satisfying a range that a `package.json` there names in its + dependencies, development, peer or optional dependencies (an `npm:` alias counts for the package it + stands for; a path, a URL, git or a workspace is not a range on this registry). npm itself installs + the `latest` dist-tag when it satisfies the range; that version is kept by its dist-tag; +- a dist-tag names it — and a range that *is* a dist-tag's name keeps that tag's version; +- it is among the newest `keep` of its package, five by default (the artifact store's five builds, + ADR 0189 §3), by semantic-version order. **Pre-releases count** among the newest. + +A version that is not a semantic version is never ordered, so it is kept. A range that cannot be read, +or a package whose dist-tags cannot be read, keeps **every** version of its package, and the answer says +why under `keeps_all`. + +**Nothing is deleted on partial knowledge.** A repository whose files could not be listed, or a +manifest or lockfile that could not be fetched, is named under `repositories_unread` in every answer, +and a real run then deletes nothing at all. A file that was fetched but is not readable (broken JSON) is +named under `files_not_read`; what it would have named is not known, and the operator reads that list +before a real run. Under `node_modules` nothing is read: that is what a lockfile already says. An empty +repository, or one without its default branch, holds nothing to read and is not a failure. + +A real run deletes each version through the forge's own interface (`DELETE +/api/v1/packages/{owner}/npm/{name}/{version}`), says what it deleted and what the forge refused, and a +version already gone counts as deleted. + +### How it reaches the forge + +Over the forge's HTTP interface on the machine (`MESH_GITEA_URL`), as the admin account the vault +delivered (`MESH_GITEA_ADMIN_USER`, the password in `MESH_GITEA_ADMIN_PASSWORD_FILE` — the same file +the TypeScript bundle mints its token with), by basic authentication: this bundle mints no token and +keeps nothing. The password is read per call and never logged, answered or put in an error. The +registry's owner is `MESH_NPM_OWNER`, the owner in the seat's `npm-path`. + +Calls to the forge run eight at a time, and one tool call reads for at most 50 seconds, inside a +module's 60-second ask. + +### Why the manifest lists no `tools` + +The TypeScript bundle's tools are served without a `tools` list, and a claim without `serves` offers +the module's `tools` as the seat's verbs — so a list here would make these two tools verbs of both the +`git` and the `npm-package-registry` seats. The two names are kept in `ToolNames`, and a test holds them +to this README and to what the bundle serves. + +### Tests + +``` +go test ./... +``` + +Against a fake forge (`httptest`): what keeps a version (a lockfile whatever its age, the highest +version satisfying a range, a dist-tag, the newest `keep`); a dry run deleting nothing; a real run +without why refused; a real run deleting only what nothing keeps; an unread repository stopping a real +run before anything is deleted; an unreadable range keeping its whole package; a wrong password failing +without saying the password. And the range matcher against npm's rules — exact, `^`, `~`, `x` and `*`, +comparisons with partial versions, hyphen ranges, `||`, and the pre-release rule — and each lockfile +format. diff --git a/modules/gitea/cmd/npm-registry/forge.go b/modules/gitea/cmd/npm-registry/forge.go new file mode 100644 index 00000000..5ce649ec --- /dev/null +++ b/modules/gitea/cmd/npm-registry/forge.go @@ -0,0 +1,258 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin +// account, which the vault delivered into a file this module owns (novox/hq ADR 0086). Basic +// authentication, so this bundle mints nothing and keeps nothing: the TypeScript bundle's token is its +// own. The password is read when a call is made and never logged, answered or put in an error. + +// Forge is the forge's HTTP interface. +type Forge struct { + URL string // e.g. http://127.0.0.1:3000 + User string + PasswordFile string + Owner string // the package registry's owner, as the seat serves it + HTTP *http.Client +} + +// ForgeFromEnv is the forge as the manifest hands it to this bundle. +func ForgeFromEnv() (*Forge, error) { + f := &Forge{ + URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), + User: os.Getenv("MESH_GITEA_ADMIN_USER"), + PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), + Owner: os.Getenv("MESH_NPM_OWNER"), + } + var missing []string + for name, v := range map[string]string{"MESH_GITEA_URL": f.URL, "MESH_GITEA_ADMIN_USER": f.User, + "MESH_GITEA_ADMIN_PASSWORD_FILE": f.PasswordFile, "MESH_NPM_OWNER": f.Owner} { + if v == "" { + missing = append(missing, name) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("the forge cannot be reached: %s not set by the manifest", strings.Join(missing, ", ")) + } + return f, nil +} + +// errNotFound is the forge answering 404. +var errNotFound = errors.New("not found") + +// statusError is an answer the forge gave that is not a success, said without the request's credential. +type statusError struct { + method, path string + status int + said string +} + +func (e *statusError) Error() string { + return fmt.Sprintf("the forge answered %d to %s %s: %s", e.status, e.method, e.path, e.said) +} + +func (f *Forge) client() *http.Client { + if f.HTTP != nil { + return f.HTTP + } + return &http.Client{Timeout: 20 * time.Second} +} + +func (f *Forge) password() (string, error) { + raw, err := os.ReadFile(f.PasswordFile) + if err != nil { + // The path is the manifest's, not a secret; the content never appears. + return "", fmt.Errorf("the forge's admin password cannot be read from its file: %w", err) + } + return strings.TrimSpace(string(raw)), nil +} + +// do sends one request and answers the body; 404 is errNotFound. +func (f *Forge) do(ctx context.Context, method, path string) ([]byte, error) { + password, err := f.password() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, f.URL+path, nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(f.User, password) + req.Header.Set("Accept", "application/json") + res, err := f.client().Do(req) + if err != nil { + return nil, fmt.Errorf("the forge did not answer %s %s: %w", method, path, err) + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, 32<<20)) + if err != nil { + return nil, err + } + switch { + case res.StatusCode == http.StatusNotFound: + return nil, fmt.Errorf("%s %s: %w", method, path, errNotFound) + case res.StatusCode >= 300: + said := strings.TrimSpace(string(body)) + if len(said) > 300 { + said = said[:300] + "…" + } + return nil, &statusError{method, path, res.StatusCode, said} + } + return body, nil +} + +func (f *Forge) getJSON(ctx context.Context, path string, into any) error { + body, err := f.do(ctx, http.MethodGet, path) + if err != nil { + return err + } + if err := json.Unmarshal(body, into); err != nil { + return fmt.Errorf("the forge's answer to %s is not readable: %w", path, err) + } + return nil +} + +// PackageVersion is one version of one package, as the forge lists it. +type PackageVersion struct { + Name string `json:"name"` + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` +} + +const pageSize = 50 + +// Packages is every npm package version of the owner, all pages. +func (f *Forge) Packages(ctx context.Context) ([]PackageVersion, error) { + var all []PackageVersion + for page := 1; page <= 1000; page++ { + var batch []PackageVersion + path := fmt.Sprintf("/api/v1/packages/%s?type=npm&page=%d&limit=%d", url.PathEscape(f.Owner), page, pageSize) + if err := f.getJSON(ctx, path, &batch); err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < pageSize { + return all, nil + } + } + return nil, errors.New("the forge listed more than 1000 pages of packages; stopped rather than read for ever") +} + +func (f *Forge) versionPath(name, version string) string { + return fmt.Sprintf("/api/v1/packages/%s/npm/%s/%s", url.PathEscape(f.Owner), url.PathEscape(name), url.PathEscape(version)) +} + +// Size is the bytes of one version's files. +func (f *Forge) Size(ctx context.Context, name, version string) (int64, error) { + var files []struct { + Size int64 `json:"size"` + } + if err := f.getJSON(ctx, f.versionPath(name, version)+"/files", &files); err != nil { + return 0, err + } + var total int64 + for _, file := range files { + total += file.Size + } + return total, nil +} + +// DistTags is the dist-tags of one package, from the registry's own metadata. +func (f *Forge) DistTags(ctx context.Context, name string) (map[string]string, error) { + var meta struct { + DistTags map[string]string `json:"dist-tags"` + } + path := fmt.Sprintf("/api/packages/%s/npm/%s", url.PathEscape(f.Owner), url.PathEscape(name)) + if err := f.getJSON(ctx, path, &meta); err != nil { + return nil, err + } + return meta.DistTags, nil +} + +// Delete removes one version from the registry. +func (f *Forge) Delete(ctx context.Context, name, version string) error { + _, err := f.do(ctx, http.MethodDelete, f.versionPath(name, version)) + return err +} + +// Repository is one repository on the forge, as far as reading its manifests needs. +type Repository struct { + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` + Empty bool `json:"empty"` +} + +// Repositories is every repository the admin sees. +func (f *Forge) Repositories(ctx context.Context) ([]Repository, error) { + var all []Repository + for page := 1; page <= 1000; page++ { + var found struct { + Data []Repository `json:"data"` + } + if err := f.getJSON(ctx, fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, pageSize), &found); err != nil { + return nil, err + } + all = append(all, found.Data...) + if len(found.Data) < pageSize { + return all, nil + } + } + return nil, errors.New("the forge listed more than 1000 pages of repositories; stopped rather than read for ever") +} + +func repoPath(fullName string) string { + owner, name, _ := strings.Cut(fullName, "/") + return url.PathEscape(owner) + "/" + url.PathEscape(name) +} + +// Files is every file path on a branch, all pages of the forge's tree. +func (f *Forge) Files(ctx context.Context, repo Repository) ([]string, error) { + var paths []string + seen := 0 + for page := 1; page <= 1000; page++ { + var tree struct { + Tree []struct { + Path string `json:"path"` + Type string `json:"type"` + } `json:"tree"` + Truncated bool `json:"truncated"` + TotalCount int `json:"total_count"` + } + path := fmt.Sprintf("/api/v1/repos/%s/git/trees/%s?recursive=true&page=%d&per_page=1000", + repoPath(repo.FullName), url.PathEscape(repo.DefaultBranch), page) + if err := f.getJSON(ctx, path, &tree); err != nil { + return nil, err + } + for _, e := range tree.Tree { + if e.Type == "blob" { + paths = append(paths, e.Path) + } + } + seen += len(tree.Tree) + if len(tree.Tree) == 0 || !tree.Truncated && (tree.TotalCount == 0 || seen >= tree.TotalCount) { + return paths, nil + } + } + return nil, fmt.Errorf("%s has more than 1000 pages of files; not read whole", repo.FullName) +} + +// Raw is one file's content on the repository's default branch. +func (f *Forge) Raw(ctx context.Context, repo Repository, file string) ([]byte, error) { + escaped := strings.Split(file, "/") + for i := range escaped { + escaped[i] = url.PathEscape(escaped[i]) + } + return f.do(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/raw/%s?ref=%s", + repoPath(repo.FullName), strings.Join(escaped, "/"), url.QueryEscape(repo.DefaultBranch))) +} diff --git a/modules/gitea/cmd/npm-registry/main.go b/modules/gitea/cmd/npm-registry/main.go new file mode 100644 index 00000000..52d07f9e --- /dev/null +++ b/modules/gitea/cmd/npm-registry/main.go @@ -0,0 +1,130 @@ +// npm-registry: the forge module's Go bundle for its package registry (novox/hq ADR 0251 §4, to-be 51). +// A process the node's runtime launches and speaks MCP over stdio to, beside the module's TypeScript +// bundle. It says which versions of each npm package the registry holds, what keeps each one, and — +// asked with a why — deletes the versions nothing keeps. stdout is the protocol; it says what it says on +// stderr, and never the forge's credential. +package main + +import ( + "context" + "fmt" + "math" + "os" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// ToolNames are the tools this bundle serves. The module's manifest lists no `tools`: its TypeScript +// bundle's tools are served without one, and a claim without `serves` would offer a `tools` list to both +// of the module's seats as their verbs. The README names these two, and a test holds the two together. +var ToolNames = []string{"npm_packages", "npm_retention"} + +func main() { + if err := stdio.Serve("", Tools(func() (*Forge, error) { return ForgeFromEnv() })); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// Tools are the bundle's tools over the forge the function gives; asked per call, so a manifest that +// does not set the environment is a refusal naming what is missing, not a bundle that will not start. +func Tools(forge func() (*Forge, error)) []stdio.Tool { + pkg := map[string]any{"type": "string", "description": "one package, by its name (default every package)"} + keep := map[string]any{"type": "integer", "description": fmt.Sprintf("how many of each package's newest versions are kept whatever names them (default %d, at least 1)", DefaultKeep)} + return []stdio.Tool{ + { + Name: "npm_packages", + Description: "Every npm package in the forge's package registry, each version newest first with when it was published, " + + "its size and what keeps it: a lockfile on a repository's default branch that names it, the highest version " + + "satisfying a range a package.json there names, a dist-tag, or being among the newest `keep` of its package " + + "(novox/hq ADR 0251). Repositories and files that could not be read are named. Changes nothing. Replaces " + + "npm view and npm dist-tag ls. (r)", + Input: map[string]any{"package": pkg, "keep": keep}, + Run: func(args map[string]any) (any, error) { + f, err := forge() + if err != nil { + return nil, err + } + k, err := count(args, "keep", DefaultKeep, 1) + if err != nil { + return nil, err + } + s, err := survey(context.Background(), f, text(args, "package"), k, func(Item) bool { return true }) + if err != nil { + return nil, err + } + kept, total := 0, 0 + for _, p := range s.Packages { + for _, v := range p.Versions { + total++ + if v.Kept { + kept++ + } + } + } + s.Said = append(s.Said, fmt.Sprintf("%d packages, %d versions, %d kept; %d repositories read, %d unread", + len(s.Packages), total, kept, s.Repositories, len(s.Unread))) + return s, nil + }, + }, + { + Name: "npm_retention", + Description: "What retention would delete from the forge's package registry: every version nothing keeps — no lockfile " + + "on a repository's default branch names it, it is not the highest version satisfying any range a package.json " + + "there names, no dist-tag names it, and it is not among the newest `keep` of its package — with the bytes. " + + "A dry run unless dry_run is false; a real run needs why, and deletes nothing at all when any repository or " + + "file could not be read (novox/hq ADR 0251). Replaces npm unpublish. (a)", + Input: map[string]any{ + "package": pkg, + "keep": keep, + "dry_run": map[string]any{"type": "boolean", "description": "false to delete; default true"}, + "why": map[string]any{"type": "string", "description": "why the versions are deleted; required for a real run"}, + }, + Run: func(args map[string]any) (any, error) { + f, err := forge() + if err != nil { + return nil, err + } + k, err := count(args, "keep", DefaultKeep, 1) + if err != nil { + return nil, err + } + dry := true + if v, given := args["dry_run"]; given && v != nil { + b, ok := v.(bool) + if !ok { + if s, isText := v.(string); isText && (s == "true" || s == "false") { + b, ok = s == "true", true + } + } + if !ok { + return nil, fmt.Errorf("dry_run is true or false, not %v", v) + } + dry = b + } + return retention(context.Background(), f, text(args, "package"), k, dry, text(args, "why")) + }, + }, + } +} + +func text(args map[string]any, key string) string { + s, _ := args[key].(string) + return strings.TrimSpace(s) +} + +func count(args map[string]any, key string, fallback, least int) (int, error) { + v, given := args[key] + if !given || v == nil { + return fallback, nil + } + x, ok := v.(float64) + if !ok || x != math.Trunc(x) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + if int(x) < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + return int(x), nil +} diff --git a/modules/gitea/cmd/npm-registry/refs.go b/modules/gitea/cmd/npm-registry/refs.go new file mode 100644 index 00000000..18c86cda --- /dev/null +++ b/modules/gitea/cmd/npm-registry/refs.go @@ -0,0 +1,241 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "path" + "regexp" + "strings" +) + +// What the forge's repositories say they depend on (novox/hq ADR 0251 §4): every range a package +// manifest names, and every version a lockfile pins. Read from file contents alone, so it is tested +// without a forge. + +// Wanted is one range a package manifest names, and where. +type Wanted struct { + Package string + Range string + Where string // repository:path +} + +// Pinned is one version a lockfile names, and where. +type Pinned struct { + Package string + Version string + Where string +} + +// Kinds of file this reads, by base name. +var ( + manifestNames = map[string]bool{"package.json": true} + lockNames = map[string]bool{"package-lock.json": true, "npm-shrinkwrap.json": true, "yarn.lock": true, + "pnpm-lock.yaml": true} +) + +// interesting is whether a path is a file this reads; nothing under node_modules, which is what a +// lockfile already says and is not the repository's own. +func interesting(p string) bool { + for _, part := range strings.Split(p, "/") { + if part == "node_modules" { + return false + } + } + base := path.Base(p) + return manifestNames[base] || lockNames[base] +} + +var dependencyFields = []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} + +// ReadManifest is every range a package.json names for a registry package. An `npm:` alias names the +// package it stands for. A range that is not the registry's (a path, a URL, git, a workspace) is not +// a range on this registry, and is left out. +func ReadManifest(content []byte, where string, known map[string]bool) ([]Wanted, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(content, &m); err != nil { + return nil, err + } + var out []Wanted + for _, field := range dependencyFields { + var deps map[string]string + if raw, has := m[field]; !has || json.Unmarshal(raw, &deps) != nil { + continue + } + for name, spec := range deps { + if alias, ok := strings.CutPrefix(spec, "npm:"); ok { + at := strings.LastIndex(alias, "@") + if at <= 0 { + name, spec = alias, "*" + } else { + name, spec = alias[:at], alias[at+1:] + } + } + if !known[name] || notRegistry(spec) { + continue + } + out = append(out, Wanted{Package: name, Range: strings.TrimSpace(spec), Where: where}) + } + } + return out, nil +} + +func notRegistry(spec string) bool { + for _, p := range []string{"file:", "link:", "workspace:", "git+", "git:", "github:", "http:", "https:", "portal:", "patch:"} { + if strings.HasPrefix(spec, p) { + return true + } + } + return strings.Contains(spec, "/") && !strings.HasPrefix(spec, "npm:") +} + +// ReadLock is every registry package version a lockfile names. +func ReadLock(base string, content []byte, where string, known map[string]bool) ([]Pinned, error) { + switch base { + case "package-lock.json", "npm-shrinkwrap.json": + return readNpmLock(content, where, known) + case "yarn.lock": + return readYarnLock(content, where, known), nil + case "pnpm-lock.yaml": + return readTextLock(content, where, known), nil + } + return nil, nil +} + +type npmLockEntry struct { + Name string `json:"name"` + Version string `json:"version"` + Dependencies map[string]npmLockEntry `json:"dependencies"` +} + +func readNpmLock(content []byte, where string, known map[string]bool) ([]Pinned, error) { + var lock struct { + Packages map[string]npmLockEntry `json:"packages"` + Dependencies map[string]npmLockEntry `json:"dependencies"` + } + if err := json.Unmarshal(content, &lock); err != nil { + return nil, err + } + var out []Pinned + add := func(name, version string) { + if known[name] && version != "" { + out = append(out, Pinned{Package: name, Version: version, Where: where}) + } + } + // Version 2 and 3: keyed by the path it is installed at. An alias carries the real name. + for key, e := range lock.Packages { + _, name, found := cutLast(key, "node_modules/") + if !found { + continue + } + if e.Name != "" { + name = e.Name + } + add(name, e.Version) + } + // Version 1 (and 2's copy): nested by name. An alias's version is `npm:@`. + var walk func(map[string]npmLockEntry) + walk = func(deps map[string]npmLockEntry) { + for name, e := range deps { + if alias, ok := strings.CutPrefix(e.Version, "npm:"); ok { + if at := strings.LastIndex(alias, "@"); at > 0 { + add(alias[:at], alias[at+1:]) + } + } else { + add(name, e.Version) + } + walk(e.Dependencies) + } + } + walk(lock.Dependencies) + return out, nil +} + +func cutLast(s, sep string) (string, string, bool) { + i := strings.LastIndex(s, sep) + if i < 0 { + return s, "", false + } + return s[:i], s[i+len(sep):], true +} + +// readYarnLock reads both yarn formats: a header of the specs it resolves (`"@a/b@^1.0.0", "@a/b@^1.1.0":`) +// and an indented `version "1.2.3"` (classic) or `version: 1.2.3` (berry) under it. +func readYarnLock(content []byte, where string, known map[string]bool) []Pinned { + var out []Pinned + var names []string + scanner := bufio.NewScanner(bytes.NewReader(content)) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + for scanner.Scan() { + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !strings.HasPrefix(line, " ") && strings.HasSuffix(line, ":") { + names = names[:0] + for _, spec := range strings.Split(strings.TrimSuffix(line, ":"), ",") { + spec = strings.Trim(strings.TrimSpace(spec), `"`) + // `name@range`, `name@npm:range`; the name may itself start with @. + at := strings.Index(spec[min(1, len(spec)):], "@") + if at < 0 { + continue + } + names = append(names, spec[:at+1]) + } + continue + } + trimmed := strings.TrimSpace(line) + if v, ok := strings.CutPrefix(trimmed, "version "); ok && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") { + addYarn(&out, names, strings.Trim(v, `"`), where, known) + } else if v, ok := strings.CutPrefix(trimmed, "version: "); ok && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") { + addYarn(&out, names, strings.Trim(v, `"`), where, known) + } + } + return out +} + +func addYarn(out *[]Pinned, names []string, version, where string, known map[string]bool) { + seen := map[string]bool{} + for _, n := range names { + if known[n] && !seen[n] { + seen[n] = true + *out = append(*out, Pinned{Package: n, Version: version, Where: where}) + } + } +} + +var versionText = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`) + +// readTextLock reads a lockfile whose format changes between versions (pnpm's) by what every format +// has in common: each resolved package written as `@` or `/`. Only names +// the registry holds are looked for, so another package whose name merely contains one is told apart by +// the character before it. +func readTextLock(content []byte, where string, known map[string]bool) []Pinned { + text := string(content) + var out []Pinned + seen := map[string]bool{} + for name := range known { + for start := 0; ; { + i := strings.Index(text[start:], name) + if i < 0 { + break + } + i += start + start = i + len(name) + if i > 0 && !strings.ContainsRune(" '\"/\n\t:", rune(text[i-1])) { + continue + } + rest := text[start:] + if len(rest) == 0 || (rest[0] != '@' && rest[0] != '/') { + continue + } + v := versionText.FindString(rest[1:]) + if v == "" || seen[name+"@"+v] { + continue + } + seen[name+"@"+v] = true + out = append(out, Pinned{Package: name, Version: v, Where: where}) + } + } + return out +} diff --git a/modules/gitea/cmd/npm-registry/refs_test.go b/modules/gitea/cmd/npm-registry/refs_test.go new file mode 100644 index 00000000..ad75526c --- /dev/null +++ b/modules/gitea/cmd/npm-registry/refs_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "sort" + "strings" + "testing" +) + +var knownHere = map[string]bool{"@novox/mesh-sdk": true, "@novox/ui": true, "sdk": true} + +func pins(ps []Pinned) string { + var out []string + for _, p := range ps { + out = append(out, p.Package+"@"+p.Version) + } + sort.Strings(out) + return strings.Join(out, " ") +} + +func TestAManifestNamesItsRegistryRangesAndNothingElse(t *testing.T) { + got, err := ReadManifest([]byte(`{ + "dependencies": {"@novox/mesh-sdk": "^0.1.0", "left-pad": "^1.0.0", "@novox/ui": "file:../ui"}, + "devDependencies": {"sdk": "npm:@novox/ui@~2.0.0"}, + "peerDependencies": {"@novox/ui": "workspace:*"}, + "optionalDependencies": {"@novox/ui": "1.x"} + }`), "r:package.json", knownHere) + if err != nil { + t.Fatal(err) + } + var said []string + for _, w := range got { + said = append(said, w.Package+" "+w.Range) + } + sort.Strings(said) + if strings.Join(said, ",") != "@novox/mesh-sdk ^0.1.0,@novox/ui 1.x,@novox/ui ~2.0.0" { + t.Fatalf("read %v", said) + } +} + +func TestAnNpmLockfileOfEveryVersionNamesWhatItPins(t *testing.T) { + v3 := `{"lockfileVersion":3,"packages":{"":{"name":"x"}, + "node_modules/@novox/mesh-sdk":{"version":"0.1.3"}, + "node_modules/a/node_modules/@novox/mesh-sdk":{"version":"0.1.1"}, + "node_modules/sdk":{"name":"@novox/ui","version":"2.0.4"}, + "node_modules/left-pad":{"version":"1.3.0"}}}` + got, err := ReadLock("package-lock.json", []byte(v3), "r:package-lock.json", knownHere) + if err != nil { + t.Fatal(err) + } + if pins(got) != "@novox/mesh-sdk@0.1.1 @novox/mesh-sdk@0.1.3 @novox/ui@2.0.4" { + t.Fatalf("v3: %s", pins(got)) + } + v1 := `{"lockfileVersion":1,"dependencies":{"@novox/mesh-sdk":{"version":"0.1.2","dependencies":{"@novox/ui":{"version":"1.0.0"}}}, + "sdk":{"version":"npm:@novox/ui@2.0.1"}}}` + got, err = ReadLock("npm-shrinkwrap.json", []byte(v1), "r", knownHere) + if err != nil { + t.Fatal(err) + } + if pins(got) != "@novox/mesh-sdk@0.1.2 @novox/ui@1.0.0 @novox/ui@2.0.1" { + t.Fatalf("v1: %s", pins(got)) + } + if _, err := ReadLock("package-lock.json", []byte("{not json"), "r", knownHere); err == nil { + t.Fatal("a broken lockfile read as empty") + } +} + +func TestAYarnLockOfEitherFormatNamesWhatItPins(t *testing.T) { + classic := "# yarn lockfile v1\n\n\"@novox/mesh-sdk@^0.1.0\", \"@novox/mesh-sdk@^0.1.2\":\n version \"0.1.4\"\n resolved \"x\"\n dependencies:\n left-pad \"^1\"\n\nleft-pad@^1:\n version \"1.3.0\"\n" + if got := pins(readYarnLock([]byte(classic), "r", knownHere)); got != "@novox/mesh-sdk@0.1.4" { + t.Fatalf("classic: %s", got) + } + berry := "__metadata:\n version: 6\n\n\"@novox/ui@npm:^2.0.0\":\n version: 2.0.7\n resolution: \"@novox/ui@npm:2.0.7\"\n" + if got := pins(readYarnLock([]byte(berry), "r", knownHere)); got != "@novox/ui@2.0.7" { + t.Fatalf("berry: %s", got) + } +} + +func TestAPnpmLockNamesWhatItPinsInEachFormat(t *testing.T) { + lock := "lockfileVersion: '9.0'\npackages:\n '@novox/mesh-sdk@0.1.5':\n resolution: {}\n /@novox/ui/2.0.2:\n resolution: {}\n /@other/novox/ui@9.9.9:\n /x-sdk@1.0.0:\n" + if got := pins(readTextLock([]byte(lock), "r", knownHere)); got != "@novox/mesh-sdk@0.1.5 @novox/ui@2.0.2" { + t.Fatalf("pnpm: %s", got) + } +} + +func TestOnlyTheRepositorysOwnManifestsAreRead(t *testing.T) { + for p, want := range map[string]bool{"package.json": true, "modules/x/package-lock.json": true, "yarn.lock": true, + "pnpm-lock.yaml": true, "node_modules/a/package.json": false, "README.md": false, "a/package.json.bak": false} { + if interesting(p) != want { + t.Errorf("%s: %v", p, !want) + } + } +} diff --git a/modules/gitea/cmd/npm-registry/retention.go b/modules/gitea/cmd/npm-registry/retention.go new file mode 100644 index 00000000..886e2706 --- /dev/null +++ b/modules/gitea/cmd/npm-registry/retention.go @@ -0,0 +1,425 @@ +package main + +import ( + "context" + "errors" + "fmt" + "path" + "sort" + "strings" + "sync" + "time" +) + +// Retention for the package registry (novox/hq ADR 0251 §4, to-be 51): a version is kept when a +// lockfile on a repository's default branch names it, when it is the highest version satisfying a +// range a package manifest there names, when a dist-tag names it, or when it is among the newest +// `keep` of its package. Everything else is what retention would delete. + +// DefaultKeep is how many of each package's newest versions are kept whatever names them: the same +// five builds the artifact store keeps (ADR 0189 §3), so "how far back" has one answer. +const DefaultKeep = 5 + +// workers bounds how many calls go to the forge at once; budget how long one tool call may read. +const ( + workers = 8 + budget = 50 * time.Second +) + +// Item is one version, with what keeps it. +type Item struct { + Version string `json:"version"` + Published time.Time `json:"published"` + Bytes int64 `json:"bytes"` + SizeError string `json:"size_error,omitempty"` + Kept bool `json:"kept"` + Why []string `json:"why,omitempty"` +} + +// Package is one package's versions, newest first. +type Package struct { + Name string `json:"name"` + DistTags map[string]string `json:"dist_tags,omitempty"` + // KeepsAll says why every version is kept, when something about the package could not be read. + KeepsAll string `json:"keeps_all,omitempty"` + Versions []Item `json:"versions"` +} + +// Survey is the registry and what the forge's repositories say of it. +type Survey struct { + Owner string `json:"owner"` + Keep int `json:"keep"` + Repositories int `json:"repositories_read"` + Unread []string `json:"repositories_unread,omitempty"` + NotRead []string `json:"files_not_read,omitempty"` + Packages []Package `json:"packages"` + Said []string `json:"said"` +} + +// survey reads the registry and every repository, and decides what is kept. sizes says whether to +// ask the forge each version's size (for these versions only, when not nil). +func survey(ctx context.Context, f *Forge, only string, keep int, sizes func(Item) bool) (*Survey, error) { + ctx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + listed, err := f.Packages(ctx) + if err != nil { + return nil, err + } + byName := map[string][]PackageVersion{} + for _, v := range listed { + byName[v.Name] = append(byName[v.Name], v) + } + if only != "" { + if _, has := byName[only]; !has { + return nil, fmt.Errorf("the registry of %s holds no npm package %q", f.Owner, only) + } + } + known := map[string]bool{} + for name := range byName { + known[name] = true + } + + s := &Survey{Owner: f.Owner, Keep: keep, Said: []string{}} + wanted, pinned, err := readRepositories(ctx, f, known, s) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(byName)) + for name := range byName { + if only == "" || name == only { + names = append(names, name) + } + } + sort.Strings(names) + tags := distTags(ctx, f, names) + for _, name := range names { + s.Packages = append(s.Packages, decide(name, byName[name], tags[name], wanted[name], pinned[name], keep)) + } + if sizes != nil { + measure(ctx, f, s, sizes) + } + return s, nil +} + +type tagAnswer struct { + tags map[string]string + err error +} + +func distTags(ctx context.Context, f *Forge, names []string) map[string]tagAnswer { + out := map[string]tagAnswer{} + var mu sync.Mutex + each(names, func(name string) { + t, err := f.DistTags(ctx, name) + mu.Lock() + out[name] = tagAnswer{t, err} + mu.Unlock() + }) + return out +} + +// each runs fn over items, workers at a time. +func each[T any](items []T, fn func(T)) { + var wg sync.WaitGroup + gate := make(chan struct{}, workers) + for _, item := range items { + wg.Add(1) + gate <- struct{}{} + go func(item T) { + defer wg.Done() + defer func() { <-gate }() + fn(item) + }(item) + } + wg.Wait() +} + +// readRepositories reads every repository's manifests and lockfiles. A repository that could not be +// read is named in Unread: what it depends on is not known, so nothing may be deleted. +func readRepositories(ctx context.Context, f *Forge, known map[string]bool, s *Survey) (map[string][]Wanted, map[string][]Pinned, error) { + repos, err := f.Repositories(ctx) + if err != nil { + return nil, nil, fmt.Errorf("the forge's repositories cannot be listed, so nothing is known to depend on anything: %w", err) + } + wanted := map[string][]Wanted{} + pinned := map[string][]Pinned{} + var mu sync.Mutex + each(repos, func(r Repository) { + if r.Empty || r.DefaultBranch == "" { + mu.Lock() + s.Repositories++ + mu.Unlock() + return + } + files, err := f.Files(ctx, r) + if errors.Is(err, errNotFound) { + // A repository without its default branch holds nothing to read. + files, err = nil, nil + } + if err != nil { + mu.Lock() + s.Unread = append(s.Unread, fmt.Sprintf("%s: %v", r.FullName, err)) + mu.Unlock() + return + } + var ws []Wanted + var ps []Pinned + var notRead, unread []string + for _, p := range files { + if !interesting(p) { + continue + } + where := r.FullName + ":" + p + content, err := f.Raw(ctx, r, p) + if err != nil { + unread = append(unread, fmt.Sprintf("%s: %v", where, err)) + continue + } + base := path.Base(p) + if manifestNames[base] { + got, err := ReadManifest(content, where, known) + if err != nil { + notRead = append(notRead, fmt.Sprintf("%s: not JSON (%v)", where, err)) + continue + } + ws = append(ws, got...) + continue + } + got, err := ReadLock(base, content, where, known) + if err != nil { + notRead = append(notRead, fmt.Sprintf("%s: not readable (%v)", where, err)) + continue + } + ps = append(ps, got...) + } + mu.Lock() + defer mu.Unlock() + s.Repositories++ + s.Unread = append(s.Unread, unread...) + s.NotRead = append(s.NotRead, notRead...) + for _, w := range ws { + wanted[w.Package] = append(wanted[w.Package], w) + } + for _, p := range ps { + pinned[p.Package] = append(pinned[p.Package], p) + } + }) + sort.Strings(s.Unread) + sort.Strings(s.NotRead) + return wanted, pinned, nil +} + +// decide is one package's versions, newest first, each with what keeps it. +func decide(name string, listed []PackageVersion, tags tagAnswer, wanted []Wanted, pinned []Pinned, keep int) Package { + p := Package{Name: name, DistTags: tags.tags} + type entry struct { + item Item + v Version + ok bool + } + var entries []*entry + byVersion := map[string]*entry{} + var parsed []Version + for _, l := range listed { + e := &entry{item: Item{Version: l.Version, Published: l.CreatedAt}} + e.v, e.ok = mustVersion(l.Version) + entries = append(entries, e) + byVersion[l.Version] = e + if e.ok { + parsed = append(parsed, e.v) + } + } + why := func(version, reason string) { + if e := byVersion[version]; e != nil { + e.item.Why = append(e.item.Why, reason) + } + } + + var keepsAll []string + if tags.err != nil { + keepsAll = append(keepsAll, fmt.Sprintf("its dist-tags could not be read (%v)", tags.err)) + } + for tag, version := range tags.tags { + why(version, "dist-tag "+tag) + } + for _, pin := range pinned { + why(pin.Version, "a lockfile names it: "+pin.Where) + } + for _, w := range wanted { + if version, isTag := tags.tags[w.Range]; isTag { + why(version, fmt.Sprintf("dist-tag %q is the range %s names", w.Range, w.Where)) + continue + } + r, err := ParseRange(w.Range) + if err != nil { + keepsAll = append(keepsAll, fmt.Sprintf("the range %q in %s cannot be read (%v)", w.Range, w.Where, err)) + continue + } + if best, found := MaxSatisfying(r, parsed); found { + why(best.String(), fmt.Sprintf("the highest version satisfying %s in %s", w.Range, w.Where)) + } + } + + sort.SliceStable(entries, func(i, j int) bool { + a, b := entries[i], entries[j] + switch { + case a.ok && b.ok: + return Compare(a.v, b.v) > 0 + case a.ok != b.ok: + return a.ok // a version that is not semver sorts last + } + return a.item.Published.After(b.item.Published) + }) + newest := 0 + for _, e := range entries { + if !e.ok { + e.item.Why = append(e.item.Why, "not a semantic version, so not ordered: kept") + continue + } + if newest < keep { + newest++ + e.item.Why = append(e.item.Why, fmt.Sprintf("among the newest %d (pre-releases count)", keep)) + } + } + if len(keepsAll) > 0 { + p.KeepsAll = strings.Join(keepsAll, "; ") + } + for _, e := range entries { + e.item.Why = dedupe(e.item.Why) + e.item.Kept = len(e.item.Why) > 0 || p.KeepsAll != "" + p.Versions = append(p.Versions, e.item) + } + return p +} + +func mustVersion(s string) (Version, bool) { + v, err := ParseVersion(s) + return v, err == nil +} + +// dedupe keeps each reason once and at most a few of each kind, so a version a hundred lockfiles name +// is not a hundred lines. +func dedupe(why []string) []string { + seen := map[string]bool{} + kinds := map[string]int{} + var out []string + more := map[string]int{} + for _, w := range why { + if seen[w] { + continue + } + seen[w] = true + kind, _, _ := strings.Cut(w, ":") + if kinds[kind] >= 3 { + more[kind]++ + continue + } + kinds[kind]++ + out = append(out, w) + } + for kind, n := range more { + out = append(out, fmt.Sprintf("%s: and %d more", kind, n)) + } + sort.Strings(out) + return out +} + +// measure asks the forge the size of each version sizes picks. +func measure(ctx context.Context, f *Forge, s *Survey, sizes func(Item) bool) { + type at struct{ p, v int } + var todo []at + for i := range s.Packages { + for j := range s.Packages[i].Versions { + if sizes(s.Packages[i].Versions[j]) { + todo = append(todo, at{i, j}) + } + } + } + var mu sync.Mutex + each(todo, func(a at) { + p := &s.Packages[a.p] + n, err := f.Size(ctx, p.Name, p.Versions[a.v].Version) + mu.Lock() + defer mu.Unlock() + if err != nil { + p.Versions[a.v].SizeError = err.Error() + return + } + p.Versions[a.v].Bytes = n + }) +} + +// Candidate is one version retention would delete. +type Candidate struct { + Package string `json:"package"` + Version string `json:"version"` + Published time.Time `json:"published"` + Bytes int64 `json:"bytes"` +} + +// Plan is what retention would delete, and — for a real run — what it did. +type Plan struct { + *Survey + DryRun bool `json:"dry_run"` + Why string `json:"why,omitempty"` + Candidates []Candidate `json:"would_delete"` + Bytes int64 `json:"bytes"` + Deleted []string `json:"deleted,omitempty"` + Refused []string `json:"refused,omitempty"` +} + +// retention works out the plan, and carries it out when dryRun is false. +func retention(ctx context.Context, f *Forge, only string, keep int, dryRun bool, why string) (*Plan, error) { + if !dryRun && strings.TrimSpace(why) == "" { + return nil, errors.New("a real run deletes versions from the package registry and needs why; nothing was done") + } + s, err := survey(ctx, f, only, keep, func(i Item) bool { return !i.Kept }) + if err != nil { + return nil, err + } + p := &Plan{Survey: s, DryRun: dryRun, Why: strings.TrimSpace(why), Candidates: []Candidate{}} + for _, pkg := range s.Packages { + for _, v := range pkg.Versions { + if !v.Kept { + p.Candidates = append(p.Candidates, Candidate{pkg.Name, v.Version, v.Published, v.Bytes}) + p.Bytes += v.Bytes + } + } + } + s.Said = append(s.Said, fmt.Sprintf("%d repositories read; %d versions in %d packages; %d would be deleted, %s", + s.Repositories, countVersions(s), len(s.Packages), len(p.Candidates), mib(p.Bytes))) + if len(s.Unread) > 0 { + s.Said = append(s.Said, fmt.Sprintf("%d repositories or files could not be read: what they depend on is not "+ + "known, so a real run deletes nothing", len(s.Unread))) + } + if dryRun { + s.Said = append(s.Said, "a dry run: nothing was deleted") + return p, nil + } + if len(s.Unread) > 0 { + return p, fmt.Errorf("refused: %d repositories or files could not be read, so what depends on what is not "+ + "known; nothing was deleted (first: %s)", len(s.Unread), s.Unread[0]) + } + del, cancel := context.WithTimeout(context.Background(), budget) + defer cancel() + for _, c := range p.Candidates { + if err := f.Delete(del, c.Package, c.Version); err != nil && !errors.Is(err, errNotFound) { + p.Refused = append(p.Refused, fmt.Sprintf("%s@%s: %v", c.Package, c.Version, err)) + continue + } + p.Deleted = append(p.Deleted, c.Package+"@"+c.Version) + } + s.Said = append(s.Said, fmt.Sprintf("deleted %d, refused %d, because: %s", len(p.Deleted), len(p.Refused), p.Why)) + return p, nil +} + +func countVersions(s *Survey) int { + n := 0 + for _, p := range s.Packages { + n += len(p.Versions) + } + return n +} + +func mib(b int64) string { return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) } diff --git a/modules/gitea/cmd/npm-registry/retention_test.go b/modules/gitea/cmd/npm-registry/retention_test.go new file mode 100644 index 00000000..d81e3c4a --- /dev/null +++ b/modules/gitea/cmd/npm-registry/retention_test.go @@ -0,0 +1,393 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "testing" + "time" +) + +// fakeForge answers the forge's interface from maps, and records every delete. +type fakeForge struct { + t *testing.T + versions map[string][]string // package -> versions + tags map[string]map[string]string // package -> dist-tags + repos map[string]map[string]string // repository -> path -> content + broken map[string]bool // repositories whose tree answers 500 + mu sync.Mutex + deleted []string + password string +} + +func (f *fakeForge) serve() *Forge { + srv := httptest.NewServer(http.HandlerFunc(f.handle)) + f.t.Cleanup(srv.Close) + file := filepath.Join(f.t.TempDir(), "admin.secret") + if err := os.WriteFile(file, []byte(f.password+"\n"), 0o600); err != nil { + f.t.Fatal(err) + } + return &Forge{URL: srv.URL, User: "admin", PasswordFile: file, Owner: "acme"} +} + +var ( + versionFiles = regexp.MustCompile(`^/api/v1/packages/acme/npm/([^/]+)/([^/]+)(/files)?$`) + treePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/git/trees/`) + rawPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/raw/(.+)$`) +) + +func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "admin" || pass != f.password { + http.Error(w, "unauthorised", http.StatusUnauthorized) + return + } + p := r.URL.EscapedPath() + q := r.URL.Query() + page := 1 + fmt.Sscan(q.Get("page"), &page) + switch { + case p == "/api/v1/packages/acme" && r.Method == http.MethodGet: + var all []map[string]any + names := keys(f.versions) + for _, n := range names { + for i, v := range f.versions[n] { + all = append(all, map[string]any{"name": n, "version": v, "type": "npm", + "created_at": time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)}) + } + } + writeJSON(w, pageOf(all, page, pageSize)) + case strings.HasPrefix(p, "/api/packages/acme/npm/"): + name, _ := url.PathUnescape(strings.TrimPrefix(p, "/api/packages/acme/npm/")) + writeJSON(w, map[string]any{"name": name, "dist-tags": f.tags[name]}) + case versionFiles.MatchString(p): + m := versionFiles.FindStringSubmatch(p) + name, _ := url.PathUnescape(m[1]) + version, _ := url.PathUnescape(m[2]) + if m[3] != "" { + writeJSON(w, []map[string]any{{"name": "a.tgz", "size": 1 << 20}, {"name": "b", "size": 1024}}) + return + } + if r.Method != http.MethodDelete { + http.Error(w, "no", http.StatusMethodNotAllowed) + return + } + f.mu.Lock() + f.deleted = append(f.deleted, name+"@"+version) + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + case p == "/api/v1/repos/search": + var all []map[string]any + for _, n := range keys(f.repos) { + all = append(all, map[string]any{"full_name": n, "default_branch": "main", "empty": len(f.repos[n]) == 0}) + } + writeJSON(w, map[string]any{"ok": true, "data": pageOf(all, page, pageSize)}) + case treePath.MatchString(p): + m := treePath.FindStringSubmatch(p) + full := m[1] + "/" + m[2] + if f.broken[full] { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + var tree []map[string]any + for _, path := range keys(f.repos[full]) { + tree = append(tree, map[string]any{"path": path, "type": "blob"}) + } + writeJSON(w, map[string]any{"tree": tree, "truncated": false, "total_count": len(tree)}) + case rawPath.MatchString(p): + m := rawPath.FindStringSubmatch(p) + file, _ := url.PathUnescape(m[3]) + content, has := f.repos[m[1]+"/"+m[2]][file] + if !has { + http.NotFound(w, r) + return + } + w.Write([]byte(content)) + default: + http.NotFound(w, r) + } +} + +func keys[T any](m map[string]T) []string { + var out []string + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func pageOf[T any](all []T, page, size int) []T { + lo := (page - 1) * size + if lo >= len(all) { + return []T{} + } + return all[lo:min(lo+size, len(all))] +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// aRegistry: the SDK with nine versions over two lines, a UI package with three, and repositories that +// pin an old SDK in a lockfile, range onto the old line, and tag a pre-release. +func aRegistry(t *testing.T) *fakeForge { + return &fakeForge{ + t: t, + password: "s3cret-never-shown", + versions: map[string][]string{ + "@acme/sdk": {"0.1.0", "0.1.1", "0.1.2", "0.1.3", "0.2.0", "0.2.1", "0.2.2", "0.3.0-beta.1", "0.3.0"}, + "@acme/ui": {"1.0.0", "1.1.0", "2.0.0"}, + }, + tags: map[string]map[string]string{ + "@acme/sdk": {"latest": "0.3.0", "next": "0.3.0-beta.1"}, + "@acme/ui": {"latest": "2.0.0"}, + }, + repos: map[string]map[string]string{ + "acme/app": { + "package.json": `{"dependencies":{"@acme/sdk":"^0.1.0","left-pad":"1.0.0"}}`, + "package-lock.json": `{"lockfileVersion":3,"packages":{"node_modules/@acme/sdk":{"version":"0.1.0"}}}`, + }, + "acme/site": {"web/package.json": `{"devDependencies":{"@acme/ui":"~1.0.0"}}`, "README.md": "x"}, + "acme/empty": {}, + }, + } +} + +func decided(s *Survey) map[string]Item { + out := map[string]Item{} + for _, p := range s.Packages { + for _, v := range p.Versions { + out[p.Name+"@"+v.Version] = v + } + } + return out +} + +func TestWhatKeepsAVersionIsWhatNamesIt(t *testing.T) { + f := aRegistry(t) + s, err := survey(context.Background(), f.serve(), "", 3, func(Item) bool { return true }) + if err != nil { + t.Fatal(err) + } + if s.Repositories != 3 || len(s.Unread) != 0 { + t.Fatalf("read %d, unread %v", s.Repositories, s.Unread) + } + d := decided(s) + wantKept := map[string]string{ + "@acme/sdk@0.1.0": "a lockfile names it: acme/app:package-lock.json", // old, but pinned + "@acme/sdk@0.1.3": "the highest version satisfying ^0.1.0 in acme/app:package.json", + "@acme/sdk@0.3.0-beta.1": "dist-tag next", + "@acme/sdk@0.3.0": "dist-tag latest", + "@acme/sdk@0.2.2": "among the newest 3 (pre-releases count)", + "@acme/ui@1.0.0": "the highest version satisfying ~1.0.0 in acme/site:web/package.json", + "@acme/ui@1.1.0": "among the newest 3 (pre-releases count)", + } + for v, why := range wantKept { + item := d[v] + if !item.Kept || !contains(item.Why, why) { + t.Errorf("%s: kept %v, why %v; want %q", v, item.Kept, item.Why, why) + } + } + for _, v := range []string{"@acme/sdk@0.1.1", "@acme/sdk@0.1.2", "@acme/sdk@0.2.0", "@acme/sdk@0.2.1"} { + if d[v].Kept { + t.Errorf("%s kept for %v", v, d[v].Why) + } + } + if d["@acme/sdk@0.1.1"].Bytes != 1<<20+1024 { + t.Errorf("size %d", d["@acme/sdk@0.1.1"].Bytes) + } + // Newest first. + if s.Packages[0].Versions[0].Version != "0.3.0" || s.Packages[0].Versions[1].Version != "0.3.0-beta.1" { + t.Errorf("order %v", s.Packages[0].Versions[:2]) + } +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func TestADryRunDeletesNothingAndSaysWhatWouldGo(t *testing.T) { + f := aRegistry(t) + p, err := retention(context.Background(), f.serve(), "", 3, true, "") + if err != nil { + t.Fatal(err) + } + if len(f.deleted) != 0 { + t.Fatalf("a dry run deleted %v", f.deleted) + } + var would []string + for _, c := range p.Candidates { + would = append(would, c.Package+"@"+c.Version) + } + sort.Strings(would) + if strings.Join(would, " ") != "@acme/sdk@0.1.1 @acme/sdk@0.1.2 @acme/sdk@0.2.0 @acme/sdk@0.2.1" { + t.Fatalf("would delete %v", would) + } + if p.Bytes != 4*(1<<20+1024) { + t.Errorf("bytes %d", p.Bytes) + } + raw, _ := json.Marshal(p) + if strings.Contains(string(raw), f.password) { + t.Fatal("the answer carries the forge's password") + } +} + +func TestARealRunWithoutWhyIsRefusedBeforeAnythingIsRead(t *testing.T) { + f := aRegistry(t) + if _, err := retention(context.Background(), f.serve(), "", 3, false, " "); err == nil || !strings.Contains(err.Error(), "needs why") { + t.Fatalf("got %v", err) + } + if len(f.deleted) != 0 { + t.Fatal("deleted without a why") + } +} + +func TestARealRunDeletesOnlyWhatNothingKeeps(t *testing.T) { + f := aRegistry(t) + p, err := retention(context.Background(), f.serve(), "", 3, false, "the registry keeps what is named") + if err != nil { + t.Fatal(err) + } + sort.Strings(f.deleted) + if strings.Join(f.deleted, " ") != "@acme/sdk@0.1.1 @acme/sdk@0.1.2 @acme/sdk@0.2.0 @acme/sdk@0.2.1" { + t.Fatalf("deleted %v", f.deleted) + } + if len(p.Deleted) != 4 || len(p.Refused) != 0 { + t.Fatalf("answered %v / %v", p.Deleted, p.Refused) + } +} + +func TestAnUnreadRepositoryStopsARealRunBeforeAnythingIsDeleted(t *testing.T) { + f := aRegistry(t) + f.broken = map[string]bool{"acme/site": true} + p, err := retention(context.Background(), f.serve(), "", 3, false, "tidy") + if err == nil || !strings.Contains(err.Error(), "nothing was deleted") { + t.Fatalf("got %v", err) + } + if len(f.deleted) != 0 { + t.Fatalf("deleted %v while a repository was unread", f.deleted) + } + if p == nil || len(p.Unread) != 1 || !strings.HasPrefix(p.Unread[0], "acme/site") { + t.Fatalf("unread %v", p) + } + // And the dry run says so, without failing. + dry, err := retention(context.Background(), f.serve(), "", 3, true, "") + if err != nil || len(dry.Unread) != 1 { + t.Fatalf("dry run: %v %v", err, dry) + } +} + +func TestARangeThatCannotBeReadKeepsEveryVersionOfItsPackage(t *testing.T) { + f := aRegistry(t) + f.repos["acme/odd"] = map[string]string{"package.json": `{"dependencies":{"@acme/sdk":"^not.a.range"}}`} + p, err := retention(context.Background(), f.serve(), "@acme/sdk", 1, true, "") + if err != nil { + t.Fatal(err) + } + if len(p.Candidates) != 0 || !strings.Contains(p.Packages[0].KeepsAll, "^not.a.range") { + t.Fatalf("candidates %v, keeps all %q", p.Candidates, p.Packages[0].KeepsAll) + } +} + +func TestADistTagNamedAsARangeKeepsItsVersion(t *testing.T) { + f := aRegistry(t) + f.repos["acme/tagged"] = map[string]string{"package.json": `{"dependencies":{"@acme/sdk":"next"}}`} + s, err := survey(context.Background(), f.serve(), "@acme/sdk", 1, nil) + if err != nil { + t.Fatal(err) + } + if s.Packages[0].KeepsAll != "" || !decided(s)["@acme/sdk@0.3.0-beta.1"].Kept { + t.Fatalf("%+v", s.Packages[0]) + } +} + +func TestAWrongPasswordIsAFailureThatDoesNotSayThePassword(t *testing.T) { + f := aRegistry(t) + forge := f.serve() + f.password = "rotated" + _, err := survey(context.Background(), forge, "", 3, nil) + if err == nil || !strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "s3cret") { + t.Fatalf("got %v", err) + } +} + +func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { + tools := Tools(func() (*Forge, error) { return nil, fmt.Errorf("unset") }) + var served []string + for _, tl := range tools { + served = append(served, tl.Name) + suffix := "(r)" + if tl.Name == "npm_retention" { + suffix = "(a)" + } + if !strings.HasSuffix(tl.Description, suffix) { + t.Errorf("%s does not end with %s", tl.Name, suffix) + } + } + if strings.Join(served, ",") != strings.Join(ToolNames, ",") { + t.Fatalf("served %v, named %v", served, ToolNames) + } + readme, err := os.ReadFile("../../README.md") + if err != nil { + t.Fatal(err) + } + for _, n := range ToolNames { + if !strings.Contains(string(readme), "`"+n+"`") { + t.Errorf("the README does not name %s", n) + } + } + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Tools []string `json:"tools"` + Build struct { + Artifacts []struct { + From string `json:"from"` + Loads []string `json:"loads"` + Env map[string]string `json:"env"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if len(m.Tools) != 0 { + t.Error("the manifest lists tools: its claims would offer them as both seats' verbs") + } + found := false + for _, a := range m.Build.Artifacts { + if a.From == "cmd/npm-registry" { + found = true + for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE", "MESH_NPM_OWNER"} { + if a.Env[k] == "" { + t.Errorf("the bundle is not given %s", k) + } + } + } + } + if !found { + t.Error("the manifest builds no cmd/npm-registry bundle") + } + // Refused cleanly when the environment is missing, not a crash. + if _, err := tools[0].Run(map[string]any{}); err == nil { + t.Error("ran without a forge") + } +} diff --git a/modules/gitea/cmd/npm-registry/semver.go b/modules/gitea/cmd/npm-registry/semver.go new file mode 100644 index 00000000..5e21bf2e --- /dev/null +++ b/modules/gitea/cmd/npm-registry/semver.go @@ -0,0 +1,362 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// Versions and ranges as npm reads them (node-semver), small enough to read whole: a version, the +// order of two, and whether a version satisfies a range. Only what retention needs — which published +// version a range resolves to — and nothing npm does beyond that (no coercion, no loose mode). + +// Version is one semantic version. +type Version struct { + Major, Minor, Patch int + Pre []string // the pre-release identifiers; none for a release + raw string +} + +func (v Version) String() string { return v.raw } + +var versionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`) + +// ParseVersion reads a full version; a partial one (1.2) is not a version. +func ParseVersion(s string) (Version, error) { + s = strings.TrimSpace(s) + m := versionPattern.FindStringSubmatch(s) + if m == nil { + return Version{}, fmt.Errorf("%q is not a version", s) + } + v := Version{raw: strings.TrimPrefix(s, "v")} + v.Major, _ = strconv.Atoi(m[1]) + v.Minor, _ = strconv.Atoi(m[2]) + v.Patch, _ = strconv.Atoi(m[3]) + if m[4] != "" { + v.Pre = strings.Split(m[4], ".") + } + return v, nil +} + +// Compare is -1, 0 or 1 as a is lower than, equal to or higher than b, by semver precedence. +func Compare(a, b Version) int { + for _, d := range [][2]int{{a.Major, b.Major}, {a.Minor, b.Minor}, {a.Patch, b.Patch}} { + if d[0] != d[1] { + if d[0] < d[1] { + return -1 + } + return 1 + } + } + switch { + case len(a.Pre) == 0 && len(b.Pre) == 0: + return 0 + case len(a.Pre) == 0: + return 1 // a release is higher than any of its pre-releases + case len(b.Pre) == 0: + return -1 + } + for i := 0; i < len(a.Pre) && i < len(b.Pre); i++ { + if c := compareIdentifier(a.Pre[i], b.Pre[i]); c != 0 { + return c + } + } + switch { + case len(a.Pre) < len(b.Pre): + return -1 + case len(a.Pre) > len(b.Pre): + return 1 + } + return 0 +} + +func compareIdentifier(a, b string) int { + an, aerr := strconv.Atoi(a) + bn, berr := strconv.Atoi(b) + switch { + case aerr == nil && berr == nil: + switch { + case an < bn: + return -1 + case an > bn: + return 1 + } + return 0 + case aerr == nil: + return -1 // a numeric identifier is lower than an alphanumeric one + case berr == nil: + return 1 + } + return strings.Compare(a, b) +} + +// comparator is one ` `; ANY when the version is the zero bound of `*`. +type comparator struct { + op string // ">", ">=", "<", "<=", "=" + v Version +} + +func (c comparator) test(v Version) bool { + d := Compare(v, c.v) + switch c.op { + case ">": + return d > 0 + case ">=": + return d >= 0 + case "<": + return d < 0 + case "<=": + return d <= 0 + } + return d == 0 +} + +// Range is a set of comparator sets, any one of which a version must satisfy (`||`). +type Range struct { + sets [][]comparator + raw string +} + +func (r Range) String() string { return r.raw } + +// partial is a version that may stop early or carry x/X/*: -1 for a missing or wild part. +type partial struct { + major, minor, patch int + pre []string +} + +var partialPattern = regexp.MustCompile(`^v?(\d+|[xX*])?(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`) + +func parsePartial(s string) (partial, error) { + m := partialPattern.FindStringSubmatch(s) + if m == nil { + return partial{}, fmt.Errorf("%q is not a version or a partial one", s) + } + num := func(x string) int { + if x == "" || x == "x" || x == "X" || x == "*" { + return -1 + } + n, _ := strconv.Atoi(x) + return n + } + p := partial{major: num(m[1]), minor: num(m[2]), patch: num(m[3])} + // Nothing below a wild part counts: 1.x.3 is 1.x. + if p.major < 0 { + p.minor, p.patch = -1, -1 + } else if p.minor < 0 { + p.patch = -1 + } + if m[4] != "" { + if p.patch < 0 { + return partial{}, fmt.Errorf("%q has a pre-release on a partial version", s) + } + p.pre = strings.Split(m[4], ".") + } + return p, nil +} + +func ver(major, minor, patch int, pre ...string) Version { + raw := fmt.Sprintf("%d.%d.%d", major, minor, patch) + if len(pre) > 0 { + raw += "-" + strings.Join(pre, ".") + } + return Version{Major: major, Minor: minor, Patch: patch, Pre: pre, raw: raw} +} + +// lower is the lowest version a partial names; upperExclusive the first version above it (with -0, so +// no pre-release of that next version slips in), and whether it has one. +func (p partial) lower() Version { + switch { + case p.major < 0: + return ver(0, 0, 0) + case p.minor < 0: + return ver(p.major, 0, 0) + case p.patch < 0: + return ver(p.major, p.minor, 0) + } + return ver(p.major, p.minor, p.patch, p.pre...) +} + +func (p partial) upperExclusive() (Version, bool) { + switch { + case p.major < 0: + return Version{}, false + case p.minor < 0: + return ver(p.major+1, 0, 0, "0"), true + case p.patch < 0: + return ver(p.major, p.minor+1, 0, "0"), true + } + return Version{}, false +} + +var ( + hyphenPattern = regexp.MustCompile(`^\s*(\S+)\s+-\s+(\S+)\s*$`) + opSpace = regexp.MustCompile(`(>=|<=|>|<|=|\^|~>?)\s+`) +) + +// ParseRange reads a range as npm does: exact, `^`, `~`, x-ranges, comparisons, hyphen ranges, sets +// joined by spaces, and `||`. +func ParseRange(s string) (Range, error) { + r := Range{raw: s} + for _, part := range strings.Split(s, "||") { + set, err := parseSet(strings.TrimSpace(part)) + if err != nil { + return Range{}, err + } + r.sets = append(r.sets, set) + } + return r, nil +} + +func parseSet(s string) ([]comparator, error) { + if m := hyphenPattern.FindStringSubmatch(s); m != nil { + from, err := parsePartial(m[1]) + if err != nil { + return nil, err + } + to, err := parsePartial(m[2]) + if err != nil { + return nil, err + } + set := []comparator{{">=", from.lower()}} + if up, has := to.upperExclusive(); has { + set = append(set, comparator{"<", up}) + } else if to.major >= 0 { + set = append(set, comparator{"<=", to.lower()}) + } + return set, nil + } + s = opSpace.ReplaceAllString(s, "$1") + if s == "" { + return []comparator{{">=", ver(0, 0, 0)}}, nil + } + var set []comparator + for _, word := range strings.Fields(s) { + cs, err := parseComparator(word) + if err != nil { + return nil, err + } + set = append(set, cs...) + } + return set, nil +} + +func parseComparator(w string) ([]comparator, error) { + var op string + for _, o := range []string{">=", "<=", "~>", ">", "<", "=", "^", "~"} { + if strings.HasPrefix(w, o) { + op, w = o, w[len(o):] + break + } + } + p, err := parsePartial(w) + if err != nil { + return nil, err + } + lo := p.lower() + up, bounded := p.upperExclusive() + switch op { + case "", "=": + if !bounded && p.major >= 0 { + return []comparator{{"=", lo}}, nil + } + return xrange(lo, up, bounded), nil + case "^": + var hi Version + switch { + case p.major < 0: + return []comparator{{">=", ver(0, 0, 0)}}, nil + case p.major > 0 || p.minor < 0: + hi = ver(p.major+1, 0, 0, "0") + case p.minor > 0 || p.patch < 0: + hi = ver(0, p.minor+1, 0, "0") + default: + hi = ver(0, 0, p.patch+1, "0") + } + return []comparator{{">=", lo}, {"<", hi}}, nil + case "~", "~>": + var hi Version + switch { + case p.major < 0: + return []comparator{{">=", ver(0, 0, 0)}}, nil + case p.minor < 0: + hi = ver(p.major+1, 0, 0, "0") + default: + hi = ver(p.major, p.minor+1, 0, "0") + } + return []comparator{{">=", lo}, {"<", hi}}, nil + case ">": + if p.major < 0 { + return []comparator{{"<", ver(0, 0, 0, "0")}}, nil // nothing + } + if bounded { + return []comparator{{">=", up}}, nil + } + return []comparator{{">", lo}}, nil + case ">=": + return []comparator{{">=", lo}}, nil + case "<": + if p.major < 0 { + return []comparator{{"<", ver(0, 0, 0, "0")}}, nil + } + return []comparator{{"<", lo}}, nil + case "<=": + if p.major < 0 { + return []comparator{{">=", ver(0, 0, 0)}}, nil + } + if bounded { + return []comparator{{"<", up}}, nil + } + return []comparator{{"<=", lo}}, nil + } + return nil, fmt.Errorf("%q is not a comparator", w) +} + +func xrange(lo, up Version, bounded bool) []comparator { + if !bounded { + return []comparator{{">=", lo}} + } + return []comparator{{">=", lo}, {"<", up}} +} + +// Satisfies is whether v is in the range. A pre-release satisfies a set only when one of its +// comparators names the same major.minor.patch with a pre-release of its own — npm's rule, so that +// `^1.2.0` never resolves to 1.3.0-beta. +func (r Range) Satisfies(v Version) bool { + for _, set := range r.sets { + if testSet(set, v) { + return true + } + } + return false +} + +func testSet(set []comparator, v Version) bool { + for _, c := range set { + if !c.test(v) { + return false + } + } + if len(v.Pre) == 0 { + return true + } + for _, c := range set { + if len(c.v.Pre) > 0 && c.v.Major == v.Major && c.v.Minor == v.Minor && c.v.Patch == v.Patch { + return true + } + } + return false +} + +// MaxSatisfying is the highest of the versions in the range; false when none is. +func MaxSatisfying(r Range, versions []Version) (Version, bool) { + var best Version + found := false + for _, v := range versions { + if r.Satisfies(v) && (!found || Compare(v, best) > 0) { + best, found = v, true + } + } + return best, found +} diff --git a/modules/gitea/cmd/npm-registry/semver_test.go b/modules/gitea/cmd/npm-registry/semver_test.go new file mode 100644 index 00000000..84ac934d --- /dev/null +++ b/modules/gitea/cmd/npm-registry/semver_test.go @@ -0,0 +1,117 @@ +package main + +import "testing" + +func TestVersionsAreOrderedAsSemverSays(t *testing.T) { + // Each is lower than the next (semver.org §11's own example, and the edges around it). + ordered := []string{"0.0.1", "0.1.0", "1.0.0-0", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", + "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0", "1.0.1", "1.2.0", "1.10.0", "2.0.0"} + for i := 0; i+1 < len(ordered); i++ { + a, _ := ParseVersion(ordered[i]) + b, _ := ParseVersion(ordered[i+1]) + if Compare(a, b) >= 0 || Compare(b, a) <= 0 { + t.Errorf("%s should be lower than %s", ordered[i], ordered[i+1]) + } + } + a, _ := ParseVersion("1.2.3+build.1") + b, _ := ParseVersion("1.2.3") + if Compare(a, b) != 0 { + t.Error("build metadata does not order") + } + for _, bad := range []string{"1.2", "x", "1.2.3.4", "latest", ""} { + if _, err := ParseVersion(bad); err == nil { + t.Errorf("%q read as a version", bad) + } + } +} + +func TestRangesAreReadAsNpmReadsThem(t *testing.T) { + cases := []struct { + r string + in []string + out []string + }{ + {"1.2.3", []string{"1.2.3"}, []string{"1.2.4", "1.2.3-beta"}}, + {"=1.2.3", []string{"1.2.3"}, []string{"1.2.2"}}, + {"^1.2.3", []string{"1.2.3", "1.9.9"}, []string{"2.0.0", "1.2.2", "1.3.0-beta", "2.0.0-0"}}, + {"^0.2.3", []string{"0.2.3", "0.2.9"}, []string{"0.3.0", "0.2.2"}}, + {"^0.0.3", []string{"0.0.3"}, []string{"0.0.4"}}, + {"^1.2", []string{"1.2.0", "1.9.0"}, []string{"2.0.0", "1.1.9"}}, + {"^0.x", []string{"0.0.1", "0.9.9"}, []string{"1.0.0"}}, + {"^0.0", []string{"0.0.9"}, []string{"0.1.0"}}, + {"^1.2.3-beta.2", []string{"1.2.3-beta.2", "1.2.3-beta.4", "1.2.3", "1.5.0"}, []string{"1.2.3-beta.1", "1.2.4-beta.1", "2.0.0"}}, + {"~1.2.3", []string{"1.2.3", "1.2.9"}, []string{"1.3.0", "1.2.2"}}, + {"~1.2", []string{"1.2.0", "1.2.9"}, []string{"1.3.0"}}, + {"~1", []string{"1.0.0", "1.9.9"}, []string{"2.0.0"}}, + {"~0.2.3", []string{"0.2.5"}, []string{"0.3.0"}}, + {"*", []string{"0.0.0", "9.9.9"}, []string{"1.0.0-beta"}}, + {"", []string{"1.0.0"}, nil}, + {"x", []string{"1.0.0"}, nil}, + {"1.x", []string{"1.0.0", "1.9.9"}, []string{"2.0.0", "0.9.9"}}, + {"1.2.*", []string{"1.2.0", "1.2.9"}, []string{"1.3.0"}}, + {"1", []string{"1.5.0"}, []string{"2.0.0"}}, + {">1.2.3", []string{"1.2.4"}, []string{"1.2.3"}}, + {">1.2", []string{"1.3.0"}, []string{"1.2.9"}}, + {">=1.2.3", []string{"1.2.3", "5.0.0"}, []string{"1.2.2"}}, + {"<1.2.3", []string{"1.2.2"}, []string{"1.2.3", "1.2.3-beta"}}, + {"<1.2", []string{"1.1.9"}, []string{"1.2.0"}}, + {"<=1.2.3", []string{"1.2.3"}, []string{"1.2.4"}}, + {"<=1.2", []string{"1.2.9"}, []string{"1.3.0"}}, + {">= 1.2.3 < 2", []string{"1.2.3", "1.9.9"}, []string{"2.0.0", "1.2.2"}}, + {">=1.2.3 <1.5.0", []string{"1.4.9"}, []string{"1.5.0"}}, + {"1.2.3 - 2.3.4", []string{"1.2.3", "2.3.4"}, []string{"2.3.5", "1.2.2"}}, + {"1.2 - 2.3", []string{"1.2.0", "2.3.9"}, []string{"2.4.0", "1.1.9"}}, + {"1.2.3 - 2", []string{"2.9.9"}, []string{"3.0.0"}}, + {"^1.0.0 || ^3.0.0", []string{"1.5.0", "3.1.0"}, []string{"2.0.0"}}, + {"1.2.3 || >=2.5.0 <3", []string{"1.2.3", "2.6.0"}, []string{"2.0.0", "3.0.0"}}, + {"v1.2.3", []string{"1.2.3"}, nil}, + {"~>1.2.3", []string{"1.2.9"}, []string{"1.3.0"}}, + {">=1.0.0-rc.1 <1.0.0", []string{"1.0.0-rc.2"}, []string{"0.9.0-rc.1"}}, + } + for _, c := range cases { + r, err := ParseRange(c.r) + if err != nil { + t.Errorf("%q: %v", c.r, err) + continue + } + for _, s := range c.in { + v, err := ParseVersion(s) + if err != nil { + t.Fatal(err) + } + if !r.Satisfies(v) { + t.Errorf("%q should hold %s", c.r, s) + } + } + for _, s := range c.out { + v, _ := ParseVersion(s) + if r.Satisfies(v) { + t.Errorf("%q should not hold %s", c.r, s) + } + } + } +} + +func TestARangeThatIsNotOneIsRefused(t *testing.T) { + for _, bad := range []string{"latest", "^abc", "1.2.3.4", ">=x.y.z", "1.x-beta"} { + if _, err := ParseRange(bad); err == nil { + t.Errorf("%q read as a range", bad) + } + } +} + +func TestTheHighestSatisfyingVersionIsTheOneARangeResolvesTo(t *testing.T) { + var vs []Version + for _, s := range []string{"0.1.0", "0.1.4", "0.2.0", "0.2.1-beta", "1.0.0"} { + v, _ := ParseVersion(s) + vs = append(vs, v) + } + r, _ := ParseRange("^0.1.0") + if best, ok := MaxSatisfying(r, vs); !ok || best.String() != "0.1.4" { + t.Fatalf("^0.1.0 resolved to %v", best) + } + r, _ = ParseRange("^2.0.0") + if _, ok := MaxSatisfying(r, vs); ok { + t.Fatal("^2.0.0 resolved to something") + } +} diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod new file mode 100644 index 00000000..c05b9bd6 --- /dev/null +++ b/modules/gitea/go.mod @@ -0,0 +1,5 @@ +module gitea + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum new file mode 100644 index 00000000..b474419e --- /dev/null +++ b/modules/gitea/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 20a52ec6..1088a76b 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -239,6 +239,23 @@ "MESH_GITEA_STATE_DIR": "${dir:runtime-state}", "MESH_RECEIVES": "${dir:grants}/npm.json" } + }, + { + "name": "npm-registry", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/npm-registry", + "binary": "npm-registry", + "loads": [ + "npm-registry" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_NPM_OWNER": "novox" + } } ] }, diff --git a/modules/gitea/npm-registry b/modules/gitea/npm-registry new file mode 100755 index 00000000..37c988b7 Binary files /dev/null and b/modules/gitea/npm-registry differ