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:
@@ -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.
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module gitea
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user