From d8e2446262cfb5b38fee65975bb0dcbf6d8137b8 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 16 Oct 2026 23:08:20 +1100 Subject: [PATCH] Add local npm registry publish and install --- README.md | 15 ++ e2e-docker/README.md | 4 +- e2e-docker/npm_local_test.go | 62 +++++ internal/api/v1/npm.go | 286 ++++++++++++++++++++++++ internal/api/v1/npm_test.go | 162 ++++++++++++++ internal/api/v1/proxy.go | 9 + ui/src/components/UsageInstructions.tsx | 21 ++ 7 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 e2e-docker/npm_local_test.go create mode 100644 internal/api/v1/npm.go create mode 100644 internal/api/v1/npm_test.go diff --git a/README.md b/README.md index 660a658..1cecd76 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,20 @@ content-addressable store (deduplicated by digest, reaped by GC once unreferenced); tags are mutable references and re-pushing a tag moves it. Blob uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows. +### Local npm registry + +A local `npm` repo is a real npm registry: `npm publish` (scoped and unscoped) +and `npm install` work against `/api/v1/local/{name}/`. npm requires an +`_authToken` for the registry before it will publish; any value works. + +```ini +# .npmrc +registry=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/npm-internal/ +//artifactapi.k8s.syd1.au.unkin.net/api/v1/local/npm-internal/:_authToken=unused +``` + +Versions are immutable; dist-tags sent with a publish move to that version. + ## Access Control | Field | Default | Behaviour | @@ -297,6 +311,7 @@ No patterns + no blocklist = open proxy. Provider handles mutability classificat ``` GET /api/v1/remote/{name}/{path} Proxy/cache artifact GET /api/v1/virtual/{name}/{path} Virtual repo (merged index) +PUT /api/v1/local/{name}/{package} npm publish (local npm repos) GET /v2/{name}/{path} Docker Registry v2 ``` diff --git a/e2e-docker/README.md b/e2e-docker/README.md index 3125d23..3694888 100644 --- a/e2e-docker/README.md +++ b/e2e-docker/README.md @@ -27,7 +27,9 @@ already-running stack. mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes verified against the origin fixture. - **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/` - index), rpm (real package + **automatic repodata** generation). + index), rpm (real package + **automatic repodata** generation), npm (stock + `node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`, + second version, republish rejected). - **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge across two members. - **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors diff --git a/e2e-docker/npm_local_test.go b/e2e-docker/npm_local_test.go new file mode 100644 index 0000000..9455611 --- /dev/null +++ b/e2e-docker/npm_local_test.go @@ -0,0 +1,62 @@ +//go:build dockere2e + +package e2edocker + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// TestLocalNPMPublishInstall drives a stock npm client against a local npm +// repo: publish unscoped and scoped packages, install and require() them in a +// fresh project, publish a second version, and reject a republish. +func TestLocalNPMPublishInstall(t *testing.T) { + network := os.Getenv("COMPOSE_NETWORK") + internal := os.Getenv("ARTIFACTAPI_INTERNAL") + if network == "" || internal == "" { + t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available on the test host") + } + + name := fmt.Sprintf("e2e-npm-local-%d", time.Now().UnixNano()) + createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","repo_type":"local"}`, name)) + defer deleteRepo(t, name) + + reg := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/" + script := `set -euo pipefail +printf 'registry=%s\n%s:_authToken=unused\n' "$REG" "${REG#http:}" > ~/.npmrc +mk() { d="/w/src/$1-$2"; mkdir -p "$d"; cd "$d" + printf '{"name":"%s","version":"%s","main":"index.js"}' "$1" "$2" > package.json + echo "module.exports = '$1@$2';" > index.js; } +mk e2e-npm-plain 1.0.0 && npm publish +mk @e2e/npm-scoped 1.0.0 && npm publish +mk e2e-npm-plain 1.0.0 +if npm publish 2>/dev/null; then echo "republish accepted"; exit 1; fi +mkdir -p /w/app && cd /w/app && npm init -y >/dev/null +npm install --no-audit --cache /tmp/fresh-cache e2e-npm-plain @e2e/npm-scoped +node -e "console.log('REQ', require('e2e-npm-plain'), require('@e2e/npm-scoped'))" +mk e2e-npm-plain 1.1.0 && npm publish +echo "VERSIONS $(npm view e2e-npm-plain versions --json | tr -d ' \n')" +echo "LATEST $(npm view e2e-npm-plain dist-tags.latest)"` + + out, err := exec.Command("docker", "run", "--rm", "--network", network, + "-e", "REG="+reg, "node:22", "bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("npm client run failed: %v\n%s", err, out) + } + for _, want := range []string{ + "REQ e2e-npm-plain@1.0.0 @e2e/npm-scoped@1.0.0", + `VERSIONS ["1.0.0","1.1.0"]`, + "LATEST 1.1.0", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("missing %q in npm output:\n%s", want, out) + } + } +} diff --git a/internal/api/v1/npm.go b/internal/api/v1/npm.go new file mode 100644 index 0000000..253d245 --- /dev/null +++ b/internal/api/v1/npm.go @@ -0,0 +1,286 @@ +package v1 + +import ( + "bytes" + "context" + "crypto/sha1" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/storage" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +// Local npm repos speak the npm registry publish/read protocol. Per package: +// /-/-.tgz tarball (immutable) +// /-/-.json version manifest (immutable) +// /-/dist-tags/ version string (mutable) + +var ( + npmNameRe = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$`) + npmRefRe = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+_-]*$`) +) + +func (h *ProxyHandler) localNPMRemote(r *http.Request, name string) (*models.Remote, bool) { + remote, err := h.db.GetRemote(r.Context(), name) + if err != nil { + return nil, false + } + return remote, remote.RepoType == models.RepoTypeLocal && remote.PackageType == models.PackageNPM +} + +// npmPackageName returns the package a packument/publish path addresses, or "" +// when the path is something else (e.g. a tarball under /-/). +func npmPackageName(raw string) string { + p, err := url.PathUnescape(raw) + if err != nil { + return "" + } + p = strings.TrimSuffix(p, "/") + if !npmNameRe.MatchString(p) { + return "" + } + return p +} + +type npmRelease struct { + version string + manifest []byte + tarball []byte + tags []string +} + +func parseNPMPublish(body io.Reader, pkg string) (*npmRelease, error) { + var doc struct { + Name string `json:"name"` + DistTags map[string]string `json:"dist-tags"` + Versions map[string]json.RawMessage `json:"versions"` + Attachments map[string]struct { + Data string `json:"data"` + } `json:"_attachments"` + } + if err := json.NewDecoder(body).Decode(&doc); err != nil { + return nil, fmt.Errorf("invalid publish body: %w", err) + } + if doc.Name != pkg { + return nil, fmt.Errorf("body name %q does not match %q", doc.Name, pkg) + } + if len(doc.Versions) != 1 || len(doc.Attachments) != 1 { + return nil, errors.New("publish must carry exactly one version and one tarball") + } + + rel := &npmRelease{} + for v, m := range doc.Versions { + rel.version, rel.manifest = v, m + } + if !npmRefRe.MatchString(rel.version) { + return nil, fmt.Errorf("invalid version %q", rel.version) + } + var meta struct { + Name string `json:"name"` + Version string `json:"version"` + Dist struct { + Integrity string `json:"integrity"` + Shasum string `json:"shasum"` + } `json:"dist"` + } + if err := json.Unmarshal(rel.manifest, &meta); err != nil { + return nil, fmt.Errorf("invalid version manifest: %w", err) + } + if meta.Name != pkg || meta.Version != rel.version { + return nil, errors.New("version manifest name/version mismatch") + } + + for _, a := range doc.Attachments { + tgz, err := base64.StdEncoding.DecodeString(a.Data) + if err != nil { + return nil, fmt.Errorf("invalid tarball encoding: %w", err) + } + rel.tarball = tgz + } + if err := verifyNPMDist(rel.tarball, meta.Dist.Integrity, meta.Dist.Shasum); err != nil { + return nil, err + } + + for tag, v := range doc.DistTags { + if !npmRefRe.MatchString(tag) || v != rel.version { + return nil, fmt.Errorf("dist-tag %q must point at the published version", tag) + } + rel.tags = append(rel.tags, tag) + } + return rel, nil +} + +func verifyNPMDist(tgz []byte, integrity, shasum string) error { + if b64, ok := strings.CutPrefix(integrity, "sha512-"); ok { + sum := sha512.Sum512(tgz) + if base64.StdEncoding.EncodeToString(sum[:]) != b64 { + return errors.New("tarball does not match dist.integrity") + } + return nil + } + if shasum != "" { + sum := sha1.Sum(tgz) + if hex.EncodeToString(sum[:]) != shasum { + return errors.New("tarball does not match dist.shasum") + } + return nil + } + return errors.New("version manifest carries no sha512 integrity or shasum") +} + +func npmError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func (h *ProxyHandler) handleLocalPut(w http.ResponseWriter, r *http.Request) { + remote, ok := h.localNPMRemote(r, chi.URLParam(r, "localName")) + if !ok { + npmError(w, http.StatusMethodNotAllowed, "publish is only supported on local npm repositories") + return + } + pkg := npmPackageName(chi.URLParam(r, "*")) + if pkg == "" { + npmError(w, http.StatusBadRequest, "invalid package name") + return + } + rel, err := parseNPMPublish(r.Body, pkg) + if err != nil { + npmError(w, http.StatusBadRequest, err.Error()) + return + } + + base := pkg[strings.LastIndex(pkg, "/")+1:] + stem := pkg + "/-/" + base + "-" + rel.version + // The tarball's immutable row is the version's uniqueness gate. + if err := h.npmStore(r.Context(), remote.Name, stem+".tgz", rel.tarball, "application/octet-stream", false); err != nil { + if errors.Is(err, database.ErrAlreadyExists) { + npmError(w, http.StatusConflict, fmt.Sprintf("%s@%s already exists; overwrites are not allowed", pkg, rel.version)) + return + } + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := h.npmStore(r.Context(), remote.Name, stem+".json", rel.manifest, "application/json", false); err != nil { + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + for _, tag := range rel.tags { + if err := h.npmStore(r.Context(), remote.Name, pkg+"/-/dist-tags/"+tag, []byte(rel.version), "text/plain", true); err != nil { + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + } + + slog.Info("local npm package published", "repo", remote.Name, "package", pkg, "version", rel.version) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": pkg}) +} + +func (h *ProxyHandler) npmStore(ctx context.Context, repo, path string, data []byte, contentType string, mutable bool) error { + result, err := h.cas.Store(ctx, bytes.NewReader(data), contentType) + if err != nil { + return err + } + if err := h.db.UpsertBlob(ctx, result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil { + return err + } + if mutable { + return h.db.UpsertLocalFile(ctx, repo, path, result.ContentHash) + } + return h.db.CreateLocalFile(ctx, repo, path, result.ContentHash) +} + +func (h *ProxyHandler) readLocalBlob(ctx context.Context, contentHash string) ([]byte, error) { + reader, _, err := h.store.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:"))) + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) +} + +func (h *ProxyHandler) serveNPMPackument(w http.ResponseWriter, r *http.Request, remote *models.Remote, pkg string) { + files, err := h.db.ListLocalFilesByPrefix(r.Context(), remote.Name, pkg+"/-/") + if err != nil { + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + read := func(hash string) ([]byte, error) { return h.readLocalBlob(r.Context(), hash) } + tarballBase := fmt.Sprintf("%s://%s/api/v1/local/%s/", scheme(r), r.Host, remote.Name) + doc, err := buildNPMPackument(pkg, files, read, tarballBase) + if err != nil { + npmError(w, http.StatusInternalServerError, err.Error()) + return + } + if doc == nil { + npmError(w, http.StatusNotFound, "not found") + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Artifact-Source", "local") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(doc) +} + +// buildNPMPackument returns nil when pkg has no published versions. +func buildNPMPackument(pkg string, files []database.LocalFile, read func(hash string) ([]byte, error), tarballBase string) (map[string]any, error) { + prefix := pkg + "/-/" + versions := map[string]any{} + tags := map[string]string{} + for _, f := range files { + // LIKE treats '_' as a wildcard, so the prefix match can over-select. + rest, ok := strings.CutPrefix(f.FilePath, prefix) + if !ok { + continue + } + if tag, isTag := strings.CutPrefix(rest, "dist-tags/"); isTag { + v, err := read(f.ContentHash) + if err != nil { + return nil, err + } + tags[tag] = string(v) + continue + } + stem, isManifest := strings.CutSuffix(rest, ".json") + if !isManifest { + continue + } + raw, err := read(f.ContentHash) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("manifest %s: %w", f.FilePath, err) + } + dist, _ := m["dist"].(map[string]any) + if dist == nil { + dist = map[string]any{} + m["dist"] = dist + } + dist["tarball"] = tarballBase + prefix + stem + ".tgz" + v, _ := m["version"].(string) + versions[v] = m + } + if len(versions) == 0 { + return nil, nil + } + return map[string]any{"_id": pkg, "name": pkg, "dist-tags": tags, "versions": versions}, nil +} diff --git a/internal/api/v1/npm_test.go b/internal/api/v1/npm_test.go new file mode 100644 index 0000000..d1af157 --- /dev/null +++ b/internal/api/v1/npm_test.go @@ -0,0 +1,162 @@ +package v1 + +import ( + "crypto/sha1" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/database" +) + +func TestNPMPackageName(t *testing.T) { + tests := map[string]string{ + "left-pad": "left-pad", + "left-pad/": "left-pad", + "@acme%2fwidget": "@acme/widget", + "@acme%2Fwidget": "@acme/widget", + "@acme/widget": "@acme/widget", + "left-pad/-/left-pad.tgz": "", + "@acme/widget/-/w-1.tgz": "", + "a/b": "", + "../etc": "", + "Upper": "", + "": "", + } + for in, want := range tests { + if got := npmPackageName(in); got != want { + t.Errorf("npmPackageName(%q) = %q, want %q", in, got, want) + } + } +} + +type publishOpts struct { + bodyName, manifestName, version, integrity, shasum string + tags map[string]string + extraVersion bool +} + +func publishBody(t *testing.T, pkg string, tgz []byte, o publishOpts) string { + t.Helper() + sha := sha512.Sum512(tgz) + sh1 := sha1.Sum(tgz) + if o.bodyName == "" { + o.bodyName = pkg + } + if o.manifestName == "" { + o.manifestName = pkg + } + if o.version == "" { + o.version = "1.0.0" + } + if o.integrity == "" { + o.integrity = "sha512-" + base64.StdEncoding.EncodeToString(sha[:]) + } + if o.shasum == "" { + o.shasum = hex.EncodeToString(sh1[:]) + } + if o.tags == nil { + o.tags = map[string]string{"latest": o.version} + } + versions := map[string]any{o.version: map[string]any{ + "name": o.manifestName, "version": o.version, "main": "index.js", + "dist": map[string]any{"integrity": o.integrity, "shasum": o.shasum, "tarball": "http://elsewhere/x.tgz"}, + }} + if o.extraVersion { + versions["2.0.0"] = map[string]any{"name": pkg, "version": "2.0.0"} + } + b, _ := json.Marshal(map[string]any{ + "_id": pkg, "name": o.bodyName, "dist-tags": o.tags, "versions": versions, + "_attachments": map[string]any{pkg + "-" + o.version + ".tgz": map[string]any{ + "content_type": "application/octet-stream", "data": base64.StdEncoding.EncodeToString(tgz), "length": len(tgz), + }}, + }) + return string(b) +} + +func TestParseNPMPublish(t *testing.T) { + tgz := []byte("fake tarball bytes") + + rel, err := parseNPMPublish(strings.NewReader(publishBody(t, "@acme/widget", tgz, publishOpts{tags: map[string]string{"latest": "1.0.0", "beta": "1.0.0"}})), "@acme/widget") + if err != nil { + t.Fatalf("valid scoped publish rejected: %v", err) + } + if rel.version != "1.0.0" || string(rel.tarball) != string(tgz) || len(rel.tags) != 2 { + t.Fatalf("unexpected release: %+v", rel) + } + + if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none", shasum: hex.EncodeToString(make([]byte, 20))})), "left-pad"); err == nil { + t.Fatal("shasum fallback should reject a mismatched tarball") + } + if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none"})), "left-pad"); err != nil { + t.Fatalf("matching shasum fallback rejected: %v", err) + } + + bad := map[string]publishOpts{ + "body name mismatch": {bodyName: "other"}, + "manifest name mismatch": {manifestName: "other"}, + "integrity mismatch": {integrity: "sha512-AAAA"}, + "path-like version": {version: "../1.0.0"}, + "tag at another version": {tags: map[string]string{"latest": "9.9.9"}}, + "path-like tag": {tags: map[string]string{"../x": "1.0.0"}}, + "multiple versions at once": {extraVersion: true}, + } + for name, o := range bad { + if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, o)), "left-pad"); err == nil { + t.Errorf("%s: expected rejection", name) + } + } + if _, err := parseNPMPublish(strings.NewReader(`{"name":"left-pad","versions":{}}`), "left-pad"); err == nil { + t.Error("deprecate-style body without attachments should be rejected") + } +} + +func TestBuildNPMPackument(t *testing.T) { + blobs := map[string]string{ + "h1": `{"name":"my_pkg","version":"1.0.0","dist":{"shasum":"aa","tarball":"http://elsewhere/x.tgz"}}`, + "h2": `{"name":"my_pkg","version":"1.1.0"}`, + "h3": "1.1.0", + "hx": `{"name":"myXpkg","version":"6.6.6"}`, + } + read := func(h string) ([]byte, error) { + b, ok := blobs[h] + if !ok { + return nil, errors.New("missing blob") + } + return []byte(b), nil + } + files := []database.LocalFile{ + {FilePath: "my_pkg/-/dist-tags/latest", ContentHash: "h3"}, + {FilePath: "my_pkg/-/my_pkg-1.0.0.json", ContentHash: "h1"}, + {FilePath: "my_pkg/-/my_pkg-1.0.0.tgz", ContentHash: "nope"}, + {FilePath: "my_pkg/-/my_pkg-1.1.0.json", ContentHash: "h2"}, + {FilePath: "myXpkg/-/myXpkg-6.6.6.json", ContentHash: "hx"}, + } + + doc, err := buildNPMPackument("my_pkg", files, read, "http://reg/api/v1/local/npm/") + if err != nil { + t.Fatal(err) + } + if tags := doc["dist-tags"].(map[string]string); tags["latest"] != "1.1.0" { + t.Errorf("dist-tags = %v", tags) + } + versions := doc["versions"].(map[string]any) + if len(versions) != 2 { + t.Fatalf("versions = %v (LIKE over-match must be dropped)", versions) + } + v1 := versions["1.0.0"].(map[string]any)["dist"].(map[string]any) + if v1["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.0.0.tgz" || v1["shasum"] != "aa" { + t.Errorf("1.0.0 dist = %v", v1) + } + if versions["1.1.0"].(map[string]any)["dist"].(map[string]any)["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.1.0.tgz" { + t.Error("1.1.0 missing rewritten tarball") + } + + if doc, err := buildNPMPackument("absent", files, read, "x/"); err != nil || doc != nil { + t.Errorf("absent package: doc=%v err=%v", doc, err) + } +} diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index e77f43e..4378bd7 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -15,6 +15,7 @@ import ( "git.unkin.net/unkin/artifactapi/internal/proxy" "git.unkin.net/unkin/artifactapi/internal/storage" "git.unkin.net/unkin/artifactapi/internal/virtual" + "git.unkin.net/unkin/artifactapi/pkg/models" ) type ProxyHandler struct { @@ -41,6 +42,7 @@ func (h *ProxyHandler) Routes() chi.Router { r := chi.NewRouter() r.Get("/remote/{remoteName}/*", h.handleProxy) r.Get("/local/{localName}/*", h.handleLocal) + r.Put("/local/{localName}/*", h.handleLocalPut) r.Get("/virtual/{virtualName}/*", h.handleVirtual) return r } @@ -199,6 +201,13 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) { return } + if remote.PackageType == models.PackageNPM { + if pkg := npmPackageName(path); pkg != "" { + h.serveNPMPackument(w, r, remote, pkg) + return + } + } + prov, _ := provider.Get(remote.PackageType) if indexer, ok := prov.(provider.LocalIndexer); ok { if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) { diff --git a/ui/src/components/UsageInstructions.tsx b/ui/src/components/UsageInstructions.tsx index 228a924..90c669a 100644 --- a/ui/src/components/UsageInstructions.tsx +++ b/ui/src/components/UsageInstructions.tsx @@ -101,6 +101,27 @@ pip install `, ]; case 'npm': + if (isLocal) { + const reg = `${url}/api/v1/local/${name}/`; + return [ + { + title: 'Configure .npmrc (registry + publish token)', + language: 'bash', + code: `cat >> .npmrc <<'EOF' +registry=${reg} +//${host}/api/v1/local/${name}/:_authToken=unused +EOF`, + note: 'npm refuses to publish without an _authToken for the registry; any value works. For a scope only, use @:registry=… instead of registry=.', + }, + { + title: 'Publish and install', + language: 'bash', + code: `npm publish +npm install `, + note: 'Versions are immutable: republishing an existing version is rejected. The dist-tags sent with the publish (latest, or --tag) move to the new version.', + }, + ]; + } return [ { title: 'Point npm at this registry',