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
8 changes: 8 additions & 0 deletions .github/workflows/sweep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ jobs:
"$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'
echo "== --json prints the installation"
"$M" install typescript@5 --dir "$RUNNER_TEMP/j" --json > "$RUNNER_TEMP/out.json"
node -e '
const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const keys = Object.keys(r).sort().join(",");
if (keys !== "bins,packages,roots,skippedInstallScripts") throw new Error(keys);
if (r.roots[0].name !== "typescript" || r.packages !== 1 || !("tsc" in r.bins)) throw new Error(JSON.stringify(r));
' "$RUNNER_TEMP/out.json"
usage() { # <expected exit> <args...>
local want=$1; shift
set +e; "$M" "$@" >/dev/null 2>&1; local got=$?; set -e
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ First published version.
- **Bins** of every top-level package are linked under `node_modules/.bin`; requested packages win a name clash.
- **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]>... --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.
- **CLI** `microbe install <name[@spec]>... --dir <path>` and `microbe install-manifest <file|-> --dir <path>`, both with `--registry <url>`, `--npmrc <file>` and `--json`, which prints the installation as one camel-cased object. Everything explicit: no environment variables, no filesystem walking. Exit 2 is a usage error, raised before any network.
- **Node-API addon** `@nubjs/microbe` in `napi/`: `install` and `installSync` taking a spec list or a `dependencies` object, with `registry`, `scopedRegistries`, `auth`, `npmrc`, `npmrcContents` and `concurrency` options, and platform packages for eight targets built by the `napi` workflow.
- **Public Rust surface**: `Microbe`, `Installation`, `Root`, `Error`, `Transport`, `TIMEOUT` and `DEFAULT_REGISTRY`. `Installation` and `Root` implement `serde::Serialize`. Transport detection is internal to `Microbe::new`.
- **Size**: 702 KB on Linux, 800 KB on Windows, 853 KB on macOS, stripped; CI fails a default build at 1 MB.
179 changes: 141 additions & 38 deletions README.md

Large diffs are not rendered by default.

41 changes: 32 additions & 9 deletions napi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,44 @@ const done = await install(["esbuild@^0.25"], "/tmp/tools");
done.bins.esbuild; // /tmp/tools/node_modules/esbuild/bin/esbuild, also linked in node_modules/.bin
```

## Install

```sh
npm install @nubjs/microbe
```

The platform build is an optional dependency, one package per `<os>-<arch>[-musl]`, selected by npm at install time: darwin-arm64, darwin-x64, linux-x64, linux-x64-musl, linux-arm64, linux-arm64-musl, win32-x64 and win32-arm64. Node 18.19 or later.

## API

Two functions with the same arguments: `install` runs off the main thread and resolves to the installation, `installSync` blocks and returns it.

```js
// A dependency map, the shape of package.json#/dependencies.
const done = await install({ eslint: "^9", prettier: "3" }, dir, {
registry: "https://registry.example.com", // default registry.npmjs.org
npmrc: "/etc/tool/.npmrc", // explicit path only; nothing is discovered
concurrency: 8, // parallel fetches; default 16
import { install, installSync } from "@nubjs/microbe";

// A dependency map, the shape of package.json#/dependencies, or a list of specs.
const done = await install({ eslint: "^9", prettier: "3" }, "/tmp/tools", {
registry: "https://registry.example.com", // default registry.npmjs.org
scopedRegistries: { "@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
npmrc: "/etc/tool/.npmrc", // the same, from an explicit path; nothing is discovered
npmrcContents: "registry=https://r.example.com\n", // or from contents the host already holds
concurrency: 8, // parallel fetches; default 16
});
const same = installSync(["eslint@^9", "prettier"], "/tmp/tools");

done.roots; // [{ name, version, dir }], in request order for a list
done.roots; // [{ name, version, dir }], in request order for a list and name order for a map
done.bins; // { command: absolute script path }, also linked in node_modules/.bin
done.packages; // tarballs extracted by this call; a package already present is not counted
done.skippedInstallScripts; // "name@version" of every package whose install script was not run
```

A second install into the same directory fetches only what is missing or at the wrong version. Nothing is read from environment variables and no file is discovered by walking the filesystem; an `.npmrc` is applied only from the path given, and a `${VAR}` in it is an error rather than a token sent as written. The synchronous form is `installSync`, with the same arguments.
A failure rejects, or throws from `installSync`, with the crate's error message: no version matches the range, an integrity mismatch, an HTTP status after retries, an `.npmrc` key that cannot be used as written. A second install into the same directory fetches only what is missing or at the wrong version.

## Everything is explicit

Nothing is read from environment variables and no file is discovered by walking the filesystem. An `.npmrc` is applied only from the path given, and a `${VAR}` in it is an error rather than a token sent as written. Credentials are keyed by URL prefix as npm keys them, and the longest matching prefix wins.

The addon reaches the registry itself: the platform TLS on macOS and Windows, rustls on Linux. Every request times out after 300 seconds and a transport failure or a 429 or 5xx response is retried twice. The full behaviour, the placement rules and the optional-dependency semantics are documented in the [microbe crate](https://github.com/nubjs/microbe).
## Network

Platform builds ship as optional dependencies, one per `<os>-<arch>[-musl]`, selected by npm at install time. Node 18.19 or later.
The addon reaches the registry itself: the platform TLS on macOS and Windows, rustls on Linux. Every request times out after 300 seconds, and a transport failure or a 429 or 5xx response is retried twice. The placement rules, the optional-dependency semantics and everything else the addon does are those of the [microbe crate](https://github.com/nubjs/microbe), which is the reference.
4 changes: 4 additions & 0 deletions napi/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export interface Options {
npmrcContents?: string;
/** Parallel fetches; default 16. */
concurrency?: number;
/** Registry per scope, `{ "@acme": "https://npm.acme.dev/" }`: what an `@acme:registry` key does. */
scopedRegistries?: Record<string, string>;
/** `authorization` header value per URL prefix, `{ "https://npm.acme.dev/": "Bearer tok" }`: what a `//npm.acme.dev/:_authToken` key does. The longest matching prefix wins. */
auth?: Record<string, string>;
}

export interface Root {
Expand Down
4 changes: 3 additions & 1 deletion napi/scripts/smoke.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ const { install, installSync } = require("../index.js");
const dir = path.join(require("node:os").tmpdir(), "napi-smoke");
const done = installSync(["is-odd@^3"], dir);
if (done.roots[0].name !== "is-odd") throw new Error(JSON.stringify(done));
install({ typescript: "5" }, dir).then((r) => {
// Routing for a scope this install never touches: the options are accepted and change nothing.
const options = { scopedRegistries: { "@nope": "https://example.invalid/" }, auth: { "https://example.invalid/": "Bearer x" } };
install({ typescript: "5" }, dir, options).then((r) => {
if (!r.bins.tsc) throw new Error(JSON.stringify(r));
console.log(r.roots.map((x) => x.name + "@" + x.version).join(" "), Object.keys(r.bins));
});
12 changes: 12 additions & 0 deletions napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ pub struct Options {
pub npmrc_contents: Option<String>,
/// Parallel fetches; default 16.
pub concurrency: Option<u32>,
/// Registry per scope, `{ "@acme": "https://npm.acme.dev/" }`: what an `@acme:registry`
/// key does.
pub scoped_registries: Option<HashMap<String, String>>,
/// `authorization` header value per URL prefix, `{ "https://npm.acme.dev/": "Bearer tok" }`:
/// what a `//npm.acme.dev/:_authToken` key does. The longest matching prefix wins.
pub auth: Option<HashMap<String, String>>,
}

#[napi(object)]
Expand Down Expand Up @@ -74,6 +80,12 @@ fn run(deps: &[(String, String)], dir: &str, opts: &Options) -> Result<Installat
if let Some(n) = opts.concurrency {
m = m.concurrency(n as usize);
}
for (scope, url) in opts.scoped_registries.iter().flatten() {
m = m.scoped_registry(scope, url);
}
for (prefix, value) in opts.auth.iter().flatten() {
m = m.auth(prefix, value);
}
let done = m
.install_all(deps.iter().map(|(n, r)| (n.as_str(), r.as_str())), Path::new(dir))
.map_err(js)?;
Expand Down
22 changes: 16 additions & 6 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@ mod error;
mod extract;
mod npmrc;
mod registry;
pub mod transport;
// Builds with in-binary TLS never call the host clients; they stay compiled so every
// platform type-checks the Linux path.
#[cfg_attr(
any(feature = "tls", target_os = "macos", target_os = "windows"),
allow(dead_code)
)]
mod transport;

pub use error::Error;
pub use transport::Transport;
pub use transport::{TIMEOUT, Transport};

use registry::{Manifest, Packument};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
Expand Down Expand Up @@ -62,8 +68,10 @@ pub struct Microbe {
packuments: Mutex<HashMap<String, Packument>>,
}

/// What an install produced. [`Microbe::install`] yields exactly one [`Root`].
#[derive(Debug)]
/// What an install produced. [`Microbe::install`] yields exactly one [`Root`]. Serializes as
/// camel-cased JSON, the shape `microbe --json` prints and the Node addon returns.
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Installation {
/// The requested packages, in request order.
Expand All @@ -80,7 +88,7 @@ pub struct Installation {
}

/// A requested package, as installed.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct Root {
pub name: String,
Expand All @@ -90,7 +98,9 @@ pub struct Root {
}

impl Microbe {
/// Uses the first transport the host provides; see [`transport::detect`].
/// Uses in-binary TLS where the build has it (macOS, Windows, Linux with `--features tls`),
/// else the first of `node`, `curl`, `wget`, `python3` on the host; [`Error::NoTransport`]
/// names them when none is found. [`Microbe::with_transport`] skips the detection.
pub fn new() -> Result<Self, Error> {
Ok(Self::from_boxed(transport::detect()?))
}
Expand Down
31 changes: 27 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
//! file, or of stdin for `-`: the `package.json#/dependencies` shape, so a whole
//! `package.json` is valid input and every other key is ignored.
//!
//! Both print one line per requested package, a package count and the bins, or the whole
//! [`microbe::Installation`] as JSON with `--json`.
//!
//! 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
Expand All @@ -13,9 +16,12 @@
use std::path::Path;
use std::process::ExitCode;

const USAGE: &str =
"usage: microbe install <name[@spec]>... --dir <path> [--registry <url>] [--npmrc <file>]
microbe install-manifest <file|-> --dir <path> [--registry <url>] [--npmrc <file>]";
const USAGE: &str = "usage: microbe install <name[@spec]>... --dir <path> [options]
microbe install-manifest <file|-> --dir <path> [options]

options: --registry <url> registry for unscoped packages; default https://registry.npmjs.org
--npmrc <file> apply this .npmrc; nothing is discovered
--json print the installation as JSON";

fn main() -> ExitCode {
let mut args = std::env::args().skip(1);
Expand All @@ -24,8 +30,10 @@ fn main() -> ExitCode {
let mut registry = None;
let mut npmrc = None;
let mut verb = None;
let mut json = false;
while let Some(a) = args.next() {
match a.as_str() {
"--json" => json = true,
"--registry" => registry = args.next(),
"--dir" => dir = args.next(),
"--npmrc" => npmrc = args.next(),
Expand Down Expand Up @@ -68,11 +76,26 @@ fn main() -> ExitCode {
)
};
match run() {
Ok(all) if json => match serde_json::to_string_pretty(&all) {
Ok(s) => {
println!("{s}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("microbe: {e}");
ExitCode::FAILURE
}
},
Ok(all) => {
for r in &all.roots {
println!("{}@{} -> {}", r.name, r.version, r.dir.display());
}
println!("{} packages", all.packages);
let noun = if all.packages == 1 {
"package"
} else {
"packages"
};
println!("{} {noun}", all.packages);
for (cmd, path) in &all.bins {
println!(" bin {cmd} -> {}", path.display());
}
Expand Down
8 changes: 3 additions & 5 deletions src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,15 @@ pub trait Transport: Send + Sync {
}

/// The first transport available, in the order documented above.
pub fn detect() -> Result<Box<dyn Transport>, Error> {
pub(crate) fn detect() -> Result<Box<dyn Transport>, Error> {
#[cfg(any(feature = "tls", target_os = "macos", target_os = "windows"))]
return Ok(Box::new(builtin::Builtin::new()));
#[cfg(not(any(feature = "tls", target_os = "macos", target_os = "windows")))]
detect_host()
}

/// The first HOST-provided transport, skipping in-binary TLS even when it is compiled in.
/// Worth reaching for deliberately behind a TLS-intercepting corporate proxy: the host's own
/// client carries the system trust store, where a bundled rustls root set does not.
pub fn detect_host() -> Result<Box<dyn Transport>, Error> {
/// The first host-provided transport, in the order documented above.
fn detect_host() -> Result<Box<dyn Transport>, Error> {
if let Some(t) = NodeFetch::spawn() {
return Ok(Box::new(t));
}
Expand Down
Loading