gitea: say what keeps each npm version and delete only what nothing names (hq ADR 0251)

Every publish added a version and nothing removed one. A Go bundle beside the
TypeScript one keeps what a lockfile or a range on the forge names, what a
dist-tag names and the newest five; a dry run unless asked with a why, and
nothing deleted while any repository is unread.
This commit is contained in:
jochen
2026-10-08 12:00:48 +02:00
parent f7b1853b8b
commit a378abc758
13 changed files with 2118 additions and 0 deletions
+76
View File
@@ -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.
+258
View File
@@ -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)))
}
+130
View File
@@ -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
}
+241
View File
@@ -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:<name>@<version>`.
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 `<name>@<version>` or `<name>/<version>`. 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
}
@@ -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)
}
}
}
+425
View File
@@ -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)) }
@@ -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")
}
}
+362
View File
@@ -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 `<op> <version>`; 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
}
@@ -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")
}
}
+5
View File
@@ -0,0 +1,5 @@
module gitea
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+17
View File
@@ -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"
}
}
]
},
BIN
View File
Binary file not shown.