From ac01c07fcd7359f654c1698183e8baf2e6d1200f Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:08:49 -0700 Subject: [PATCH 1/3] Two verbs, and registry routing as API primitives install takes specs; install-manifest takes one JSON file or - for stdin and reads its dependencies map. --from is gone: one verb that took either specs or a file overloaded the argument shape. Microbe::scoped_registry and Microbe::auth set what an @scope:registry key and a credential key set; the .npmrc reader is a convenience over them. --- CHANGELOG.md | 4 ++-- README.md | 6 ++++-- src/lib.rs | 20 +++++++++++++++++++ src/main.rs | 50 +++++++++++++++++++++++++++--------------------- src/npmrc.rs | 11 +++++++++++ tests/install.rs | 43 ++++++++++++++++++++++++++++------------- 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9397a24..ff7dbab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ First published version. - **Optional dependencies** are dropped as a branch when anything under them fails; a required package that fails ends the install with that package's error. - **Integrity** is checked against `dist.integrity`, falling back to `dist.shasum`; a tarball entry that would escape its package directory is refused. - **Bins** of every top-level package are linked under `node_modules/.bin`; requested packages win a name clash. -- **The `.npmrc` subset** an installer needs, applied from an explicit path only: `registry`, `@scope:registry`, and `_authToken`, `_auth`, `username` with `_password` keyed by URL prefix. `${VAR}` is an error, not expanded. +- **Registry routing and credentials** set directly with `scoped_registry` and `auth`, and **the `.npmrc` subset** an installer needs on top of them, applied from an explicit path only: `registry`, `@scope:registry`, and `_authToken`, `_auth`, `username` with `_password` keyed by URL prefix. `${VAR}` is an error, not expanded. - **Transport**: platform TLS on macOS and Windows through the OS root store; on Linux the first of `node`, `curl`, `wget`, `python3` on the host, or rustls with `--features tls`; every host client refuses a redirect off HTTPS. Requests time out after 300 s and are retried twice on a transport failure or a 429 / 5xx. -- **CLI** `microbe install [...] [--from ] --dir [--registry ] [--npmrc ]`. Everything explicit: no environment variables, no filesystem walking. +- **CLI** `microbe install ... --dir ` and `microbe install-manifest --dir `, both with `[--registry ] [--npmrc ]`. Everything explicit: no environment variables, no filesystem walking. - **Node-API addon** `@nubjs/microbe` in `napi/`: `install` and `installSync` taking a spec list or a `dependencies` object, with platform packages for eight targets built by the `napi` workflow. - **Size**: 702 KB on Linux, 800 KB on Windows, 853 KB on macOS, stripped; CI fails a default build at 1 MB. diff --git a/README.md b/README.md index 3a79cc8..28d113c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ let esbuild = &done.bins["esbuild"]; // /tmp/tools/node_modules/.bin/esbuild -> ``` microbe install ... --dir [--registry ] [--npmrc ] -microbe install --from package.json --dir /tmp/tools # its `dependencies` map; other keys are ignored +microbe install-manifest package.json --dir /tmp/tools # its `dependencies` map; other keys are ignored ``` ## From Node @@ -42,6 +42,8 @@ use microbe::{Microbe, Transport}; let m = Microbe::new()? // in-binary TLS, or the first HTTPS client on the host .registry("https://registry.example.com") // default is registry.npmjs.org .npmrc(Path::new("/etc/tool/.npmrc"))? // explicit path only; nothing is discovered + .scoped_registry("@acme", "https://npm.acme.dev/") // what an `@acme:registry` key does + .auth("https://npm.acme.dev/", "Bearer tok") // what a `//npm.acme.dev/:_authToken` key does .concurrency(8); // parallel fetches; default 16 // One package by spec: `name`, `name@tag`, `name@1.2.3`, `name@^1`, `@scope/name@^1`. @@ -70,7 +72,7 @@ Microbe is an embedder-facing tool, not a human CLI, so it never guesses. The ta ## Registry and credentials -An `.npmrc` is applied only from an explicit path (`Microbe::npmrc`, or `--npmrc `), or from contents the embedder already holds (`Microbe::npmrc_contents`). Four keys are read: +A registry for a scope and a credential for a URL prefix are set directly with `Microbe::scoped_registry` and `Microbe::auth`. An `.npmrc` is a convenience over those two: it is applied only from an explicit path (`Microbe::npmrc`, or `--npmrc `), or from contents the embedder already holds (`Microbe::npmrc_contents`), and four keys are read: ```ini registry=https://registry.example.com diff --git a/src/lib.rs b/src/lib.rs index 13f7591..4c8ad26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,6 +115,26 @@ impl Microbe { self } + /// Route packages under `@scope` to `url`, as an `@scope:registry` key does. + pub fn scoped_registry(mut self, scope: &str, url: &str) -> Self { + let scope = if scope.starts_with('@') { + scope.to_string() + } else { + format!("@{scope}") + }; + self.scoped + .insert(scope, url.trim_end_matches('/').to_string()); + self + } + + /// Send `authorization: ` with every request whose URL starts with `prefix`, + /// given as a URL (`https://npm.acme.dev/`) or in npm's `//npm.acme.dev/` form. The + /// longest matching prefix wins. The `.npmrc` credential keys build on this. + pub fn auth(mut self, prefix: &str, value: &str) -> Self { + self.auth.push((npmrc::prefix(prefix), value.to_string())); + self + } + /// Apply an `.npmrc` at an EXPLICIT path: `registry`, `@scope:registry`, and credentials /// (`_authToken`, `_auth`, `username` with `_password`) keyed by URL prefix, as npm keys /// them. Nothing is discovered, and `${VAR}` is not expanded — resolve it and use diff --git a/src/main.rs b/src/main.rs index 866d1af..6b3b323 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,25 @@ -//! `microbe install [...] [--from ] --dir [--registry ] [--npmrc ]` -//! installs into `/node_modules` and prints what landed. Everything is explicit: the -//! target directory is required, nothing is read from the environment, and no file is -//! discovered by walking the filesystem — the embedder decides where configuration comes -//! from. This is an embedder-facing tool, not a human CLI. Specs name packages directly; `--from` reads a JSON file (or stdin with `-`) and -//! takes its `dependencies` map — the `package.json#/dependencies` shape — so a whole -//! `package.json` is a valid input and its other keys are ignored. The library is the -//! product; this binary exists to measure it and to try it from a shell. +//! Two verbs, both installing into `/node_modules` and printing what landed: +//! +//! - `microbe install ... --dir ` names packages directly. +//! - `microbe install-manifest --dir ` takes the `dependencies` map of a JSON +//! file, or of stdin for `-`: the `package.json#/dependencies` shape, so a whole +//! `package.json` is valid input and every other key is ignored. +//! +//! Everything is explicit: the target directory is required, nothing is read from the +//! environment, and no file is discovered by walking the filesystem — the embedder decides +//! where configuration comes from. This is an embedder-facing tool, not a human CLI. The +//! library is the product; this binary exists to measure it and to try it from a shell. use std::path::Path; use std::process::ExitCode; -const USAGE: &str = "usage: microbe install [...] [--from ] --dir [--registry ] [--npmrc ]"; +const USAGE: &str = + "usage: microbe install ... --dir [--registry ] [--npmrc ] + microbe install-manifest --dir [--registry ] [--npmrc ]"; fn main() -> ExitCode { let mut args = std::env::args().skip(1); - let mut specs = Vec::new(); - let mut from = None; + let mut positionals = Vec::new(); let mut dir = None; let mut registry = None; let mut npmrc = None; @@ -24,21 +28,27 @@ fn main() -> ExitCode { match a.as_str() { "--registry" => registry = args.next(), "--dir" => dir = args.next(), - "--from" => from = args.next(), "--npmrc" => npmrc = args.next(), _ if verb.is_none() => verb = Some(a), - _ => specs.push(a), + _ => positionals.push(a), } } - let (Some("install"), Some(dir)) = (verb.as_deref(), dir) else { + let Some(dir) = dir else { eprintln!("{USAGE}"); return ExitCode::from(2); }; - if specs.is_empty() && from.is_none() { - eprintln!("{USAGE}"); - return ExitCode::from(2); - } + let deps = match (verb.as_deref(), positionals.as_slice()) { + (Some("install"), specs) if !specs.is_empty() => { + Ok(specs.iter().map(|s| split(s)).collect::>()) + } + (Some("install-manifest"), [source]) => read_manifest(source), + _ => { + eprintln!("{USAGE}"); + return ExitCode::from(2); + } + }; let run = || -> Result { + let deps = deps?; let mut m = microbe::Microbe::new()?; if let Some(path) = &npmrc { m = m.npmrc(Path::new(path))?; @@ -46,10 +56,6 @@ fn main() -> ExitCode { if let Some(r) = ®istry { m = m.registry(r); } - let mut deps: Vec<(String, String)> = specs.iter().map(|s| split(s)).collect(); - if let Some(source) = &from { - deps.extend(read_manifest(source)?); - } m.install_all( deps.iter().map(|(n, r)| (n.as_str(), r.as_str())), Path::new(&dir), diff --git a/src/npmrc.rs b/src/npmrc.rs index 859088c..70b518e 100644 --- a/src/npmrc.rs +++ b/src/npmrc.rs @@ -82,6 +82,17 @@ pub fn parse(contents: &str) -> Result { Ok(rc) } +/// A caller-given prefix in the same form: scheme dropped, leading `//`, trailing `/`. +/// Unlike [`nerf`], the path is kept whole, because a prefix is not a request URL. +pub fn prefix(p: &str) -> String { + let rest = p.split_once("://").map_or(p, |(_, r)| r); + let mut out = format!("//{}", rest.trim_start_matches('/')); + if !out.ends_with('/') { + out.push('/'); + } + out +} + /// The URL-prefix form credentials are keyed by: scheme dropped, query dropped, the path /// cut after its last `/`. `https://r.io/@s%2fx?x=1` → `//r.io/`; /// `https://r.io/x/-/x-1.tgz` → `//r.io/x/-/`. diff --git a/tests/install.rs b/tests/install.rs index 841b64d..ef92c85 100644 --- a/tests/install.rs +++ b/tests/install.rs @@ -354,19 +354,36 @@ fn reinstall_at_another_version_replaces_the_directory() { #[test] fn npmrc_scoped_registry_and_credentials_apply_by_url_prefix() { - let reg = FakeRegistry::publish_at("https://acme.io/npm", &[pkg("@acme/tool", "1.0.0")]); - let dir = tempdir(); - let m = microbe(®) - .npmrc_contents( - "@acme:registry=https://acme.io/npm/\n//acme.io/npm/:_authToken=tok\n//elsewhere.io/:_authToken=nope\n", - ) - .unwrap(); - m.install("@acme/tool", dir.path()).unwrap(); - let requests = reg.requests.lock().unwrap().clone(); - assert_eq!(requests[0].0, "https://acme.io/npm/@acme%2ftool"); - for (url, headers) in &requests { - let auth = headers.iter().find(|(k, _)| k == "authorization"); - assert_eq!(auth.map(|(_, v)| v.as_str()), Some("Bearer tok"), "{url}"); + // The same routing and credential, once through .npmrc keys and once through the setters + // those keys are built on. + for setters in [false, true] { + let reg = FakeRegistry::publish_at("https://acme.io/npm", &[pkg("@acme/tool", "1.0.0")]); + let m = if setters { + microbe(®) + .scoped_registry("@acme", "https://acme.io/npm/") + .auth("https://acme.io/npm", "Bearer tok") + .auth("//elsewhere.io/", "Bearer nope") + } else { + microbe(®) + .npmrc_contents( + "@acme:registry=https://acme.io/npm/\n//acme.io/npm/:_authToken=tok\n//elsewhere.io/:_authToken=nope\n", + ) + .unwrap() + }; + m.install("@acme/tool", tempdir().path()).unwrap(); + let requests = reg.requests.lock().unwrap().clone(); + assert_eq!( + requests[0].0, "https://acme.io/npm/@acme%2ftool", + "setters={setters}" + ); + for (url, headers) in &requests { + let auth = headers.iter().find(|(k, _)| k == "authorization"); + assert_eq!( + auth.map(|(_, v)| v.as_str()), + Some("Bearer tok"), + "{url} setters={setters}" + ); + } } // The default registry carries no credential: its prefix matches nothing configured. let plain = FakeRegistry::publish(&[pkg("plain", "1.0.0")]); From 14450e7c79243697237fc3409858b1afc17a8404 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:12:39 -0700 Subject: [PATCH 2/3] CLI: an unknown flag is a usage error, not a package name install --from package.json looked up a package called --from and exited 1 on the 404. A manifest without a dependencies key now names the file plainly instead of calling it a packument. --- src/main.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6b3b323..d1f4b43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,12 @@ fn main() -> ExitCode { "--registry" => registry = args.next(), "--dir" => dir = args.next(), "--npmrc" => npmrc = args.next(), + // `-` alone is stdin; anything else dashed is a flag this binary does not have, + // never a package name to look up. + _ if a.starts_with('-') && a != "-" => { + eprintln!("{USAGE}"); + return ExitCode::from(2); + } _ if verb.is_none() => verb = Some(a), _ => positionals.push(a), } @@ -107,10 +113,8 @@ fn read_manifest(source: &str) -> Result, microbe::Error> struct Manifest { dependencies: Option>, } - let bad = |detail: String| microbe::Error::Registry { - name: source.to_string(), - detail, - }; + let bad = + |detail: String| microbe::Error::Io(std::io::Error::other(format!("{source}: {detail}"))); let manifest: Manifest = serde_json::from_str(&json).map_err(|e| bad(e.to_string()))?; let map = manifest .dependencies From 1c5fca16e2d9fef851b9366c80bf46fb6aa6408f Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:08:49 -0700 Subject: [PATCH 3/3] sweep: the two verbs, the stdin manifest, and every usage error exits 2 --- .github/workflows/sweep.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index 8d8fad1..07985ec 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -42,16 +42,31 @@ jobs: run_bin "$RUNNER_TEMP/spec-vite" vite --version run_bin "$RUNNER_TEMP/spec-typescript" tsc --version run_bin "$RUNNER_TEMP/spec-esbuild" esbuild --version - echo "== package map through --from, with an explicit .npmrc" + echo "== package map through install-manifest, with an explicit .npmrc" d=$RUNNER_TEMP/map printf '{"name":"x","dependencies":{"express":"^5","@types/node":"22","is-odd":"^3"},"devDependencies":{"nope":"1"}}' > "$RUNNER_TEMP/package.json" printf 'registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n' > "$RUNNER_TEMP/bad.npmrc" - if "$M" install --from "$RUNNER_TEMP/package.json" --dir "$d" --npmrc "$RUNNER_TEMP/bad.npmrc"; then echo "unexpanded \${VAR} was accepted"; exit 1; fi + if "$M" install-manifest "$RUNNER_TEMP/package.json" --dir "$d" --npmrc "$RUNNER_TEMP/bad.npmrc"; then echo "unexpanded \${VAR} was accepted"; exit 1; fi printf 'registry=https://registry.npmjs.org/\n' > "$RUNNER_TEMP/ok.npmrc" - "$M" install --from "$RUNNER_TEMP/package.json" --dir "$d" --npmrc "$RUNNER_TEMP/ok.npmrc" | head -5 + "$M" install-manifest "$RUNNER_TEMP/package.json" --dir "$d" --npmrc "$RUNNER_TEMP/ok.npmrc" | head -5 [ ! -e "$d/node_modules/nope" ] node -e "const e=require(require('path').join(process.argv[1],'node_modules','express')); console.log('express', typeof e)" "$d" node -e "console.log('is-odd', require(require('path').join(process.argv[1],'node_modules','is-odd'))(3))" "$d" test -f "$d/node_modules/@types/node/package.json" echo "== a second install over the first fetches nothing" - "$M" install --from "$RUNNER_TEMP/package.json" --dir "$d" | grep -x '0 packages' + "$M" install-manifest "$RUNNER_TEMP/package.json" --dir "$d" | grep -x '0 packages' + echo "== stdin manifest, and the usage errors exit 2 before any network" + "$M" install-manifest - --dir "$d" < "$RUNNER_TEMP/package.json" | grep -x '0 packages' + usage() { # + local want=$1; shift + set +e; "$M" "$@" >/dev/null 2>&1; local got=$?; set -e + [ "$got" = "$want" ] || { echo "microbe $* exited $got, expected $want"; exit 1; } + } + usage 2 install --dir "$RUNNER_TEMP/u" + usage 2 install is-odd + usage 2 install-manifest --dir "$RUNNER_TEMP/u" + usage 2 install-manifest a b --dir "$RUNNER_TEMP/u" + usage 2 install --from "$RUNNER_TEMP/package.json" --dir "$RUNNER_TEMP/u" + usage 2 frobnicate is-odd --dir "$RUNNER_TEMP/u" + printf '{"name":"x"}' > "$RUNNER_TEMP/nodeps.json" + usage 1 install-manifest "$RUNNER_TEMP/nodeps.json" --dir "$RUNNER_TEMP/u"