From e298d3fe803e64e3befc86399449e755b11fdf51 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:50:59 -0700 Subject: [PATCH 1/2] Private transport internals, --json, addon routing options, and the README reference - The transport module is private; the crate exports Microbe, Installation, Root, Error, Transport, TIMEOUT and DEFAULT_REGISTRY. Builds with in-binary TLS keep the host clients compiled but allow them dead. - Installation and Root derive serde::Serialize, camel-cased; the binary prints that shape with --json. The package count line is singular for one package. - The addon takes scopedRegistries and auth options, the setters the .npmrc keys build on; its smoke passes them. - README: install for each audience, the Rust API with Transport and TIMEOUT, an Errors section, the command-line reference with output and exit codes, the Node API with every option, and the placement rules as a list. --- CHANGELOG.md | 5 +- README.md | 179 ++++++++++++++++++++++++++++++++--------- napi/README.md | 41 +++++++--- napi/index.d.ts | 4 + napi/scripts/smoke.cjs | 4 +- napi/src/lib.rs | 12 +++ src/lib.rs | 22 +++-- src/main.rs | 31 ++++++- src/transport.rs | 8 +- 9 files changed, 241 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7dbab..6d4ad61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ... --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. +- **CLI** `microbe install ... --dir ` and `microbe install-manifest --dir `, both with `--registry `, `--npmrc ` 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. diff --git a/README.md b/README.md index 28d113c..f935abb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # microbe -The smallest embeddable npm package installer. One crate, pure Rust, no async runtime: it fetches packages and their dependency trees from the registry into a directory the caller names, verifies integrity, links the bins, and reports where everything landed. +The smallest embeddable npm package installer. One crate, pure Rust, no async runtime: it installs a package and its dependency tree into a directory the caller names, verifies integrity, links the bins, and reports where everything landed. ```rust use microbe::Microbe; @@ -10,41 +10,41 @@ let done = Microbe::new()?.install("esbuild@^0.25", Path::new("/tmp/tools"))?; let esbuild = &done.bins["esbuild"]; // /tmp/tools/node_modules/.bin/esbuild -> esbuild/bin/esbuild ``` -``` -microbe install ... --dir [--registry ] [--npmrc ] +```sh +microbe install esbuild@^0.25 --dir /tmp/tools microbe install-manifest package.json --dir /tmp/tools # its `dependencies` map; other keys are ignored ``` -## From Node - -The same installer is on npm as [`@nubjs/microbe`](napi/README.md), a Node-API addon with the platform builds as optional dependencies: +## Install -```js -import { install } from "@nubjs/microbe"; -const done = await install({ eslint: "^9" }, "/tmp/tools"); // or install(["eslint@^9"], dir) +```sh +cargo add microbe # the library +cargo install microbe # the `microbe` binary +npm install @nubjs/microbe # the Node-API addon; the platform build is an optional dependency ``` +On Linux the library and the binary carry no TLS by default and reach the registry through `node`, `curl`, `wget` or `python3` from the host. A build with `--features tls` carries rustls instead, for about 1.1 MB. The addon always carries TLS. See [How it reaches the network](#how-it-reaches-the-network). + ## Who it is for Microbe does the `npx`-shaped job for a tool that is not a package manager: install a package, or a small map of them, then run what was installed. Typical hosts are a language server launcher, an editor or agent that needs a formatter or a linter on demand, a build tool that pulls a plugin, or a CLI that ships without Node dependencies but needs one at run time. It is not a replacement for npm, pnpm or bun in a project checkout. There is no lockfile, no store, no `node_modules` reconciliation, no lifecycle scripts, no workspaces. Those are the parts of a package manager that take up the space, and a host that needs them should call a package manager. -## Status +## Rust API -Beta. The API is small and settled enough to build on, and CI verifies every change on Linux, macOS and Windows against a real registry. Until 1.0 a minor release may add a field to `Installation`, a variant to `Error`, or a method to `Microbe`; every such type is `#[non_exhaustive]` so that is not a breaking change for a caller. A change that breaks a caller bumps the minor version and is listed in [`CHANGELOG.md`](CHANGELOG.md). - -## API +The whole surface is one builder, one result type, one error type and one trait. ```rust -use microbe::{Microbe, Transport}; +use microbe::{Microbe, Transport, DEFAULT_REGISTRY, TIMEOUT}; -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 +let m = Microbe::new()? // in-binary TLS, or the first HTTPS client on the host + .registry("https://registry.example.com") // default is DEFAULT_REGISTRY, registry.npmjs.org .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 + .npmrc(Path::new("/etc/tool/.npmrc"))? // the same, read from an explicit path; nothing is discovered + .npmrc_contents("registry=https://r.example.com\n")? // or from contents the embedder already holds + .concurrency(8); // parallel fetches; default 16 // One package by spec: `name`, `name@tag`, `name@1.2.3`, `name@^1`, `@scope/name@^1`. let one = m.install("eslint@^9", dir)?; @@ -55,17 +55,111 @@ many.roots; // Vec, in request o many.bins; // BTreeMap; also linked in node_modules/.bin many.packages; // tarballs extracted by this call; a package already present is not counted many.skipped_install_scripts; // "name@version" of every package whose install script was not run +serde_json::to_string(&many)?; // Installation and Root serialize, camel-cased: the shape `--json` prints // An embedder that already links an HTTP client supplies it and pays for no TLS. struct MyClient; impl Transport for MyClient { + // `headers` always carries an `accept`, and an `authorization` when one is configured. + // A non-2xx status is an error; the installer retries on Error::Transport and 429 / 5xx. fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result, microbe::Error> { todo!() } } let m = Microbe::with_transport(MyClient); +TIMEOUT; // 300 s, the bound every built-in transport puts on one request ``` A second install into the same directory fetches only what is missing or at the wrong version. A package about to be installed at another version is removed first, never overlaid. +## Errors + +Every failure is one `microbe::Error` variant, and each one displays as a sentence an embedder can show as is. + +```rust +use microbe::Error; + +match Microbe::new()?.install("eslint@^9", dir) { + Ok(done) => {} + Err(Error::NoTransport(tried)) => {} // nothing on the host speaks HTTPS and the build has no TLS + Err(Error::Transport(detail)) => {} // a request failed after two retries + Err(Error::Status { url, status }) => {} // a non-2xx answer, after retries for 429 and 5xx + Err(Error::NoVersion { name, spec }) => {} // no published version satisfies the range or tag + Err(Error::Integrity { name, version }) => {} // the tarball does not match dist.integrity / dist.shasum + Err(Error::UnsafePath(entry)) => {} // a tarball entry would escape its package directory + Err(Error::Registry { name, detail }) => {} // the packument could not be parsed + Err(Error::Npmrc(detail)) => {} // a consumed .npmrc key cannot be used as written, such as `${VAR}` + Err(Error::Io(e)) => {} // a filesystem operation failed + Err(_) => {} // the enum is #[non_exhaustive] +} +``` + +An optional dependency never produces an error. When anything under one fails to resolve, download or verify, that branch is dropped and the install succeeds. + +## Command line + +The binary is the library from a shell. It exists to measure the crate and to try it; it is not a package manager for a project checkout. + +``` +usage: microbe install ... --dir [options] + microbe install-manifest --dir [options] + +options: --registry registry for unscoped packages; default https://registry.npmjs.org + --npmrc apply this .npmrc; nothing is discovered + --json print the installation as JSON +``` + +The `install` verb takes one or more specs. The `install-manifest` verb takes the `dependencies` map of a JSON file, or of stdin for `-`; a whole `package.json` is valid input, and every other key in it is ignored. Both install into `/node_modules`, and both print one line per requested package, the count of tarballs extracted, and every bin linked: + +``` +$ microbe install typescript@5 --dir /tmp/tools +typescript@5.9.3 -> /tmp/tools/node_modules/typescript +1 package + bin tsc -> /tmp/tools/node_modules/typescript/bin/tsc + bin tsserver -> /tmp/tools/node_modules/typescript/bin/tsserver +``` + +With `--json` the same installation is printed as one object, the serialized `Installation`: + +```json +{ + "roots": [{ "name": "typescript", "version": "5.9.3", "dir": "/tmp/tools/node_modules/typescript" }], + "bins": { + "tsc": "/tmp/tools/node_modules/typescript/bin/tsc", + "tsserver": "/tmp/tools/node_modules/typescript/bin/tsserver" + }, + "packages": 1, + "skippedInstallScripts": [] +} +``` + +| Exit | Meaning | +| --- | --- | +| 0 | Installed. | +| 1 | The install failed; stderr carries `microbe: `. | +| 2 | Usage error, before any network: no verb, no `--dir`, no spec, a wrong positional count, or a flag the binary does not have. | + +## Node API + +The same installer is on npm as [`@nubjs/microbe`](napi/README.md), a Node-API addon with the platform builds as optional dependencies. It needs Node 18.19 or later. + +```js +import { install, installSync } from "@nubjs/microbe"; + +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 +}); +await install(["eslint@^9", "prettier"], dir); // a spec list works too, and so does installSync + +done.roots; // [{ name, version, dir }] +done.bins; // { command: absolute script path }, also linked in node_modules/.bin +done.packages; // tarballs extracted by this call +done.skippedInstallScripts; // "name@version" of every package whose install script was not run +``` + ## Everything is explicit Microbe is an embedder-facing tool, not a human CLI, so it never guesses. The target directory is always given. Nothing is read from environment variables. No configuration file is discovered by walking up the filesystem: a registry is passed as a URL, and an `.npmrc` is passed as an explicit path. What the embedder does not pass, Microbe does not know about. The one thing detected at run time is which HTTPS client the host has, and an embedder that supplies a `Transport` opts out of that too. @@ -85,6 +179,32 @@ registry=https://registry.example.com Credentials are keyed by URL prefix exactly as npm keys them, and the longest matching prefix wins. A `${VAR}` reference is not expanded, because nothing is read from the environment; a consumed key that still holds one is an error, so a placeholder is never sent as a token. +## How it reaches the network + +The in-binary client is used on macOS and Windows, and on Linux when built with `--features tls`. A Linux build without it tries, in order: + +1. **`node`** — one long-lived child running `fetch`, with requests multiplexed over its stdio so the parallel install actually runs in parallel and undici reuses connections. This is the anchor, because whatever gets installed is about to be run by Node anyway. +2. **`curl`** — most full Linux distributions. +3. **`wget`** — busybox, so Alpine. +4. **`python3`** — the Python container images, which carry neither `curl` nor `wget` but do carry Python with its `ssl` module and a CA bundle. + +**The default Linux build requires one of those four programs on `PATH`, or a `Transport` supplied by the embedder.** A survey of 16 popular container base images found `curl` on 4 of them, and 8 carried neither `curl` nor `wget`; every Node image carries Node. The Debian and Ubuntu slim images carry none of the four and no CA bundle either, so on those the answer is `--features tls` or an embedder-supplied `Transport`. + +Every request is bounded by a 300 second timeout, npm's default, and a transport failure or a 429 or 5xx response is retried twice. Every host client is told to refuse a redirect off HTTPS: a tarball is protected by its integrity hash, but a packument is not. + +## What it implements + +Packages land flat under `/node_modules`, placed the way Node's resolver expects, and the same request always produces the same tree. + +- **Resolution** is first-wins over `dependencies` plus platform-matching `optionalDependencies`, one abbreviated packument fetch per package name. A name listed under `optionalDependencies` is optional even when it also appears under `dependencies`, because `npm publish` mirrors it there. +- **Placement** is flat; a version conflict nests the loser under its dependent, which is what Node's resolver walks up to find. +- **Optionality covers the whole branch.** When anything an optional dependency itself requires cannot be resolved or fails its integrity check, that optional dependency is dropped along with every package only it needed, and the install succeeds. A required path reaching the same package fails the install. +- **Bundled dependencies** ship inside their parent's tarball and are never fetched. +- **Integrity** is checked against `dist.integrity`, falling back to the pre-SRI `dist.shasum`. A tarball entry whose path would escape its package directory is refused. +- **Bins** of every requested package are linked under `node_modules/.bin`: a relative symlink on Unix, a `.cmd` shim that runs the script with `node` on Windows. A requested package wins a name clash with a dependency. +- **Peer dependencies** are ignored. +- **Install scripts** are not run. Packages that declare one are named in `Installation::skipped_install_scripts` so the caller can decide what that means; for a prebuilt-binary package like esbuild or biome the postinstall is a no-op, because the platform package carrying the binary is an optional dependency that Microbe already installed. + ## Size Stripped, `opt-level = "z"` with fat LTO, measured by CI on 2026-09-22 with the stable toolchain: @@ -107,27 +227,10 @@ Two phases. The plan phase walks the dependency graph breadth-first, fetching ea | eslint (77 packages) | 5.7 s | 8.5 s | 8.6 s | | vite (16 packages, native binaries) | 27.8 s | 51.5 s | 23.0 s | -## How it reaches the network - -The in-binary client is used on macOS and Windows, and on Linux when built with `--features tls`. A Linux build without it tries, in order: - -1. **`node`** — one long-lived child running `fetch`, with requests multiplexed over its stdio so the parallel install actually runs in parallel and undici reuses connections. This is the anchor, because whatever gets installed is about to be run by Node anyway. -2. **`curl`** — most full Linux distributions. -3. **`wget`** — busybox, so Alpine. -4. **`python3`** — the Python container images, which carry neither `curl` nor `wget` but do carry Python with its `ssl` module and a CA bundle. - -**The default Linux build requires one of those four programs on `PATH`, or a `Transport` supplied by the embedder.** A survey of 16 popular container base images found `curl` on 4 of them, and 8 carried neither `curl` nor `wget`; every Node image carries Node. The Debian and Ubuntu slim images carry none of the four and no CA bundle either, so on those the answer is `--features tls` or an embedder-supplied `Transport`. - -Every request is bounded by a 300 second timeout, npm's default, and a transport failure or a 429 or 5xx response is retried twice. Every host client is told to refuse a redirect off HTTPS: a tarball is protected by its integrity hash, but a packument is not. - -## What it implements - -Packages land flat under `/node_modules`. A version conflict nests the loser under its dependent, which is what Node's resolver walks up to find, and placement is deterministic: the same request always produces the same tree. Resolution is first-wins over `dependencies` plus platform-matching `optionalDependencies`, one abbreviated packument fetch per package name. A name listed under `optionalDependencies` is optional even when it also appears under `dependencies`, because `npm publish` mirrors it there. Optionality covers the whole branch: when anything an optional dependency itself requires cannot be resolved or fails its integrity check, that optional dependency is dropped along with every package only it needed, and the install succeeds, unless a required path reaches the same package, in which case the install fails. A name listed under `bundleDependencies` ships inside its parent's tarball and is never fetched. Tarballs are checked against `dist.integrity`, falling back to the pre-SRI `dist.shasum`. A tarball entry whose path would escape its package directory is refused. - -Peer dependencies are ignored and install scripts are not run. Packages that declare one are named in `Installation::skipped_install_scripts` so the caller can decide what that means; for a prebuilt-binary package like esbuild or biome the postinstall is a no-op, because the platform package carrying the binary is an optional dependency that Microbe already installed. +## Status -Every command a top-level package declares is linked under `node_modules/.bin`: a relative symlink on Unix, a `.cmd` shim that runs the script with `node` on Windows. A requested package wins a name clash with a dependency. +Beta. The API is small and settled enough to build on. Until 1.0 a minor release may add a field to `Installation`, a variant to `Error`, or a method to `Microbe`; every such type is `#[non_exhaustive]`, so that is not a breaking change for a caller. A change that breaks a caller bumps the minor version and is listed in [`CHANGELOG.md`](CHANGELOG.md). The crate, the binary and the addon are released together, at one version; [`RELEASING.md`](RELEASING.md) has the procedure. ## Tests -`cargo test` runs against an in-memory registry serving real gzipped tarballs with real integrity strings, so the whole install path runs with no network. The `Sweep` workflow, run on demand, builds the release binary on Linux, macOS and Windows and installs real packages from the registry with it. +The test suite runs against an in-memory registry serving real gzipped tarballs with real integrity strings, so the whole install path runs with no network. CI runs it on Linux, macOS and Windows, with clippy, rustfmt, rustdoc and the 1 MB size check. The `Sweep` workflow, run on demand, builds the release binary on Linux, macOS and Windows and installs real packages from the registry with it, and the `napi` workflow builds the addon for its eight platforms and installs a real package through each native one. diff --git a/napi/README.md b/napi/README.md index bb71e88..9c8e5c9 100644 --- a/napi/README.md +++ b/napi/README.md @@ -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 `-[-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 `-[-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. diff --git a/napi/index.d.ts b/napi/index.d.ts index c96905f..091328e 100644 --- a/napi/index.d.ts +++ b/napi/index.d.ts @@ -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; + /** `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; } export interface Root { diff --git a/napi/scripts/smoke.cjs b/napi/scripts/smoke.cjs index 32e7a2a..d03f4ff 100644 --- a/napi/scripts/smoke.cjs +++ b/napi/scripts/smoke.cjs @@ -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)); }); diff --git a/napi/src/lib.rs b/napi/src/lib.rs index 9026277..8015dbf 100644 --- a/napi/src/lib.rs +++ b/napi/src/lib.rs @@ -18,6 +18,12 @@ pub struct Options { pub npmrc_contents: Option, /// Parallel fetches; default 16. pub concurrency: Option, + /// Registry per scope, `{ "@acme": "https://npm.acme.dev/" }`: what an `@acme:registry` + /// key does. + pub scoped_registries: Option>, + /// `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>, } #[napi(object)] @@ -74,6 +80,12 @@ fn run(deps: &[(String, String)], dir: &str, opts: &Options) -> Result>, } -/// 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. @@ -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, @@ -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 { Ok(Self::from_boxed(transport::detect()?)) } diff --git a/src/main.rs b/src/main.rs index d1f4b43..2302119 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 @@ -13,9 +16,12 @@ use std::path::Path; use std::process::ExitCode; -const USAGE: &str = - "usage: microbe install ... --dir [--registry ] [--npmrc ] - microbe install-manifest --dir [--registry ] [--npmrc ]"; +const USAGE: &str = "usage: microbe install ... --dir [options] + microbe install-manifest --dir [options] + +options: --registry registry for unscoped packages; default https://registry.npmjs.org + --npmrc apply this .npmrc; nothing is discovered + --json print the installation as JSON"; fn main() -> ExitCode { let mut args = std::env::args().skip(1); @@ -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(), @@ -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()); } diff --git a/src/transport.rs b/src/transport.rs index de7a883..bd95cb4 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -41,17 +41,15 @@ pub trait Transport: Send + Sync { } /// The first transport available, in the order documented above. -pub fn detect() -> Result, Error> { +pub(crate) fn detect() -> Result, 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, Error> { +/// The first host-provided transport, in the order documented above. +fn detect_host() -> Result, Error> { if let Some(t) = NodeFetch::spawn() { return Ok(Box::new(t)); } From 0137dffe1718dc0444ce1c0ad1a865389d548c9f Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:50:59 -0700 Subject: [PATCH 2/2] sweep: check the --json output shape --- .github/workflows/sweep.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index 07985ec..3c91316 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -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() { # local want=$1; shift set +e; "$M" "$@" >/dev/null 2>&1; local got=$?; set -e