Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions .github/workflows/sweep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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() { # <expected exit> <args...>
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"
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<name[@spec]>...] [--from <file|->] --dir <path> [--registry <url>] [--npmrc <file>]`. Everything explicit: no environment variables, no filesystem walking.
- **CLI** `microbe install <name[@spec]>... --dir <path>` and `microbe install-manifest <file|-> --dir <path>`, both with `[--registry <url>] [--npmrc <file>]`. 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.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ let esbuild = &done.bins["esbuild"]; // /tmp/tools/node_modules/.bin/esbuild ->

```
microbe install <name[@spec]>... --dir <path> [--registry <url>] [--npmrc <file>]
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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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 <file>`), 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 <file>`), or from contents the embedder already holds (`Microbe::npmrc_contents`), and four keys are read:

```ini
registry=https://registry.example.com
Expand Down
20 changes: 20 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <value>` 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
Expand Down
62 changes: 36 additions & 26 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
//! `microbe install [<name[@spec]>...] [--from <file|->] --dir <path> [--registry <url>] [--npmrc <file>]`
//! installs into `<dir>/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 `<dir>/node_modules` and printing what landed:
//!
//! - `microbe install <name[@spec]>... --dir <path>` names packages directly.
//! - `microbe install-manifest <file|-> --dir <path>` 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 [<name[@spec]>...] [--from <file|->] --dir <path> [--registry <url>] [--npmrc <file>]";
const USAGE: &str =
"usage: microbe install <name[@spec]>... --dir <path> [--registry <url>] [--npmrc <file>]
microbe install-manifest <file|-> --dir <path> [--registry <url>] [--npmrc <file>]";

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;
Expand All @@ -24,32 +28,40 @@ fn main() -> ExitCode {
match a.as_str() {
"--registry" => registry = args.next(),
"--dir" => dir = args.next(),
"--from" => from = 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),
_ => 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::<Vec<_>>())
}
(Some("install-manifest"), [source]) => read_manifest(source),
_ => {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
};
let run = || -> Result<microbe::Installation, microbe::Error> {
let deps = deps?;
let mut m = microbe::Microbe::new()?;
if let Some(path) = &npmrc {
m = m.npmrc(Path::new(path))?;
}
if let Some(r) = &registry {
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),
Expand Down Expand Up @@ -101,10 +113,8 @@ fn read_manifest(source: &str) -> Result<Vec<(String, String)>, microbe::Error>
struct Manifest {
dependencies: Option<std::collections::BTreeMap<String, String>>,
}
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
Expand Down
11 changes: 11 additions & 0 deletions src/npmrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ pub fn parse(contents: &str) -> Result<Npmrc, Error> {
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/-/`.
Expand Down
43 changes: 30 additions & 13 deletions tests/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&reg)
.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(&reg)
.scoped_registry("@acme", "https://acme.io/npm/")
.auth("https://acme.io/npm", "Bearer tok")
.auth("//elsewhere.io/", "Bearer nope")
} else {
microbe(&reg)
.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")]);
Expand Down
Loading