Repository navigation
Import: copy an npm registry's packages (Nexus, Verdaccio, another Forge) into a hosted npm repository - #718
Merged
Merged
Conversation
An import of a hosted npm repository reads another npm registry (Nexus' /repository/<name>, Verdaccio, Artifactory, another Forge) through npm's own protocol. The npm crate says where to ask and reads the answers (forge_format_npm::import, on the packument reader npm proxies use); the server's import worker sends the requests and copies through the hosted write path. npm has no listing of every package. The preflight walks the registry's search with an empty text where it lists packages (another Forge), else its /-/all (Nexus 3.70's search finds nothing without a text and drops the scope of a scoped name; Verdaccio refuses an empty text), else refuses: the import then names packages or scopes (@acme). Each package's full packument gives every version's dist.integrity and dist.shasum, its publish time and deprecation, and the dist-tags. A tarball is downloaded from the import's URL, not from dist.tarball's host, checked against its SHA-512 (or dist.shasum alone), and must be the package and version the packument names (ImportOptions::expected). A version Forge holds with that SHA-512 is skipped without a download. Deprecations come along with their versions; dist-tags are items of their own, set after the versions. A tag or deprecation Forge holds otherwise follows on_conflict, unless an earlier import of the repository set it: then it follows the source, so a re-run moves latest. Migration 0008 lets an import's scan walk `all` and an item be a `tag`. The web UI offers the import on npm hosted repositories. Tested against a mock registry in the server's import tests, and in the e2e suite against a real Nexus 3.70.1 and Verdaccio 6.11.1, with npm install from Forge after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012e6DJbbDh51swmocfFvXPe
0008 is the device authorization grant's (#649, its own pull request). Both touch different tables and apply in either order; a distinct number keeps sqlx from seeing two version-8 migrations once both are merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012e6DJbbDh51swmocfFvXPe
schmitch
pushed a commit
that referenced
this pull request
Oct 31, 2026
#718 added npm to the same import framework this branch adds raw to. The merge keeps both formats: - FORMATS / IMPORT_FORMATS: nuget, npm, docker, raw. - Listing: npm's All beside raw's Assets and Directories (as_str, parse, their round-trip test). - Protocol (from #718) gets a Raw variant: a Nexus server plus repository resolves to <url>/repository/<name>, as for npm; the admin API and the worker pick raw's RawSource / Target::Files through it. - Found.kind: one field for component, tag (npm) and file (raw); npm's versions and dist-tags are built with raw's Found::component. - ItemSource: npm's integrity, shasum, tarball, deprecated and target beside raw's sha256, sha1, sidecar, content_type, error and missing. - copy(): kinds component, manifest, tag, file; copy_item: raw's Target::Files arm beside npm's dist-tag and deprecation arms, with raw's item labels in the failure messages; the expand_one method (#718) takes raw's named files. npm's verify/PackageSource and raw's verify_file both stay. - ImportView.listing: the one field both added; its values documented. - Import.tsx: the one format prop both introduced, with npm and raw wording side by side; both web tests kept. - Docs (import.md, nexus-import.md, README.md, docs/README.md) and the CI e2e pre-pulls (Verdaccio and nginx) carry both. Migration 0010 already lists 0009's values (all, tag) beside raw's (assets, directories, file), so it applies after 0009 unchanged. No query text changed: .sqlx keeps both sides' files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012e6DJc3No8xu6rxoMc3pFJ
schmitch
added a commit
that referenced
this pull request
Oct 31, 2026
main merged its own 0009 (#718), so the merge had two migrations of version 9 and every job that migrates a database failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012e6DJbxCkG53eCuDWqGGFy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #643
An import now copies an npm registry's packages into a hosted npm repository. Building on #642 (#712), it reads the source through npm's own protocol, so the source can be Nexus'
…/repository/<name>, Verdaccio, Artifactory or another Forge. The npm crate decides where to ask and how to read the answers: a newforge_format_npm::import, built on the packument reader npm proxies already use (upstream::read). The server's import worker sends the requests through the sharedUpstreamHttpclient and copies through the hosted write path.Source: an npm registry
Names. npm has no listing of every package. Tried against real registries, the preflight picks the first of these that lists packages, and stores its choice in
scan_listing:/-/v1/searchwith an emptytext, paged withfrom/size(100 a page). Another Forge lists every package this way. The walk ends at an empty page, and stops with an error if the search returns the same page again (it ignoresfrom)./-/all, read as a stream that keeps only the keys. Two registries need it:textfinds nothing (total: 0). With any text it names a scoped package without its scope (corefor@acme/core) and gives noscopefield./-/alllists every package by its full name.textis refused (400 ERR_TEXT_MISSING)./-/allis served.names.Named packages or scopes.
namestakes packages (left-pad,@acme/ui) and scopes (@acme,@acme/or@acme/*, stored as@acme). A scope's packages come from/-/allwhere the registry serves it, otherwise from the search for@acme. Only names in the scope are kept, and the next page starts after the last name returned.Missing packages. A named package or scope the registry does not have is an item that fails, saying so. So is a package the search or
/-/alllists but has no packument for. This makes Nexus' scope-less search names show up instead of being dropped silently.Versions. Each package's full packument (
GET /<name>, or@scope%2fname) is read as a stream. A newKeep::Releasekeeps onlyname,version,distanddeprecatedof every version (Select::every). From it the import takes:dist.integrity(its SHA-512) anddist.shasum;time;deprecatedmessage (an empty message is none);A full packument larger than
proxy.max_metadata_bytesis read abbreviated instead, without publish times. A packument with no versions counts as missing.Copy.
<url>/<name>/-/<file>, never taken fromdist.tarball's host.<file>is the last segment ofdist.tarballafter/-/, or npm's<name>-<version>.tgz.dist.integrity. For an old package listed without one, it is checked againstdist.integrity/dist.shasumwith the npm crate'sDigests::verify.ImportOptions::expected, set for npm only: the tarball'spackage.jsonmust be the package and version the packument names. Otherwise it is refused before anything is written.identicalwithout a download. A dry run decides from the checksums alone.timethroughImportOptions::published_at, as for NuGet.dist-tags and deprecations
Deprecations travel with their version item. They are applied to a version that is imported, replaced or identical (the dry run reports "would be deprecated: …").
Dist-tags are items of their own: kind
tag,namethe package,versionthe tag. They are copied after every version, once the version they name is in Forge. A tag whose version failed fails with "version X is not in Forge".Conflicts. A dist-tag Forge points at another version, or a deprecation Forge gives otherwise, follows
on_conflict:skip: aconflictitem with the reason;overwrite: the source's value;fail: the import fails.Refinement of the issue's rule, a judgment call to review: where Forge holds exactly what an earlier, non-dry-run import of the same repository set, the import follows the source without a conflict. The new
import_dao::earlier_sourcereads the newest earlier import's item for the same kind, name and version that endedimported,replacedoridentical. Without this, a cutover re-run withskipwould report everylatestthe source moved as a conflict and leave Forge'slateststale. A tag or deprecation changed in Forge since then is still a conflict.Schema
Migration
0009_npm_import.sql:repository_import.scan_listingalso takesall;repository_import_item.kindalso takestag.Nothing else changes, and no upgrade note is needed.
New
.sqlxentries:component_dao::deprecation,import_dao::earlier_source, and two test queries.API / UI
POST /admin/repositories/{name}/imports:FORMATSis nownuget, npm, docker);names;urla Nexus server +repository), reads<url>/repository/<name>for npm.GET /-/ping: a refusal of the credentials or a 5xx is an error, and a registry that does not serve the ping is let through;source_servercomes from the ping'sServerheader;source_totalis the search's total, or how many names/-/alllists.listingfield (search,catalog,all,names).IMPORT_FORMATSnow includes npm, so npm hosted repositories link to the import screen;<package> dist-tag <tag>.Docs
docs/import.md:url/names/on_conflictfor npm;/-/all, with what Nexus and Verdaccio answer), the scan, and the tarball copy (import URL, integrity/shasum, identity);docs/nexus-import.md: where Nexus serves npm, its search's behaviour,/-/all, anddist.tarballnaming the configured Base URL.docs/npm.md: a new section "Importing another registry".docs/changes/2.2.0/643.feature.md.mirror.gcr.io/verdaccio/verdaccio:6.11.1.Tests
crates/server/tests/import.rs. A mock npm registry joins the existing mock source (axum, Basic auth,Server: Nexus/…). Its search mode (lists everything like Forge / finds nothing and drops scopes like Nexus / refuses an empty text like Verdaccio) and/-/allcan be switched. Its packuments name tarballs on an unreachable host (npm.internal.invalid). 4 new tests:npm_import_keeps_bytes_times_tags_and_deprecations_and_a_second_run_copies_only_what_is_new:time→ creation time, integrity, deprecation and dist-tags in Forge's packument;latestis moved (replaced, "moved from 1.1.0 to 1.2.0"), and a deprecation added at the source arrives;skip, and take the source's withoverwrite.npm_import_walks_all_where_the_search_lists_nothing_and_checks_every_tarball:allwith scoped packages;dist.shasum, and the tag of a failed version fails;npm_preflight_names_scopes_conflicts_and_the_dry_run:overwrite.npm_import_names_a_nexus_repository_in_the_previous_request_form: the previous request form, and a tarball whosepackage.jsonnames another version is refused ("the package is beta 9.9.9, not beta 1.0.0").crates/formats/npm/src/import.rs: URLs, scopes, search names with and withoutscope,/-/allkeys, and the packument read into releases (file names, SHA-512, shasum, deprecated, tags of versions it has);crates/server/src/import/mod.rs:Protocol,nexus_source_urlfor npm,Listing::All, and how an item stores its source.crates/e2e/tests/import.rs), run locally against real containers. Both npm tests seed the registry over HTTP the way npm does it: publish,dist-tag add, anddeprecate(a packument PUT).npm_import_from_a_real_nexus(mirror.gcr.io/sonatype/nexus3:3.70.1):alpha1.0.0 (deprecated), 1.1.0 and 2.0.0-beta.1 (next),@acme/ui1.0.0 (stable) and 1.0.1, and@acme/core0.1.0;/-/all;npm install alpha @acme/ui@stable @acme/corefrom Forge succeeds;alpha1.2.0, a second run gives 1 imported, 1 replaced (latest) and 10 identical, withbytes_copiedequal to the new tarball's size;npm install alpha@nextgives 2.0.0-beta.1.npm_import_from_verdaccio(mirror.gcr.io/verdaccio/verdaccio:6.11.1):@acme/*is readable only by authenticated users, so the import has to send its credentials;/-/alland imports 11 items, with the same checks andnpm install;@acmeimport comes out allidenticalwith 0 bytes copied.npm install: runs whennpmis on the PATH and prints that it was skipped otherwise, like the dotnet smoke test. It ran here (node 22.23.4, npm 10.9.9).import_from_a_real_nexus.Environment: a shared build machine, with per-package profile overrides that keep this branch's artifacts apart from other checkouts in the shared target dir,
SQLX_OFFLINE=true(except prepare),CARGO_PROFILE_{DEV,TEST}_DEBUG=line-tables-onlyandCARGO_INCREMENTAL=0.cargo test -p forge-format-npm: 61 + 15 passed.cargo test -p forge-server --lib: 253 passed.cargo test -p forge-server --test import: 16 passed (the 12 existing and the 4 new).cargo test -p forge-server --test npm: 16 passed (the proxy reads the packument reader that changed).cargo test -p forge-db --test migrations: 3 passed (including the 0006 → 0007 rename test, now migrating on through 0009). Re-run after the renumber: 3 passed.cargo test -p forge-e2e --features forge-e2e --test import npm_import -- --nocapture: 2 passed, on the final code.cargo test -p forge-e2e --features forge-e2e --test import: all 5 passed:import_from_a_real_nexus(NuGet, and Docker with 105 images);import_from_a_distribution_registry;import_from_another_forge;This run was on the code before
cargo fmt, the Verdaccio tag pin (6→6.11.1, the same image ID) and comment-only edits, with the same codegen override but incremental compilation on.cargo clippy -p forge-format-npm -p forge-core -p forge-db -p forge-server -p forge-e2e --all-targets --features forge-e2e/forge-e2e -- -D warnings: clean, also re-run after the last comment edits.cargo fmt --all -- --check: clean.cargo xtask changelog --check: "32 changelog fragment(s) … all for milestones after 2.1.0".cargo xtask docs --check: "16 page(s) of the site, every link resolves".cargo sqlx prepare --workspace -- --all-targets --all-featuresagainst a Postgres 18 migrated through this migration: 4 new.sqlxfiles, none removed.Web:
pnpm install --frozen-lockfile && pnpm run tsc(clean),pnpm run test(40 files, 535 tests passed, including the new "offers the import on an npm hosted repository, with packages and scopes"), andpnpm run build(ok).Notes
Migration number:
0009, because Auth: device authorization grant (RFC 8628) with a /device approval page #717 (the device authorization grant, Device authorization grant (RFC 8628): device and token endpoints and a /device approval page #649) takes0008. This migration only replaces two CHECK constraints, and the two apply in either order:0001–0009, with Auth: device authorization grant (RFC 8628) with a /device approval page #717's0008, apply cleanly in sequence on a fresh Postgres 18. If this merges before Auth: device authorization grant (RFC 8628) with a /device approval page #717,mainhas a gap at0008until Auth: device authorization grant (RFC 8628) with a /device approval page #717 lands; a database migrated in between then applies0008after0009, which these two independent migrations allow.Why not the generic upstream conversation (
Format::upstream)? That is how NuGet is read in Import through the format's own protocol (a NuGet feed, a registry's catalog) instead of Nexus' REST API, named neutrally #642, but for npm:Versions,Dating,Tags, thenPackageonce per version, each re-reading the packument);VersionMetadatacarries no deprecation;/-/all.So the npm crate gets a pure module of its own (URLs and parsing). The server sends one full packument request per package.
UpstreamOp::Packagesfor npm still answersSearch(None), with an updated comment.What is stored as a version's manifest: the tarball's
package.jsonwith the computeddist, as when a proxy downloads a tarball. The fields only the source's version object carries (_npmUser,gitHead,maintainers) are not copied. The publish time, deprecation and dist-tags are.Not covered:
_authTokenwithout a username);proxy.max_metadata_bytes(no test);Generated by Claude Code