Skip to main content

anodizer_core/config/
npm.rs

1use std::collections::{BTreeMap, HashMap};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::{Amd64Variant, StringOrBool, deserialize_string_or_bool_opt};
7
8/// Binary-distribution strategy for an [`NpmConfig`] entry.
9///
10/// `optional-deps` (the default for a Rust release) emits npm's native
11/// platform-resolution layout: one thin per-platform package whose
12/// `os`/`cpu`/`libc` selectors are derived from the built target triples,
13/// plus a metapackage that lists every platform package under
14/// `optionalDependencies` and ships a `bin` shim resolving the installed
15/// one via `require.resolve`. npm installs only the matching platform
16/// package; there is no download and no postinstall script. This is the
17/// pattern leading Rust CLIs ship binaries through npm with (biome,
18/// git-cliff).
19///
20/// `postinstall` emits a single package carrying a `postinstall.js` shim that
21/// downloads + sha256-verifies the OS/arch-matching release archive at
22/// `npm install` time — for registries or policies that disallow per-platform
23/// packages.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
25#[serde(rename_all = "kebab-case")]
26pub enum NpmMode {
27    /// Emit per-platform packages + a metapackage with `optionalDependencies`
28    /// and a `require.resolve` bin shim. npm's native `os`/`cpu`/`libc`
29    /// resolution selects the right prebuilt package — no download, no
30    /// postinstall. Default.
31    #[default]
32    OptionalDeps,
33    /// Emit a single package with a `postinstall.js` shim that downloads and
34    /// sha256-verifies the matching archive at install time.
35    Postinstall,
36}
37
38/// Credential-selection strategy for an [`NpmConfig`] entry.
39///
40/// Controls whether the publisher authenticates with a long-lived registry
41/// token (`NPM_TOKEN` / `cfg.token`) or with GitHub Actions OIDC (npm Trusted
42/// Publishing), evaluated **per published package** — in `optional-deps` mode
43/// that means the metapackage and every per-platform sub-package are decided
44/// independently, so a metapackage with a configured Trusted Publisher can use
45/// OIDC while brand-new sub-packages (which Trusted Publishing cannot create)
46/// fall back to the token.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
48#[serde(rename_all = "kebab-case")]
49pub enum NpmAuthMode {
50    /// Decide per package at publish time (default). The registry is probed for
51    /// each package's existence: a package that already exists prefers OIDC
52    /// (Trusted Publishing) when an OIDC context is present, otherwise the
53    /// token; a brand-new package always uses the token (Trusted Publishing
54    /// cannot create a package that does not yet exist) and hard-errors if no
55    /// token is set. When OIDC is chosen for an existing package and the
56    /// publish fails, `auto` retries that package with the token (if available)
57    /// and emits a loud warning that Trusted Publishing was not exercised.
58    #[default]
59    Auto,
60    /// Always authenticate with the token (`NPM_TOKEN` / `cfg.token`); never
61    /// attempt OIDC. Errors if no token is available. This is anodizer's
62    /// historical behaviour.
63    Token,
64    /// Always authenticate with OIDC (Trusted Publishing); never fall back to
65    /// the token. Errors if the OIDC request env is absent. Use when every
66    /// package in this entry has a configured Trusted Publisher and you want a
67    /// failed exchange to fail the release loudly rather than silently fall
68    /// back to a token.
69    Oidc,
70}
71
72/// NPM package registry publisher configuration.
73///
74/// In the default `optional-deps` mode anodizer emits one thin npm package
75/// per built platform (with `os`/`cpu`/`libc` selectors derived from the
76/// target triple) plus a metapackage whose `optionalDependencies` lists
77/// every platform package; npm's native resolution installs only the one
78/// matching the host. In `postinstall` mode a single package carries a
79/// `postinstall` script that downloads the matching release archive at
80/// `npm install` time. Each `npms[]` entry produces one publish.
81#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
82#[serde(default, deny_unknown_fields)]
83pub struct NpmConfig {
84    /// Unique identifier for selecting this entry from the CLI (`--id=...`).
85    pub id: Option<String>,
86
87    /// Crate-name filter: only include artifacts whose owning `crate_name` is
88    /// in this list. Orthogonal to `targets:` (both filters apply).
89    pub ids: Option<Vec<String>>,
90
91    /// Target-triple allowlist: restrict the per-platform packages to a subset
92    /// of the built targets. When unset (the default), every built target that
93    /// maps to an npm triple becomes a package. When set, only artifacts whose
94    /// target triple appears in this list are turned into packages; the rest
95    /// are silently skipped (deliberately out of scope — unlike a target with
96    /// no npm os/cpu mapping, which is warned about). Orthogonal to `ids:`:
97    /// both filters apply (an artifact must pass the `ids` filter AND, when
98    /// this is set, be listed here). A listed triple that no selected build
99    /// produces is a config error, and an explicit empty list (`targets: []`)
100    /// is rejected — omit the field to publish every built target. Example:
101    /// `targets: [x86_64-unknown-linux-gnu, aarch64-apple-darwin]`.
102    pub targets: Option<Vec<String>>,
103
104    /// Binary-distribution strategy. `optional-deps` (default) emits npm's
105    /// native per-platform packages; `postinstall` emits a download shim.
106    #[serde(default)]
107    pub mode: NpmMode,
108
109    /// npm scope for the per-platform packages emitted in `optional-deps`
110    /// mode (e.g. `@biomejs`). The per-platform packages are named
111    /// `<scope>/<bin>-<os>-<cpu>[-<libc>]`. Required for `optional-deps`
112    /// mode unless `platform_name_template` is set (a template can produce
113    /// unscoped names); ignored in `postinstall` mode.
114    pub scope: Option<String>,
115
116    /// Override the per-platform package naming in `optional-deps` mode.
117    ///
118    /// The rendered template is the FULL package name for each platform,
119    /// replacing the default `<scope>/<bin>-<os>-<cpu>[-<libc>]`. Beyond the
120    /// standard release context, four platform vars are available per package:
121    /// `NpmOs` (npm's os selector: `linux`/`darwin`/`win32`), `NpmCpu`
122    /// (`x64`/`arm64`/`ia32`/...), `NpmLibc` (`glibc`/`musl`, empty off-linux),
123    /// plus anodizer's own `Os`/`Arch` target mapping (os `windows`, not
124    /// `win32`). Example: `"git-cliff-{{ Os }}-{{ NpmCpu }}"` yields
125    /// `git-cliff-linux-x64`, `git-cliff-darwin-arm64`, `git-cliff-windows-x64`.
126    /// A rendered name without a leading `@` is prefixed with `scope` when one
127    /// is set; with this template set, `scope` is optional and unscoped names
128    /// are allowed. If the template renders the same name for two platforms
129    /// (e.g. it omits `NpmLibc` while `libc_aware` is `true`), the publisher
130    /// fails with a config error naming the colliding packages. The npm
131    /// `os`/`cpu`/`libc` selector fields inside each `package.json` always use
132    /// the npm tokens regardless of this template. Ignored (hard error) in
133    /// `postinstall` mode.
134    pub platform_name_template: Option<String>,
135
136    /// In `optional-deps` mode, emit and publish ONLY the per-platform
137    /// packages — no metapackage (no `optionalDependencies` aggregate, no
138    /// `bin` shim). Accepts bool or template string, like `skip`. For
139    /// projects whose base npm package is hand-written (e.g. a TypeScript
140    /// library owning the name) while anodizer owns the per-platform binary
141    /// packages it lists under its own `optionalDependencies`. Hard error in
142    /// `postinstall` mode (there is no metapackage to skip) — but only when
143    /// it evaluates truthy: `skip_metapackage: false` (or a template
144    /// rendering falsey/empty) is inert. Example bool form:
145    /// `skip_metapackage: true`. Example templated form:
146    /// `skip_metapackage: "{{ if .Env.EXTERNAL_METAPACKAGE }}true{{ end }}"`
147    /// — skip only when the base package is published elsewhere.
148    #[serde(default, deserialize_with = "deserialize_string_or_bool_opt")]
149    pub skip_metapackage: Option<StringOrBool>,
150
151    /// Metapackage name for `optional-deps` mode (e.g. `biome`). This is the
152    /// package users `npm install`; it lists every per-platform package under
153    /// `optionalDependencies` and ships the `bin` shim. Falls back to `name`
154    /// (or the crate name) when unset.
155    pub metapackage: Option<String>,
156
157    /// Command name installed by the metapackage's `bin` map (`optional-deps`
158    /// mode). Falls back to the metapackage basename when unset. Shorthand for a
159    /// single-command package; superseded by `bins` when both are set.
160    pub bin: Option<String>,
161
162    /// Multiple commands installed by one package, as a map of `command name →
163    /// binary filename` to resolve inside the selected per-platform package
164    /// (`optional-deps`) or the extracted `bin/` directory (`postinstall`). The
165    /// package that ships `hurl` + `hurlfmt`, for example, sets
166    /// `bins: { hurl: hurl, hurlfmt: hurlfmt }` to emit both commands. Each
167    /// command gets its own launcher shim (`<command>.js`) and its own entry in
168    /// the package's `bin` map. When set, this supersedes the single-command
169    /// `bin:` shorthand; when unset, a single command is emitted from `bin:`.
170    pub bins: Option<BTreeMap<String, String>>,
171
172    /// Per-platform binary subdirectory inside each `optional-deps` package
173    /// (e.g. `bin`). When set, the platform binary lands at
174    /// `<platform_bin_dir>/<binary>` rather than the package root, the metapackage
175    /// shim resolves it at that path, and the package's `files` allowlist covers
176    /// it. Required by external shims that hard-code a nested resolve path — for
177    /// example git-cliff's own wrapper resolves
178    /// `git-cliff-<os>-<arch>/bin/git-cliff`, so a `skip_metapackage` layout must
179    /// place the binary under `bin/`. When unset (the default), the binary lands
180    /// at the package root (`<binary>`). Ignored in `postinstall` mode.
181    pub platform_bin_dir: Option<String>,
182
183    /// Environment variables injected into the child process the generated
184    /// launcher shim spawns (both the `optional-deps` metapackage shim and the
185    /// `postinstall` launcher). Each entry is merged over the inherited
186    /// `process.env` before the native binary is exec'd, so a wrapper can set a
187    /// runtime variable its binary expects (e.g. `{ BIOME_BINARY_SOURCE: npm }`).
188    /// When unset, the shim spawns with the inherited environment unchanged.
189    pub shim_env: Option<BTreeMap<String, String>>,
190
191    /// In `optional-deps` mode, emit separate per-platform packages for linux
192    /// `musl` vs `glibc` (distinguished by the npm `libc` selector). When
193    /// `false`, a single linux package per cpu is emitted with no `libc`
194    /// selector. Default `true` — musl and glibc binaries are not
195    /// interchangeable, so collapsing them risks installing the wrong one.
196    #[serde(default = "default_libc_aware")]
197    pub libc_aware: bool,
198
199    /// NPM package name (the metapackage / postinstall package). May be scoped
200    /// (`@org/foo`) or unscoped (`foo`). Falls back to the crate name when unset.
201    pub name: Option<String>,
202
203    /// Templated package description. Falls back to the project-level
204    /// `metadata.description` when unset.
205    pub description: Option<String>,
206
207    /// Templated homepage URL. Falls back to `metadata.homepage` when unset.
208    pub homepage: Option<String>,
209
210    /// NPM `keywords` list.
211    pub keywords: Option<Vec<String>>,
212
213    /// Templated SPDX license identifier (e.g. `MIT`, `Apache-2.0`).
214    /// Falls back to `metadata.license` when unset.
215    pub license: Option<String>,
216
217    /// Templated `author` field for `package.json`. Falls back to the
218    /// project's `metadata.maintainers[0]`, and then to the crate's
219    /// `Cargo.toml [package].authors[0]`, when unset.
220    pub author: Option<String>,
221
222    /// npm `engines` constraint map written verbatim into `package.json`
223    /// (e.g. `{ node: ">=18" }`). When unset, anodizer emits a sensible
224    /// default of `{ node: ">=18" }` — the floor every leading native-CLI
225    /// wrapper (esbuild, biome, swc) declares. Set to an empty map to
226    /// suppress the field entirely.
227    pub engines: Option<std::collections::BTreeMap<String, String>>,
228
229    /// Explicit npm `files` allowlist written into `package.json`. When
230    /// unset, anodizer derives it from what each package actually ships
231    /// (the per-platform binary, the metapackage `shim.js`, or the
232    /// postinstall launcher/script) plus any `extra_files` basenames. Set
233    /// to an empty list to suppress the field (npm then falls back to its
234    /// implicit inclusion rules).
235    pub files: Option<Vec<String>>,
236
237    /// npm `publishConfig.provenance` flag. When unset, anodizer emits
238    /// `true` — the npm supply-chain norm that biome and swc both set,
239    /// pairing with anodizer's signing story. Set to `false` to disable.
240    pub provenance: Option<bool>,
241
242    /// Templated repository URL. Emitted as `repository.url` in
243    /// `package.json` with `type: git`. Falls back to the crate's
244    /// `Cargo.toml [package].repository` when unset. Named `repository_url` to
245    /// avoid colliding with the `{owner, name, token, ...}` `repository:` block
246    /// every git-based publisher uses; the legacy `repository:` spelling is
247    /// accepted via serde alias for back-compat.
248    #[serde(alias = "repository")]
249    pub repository_url: Option<String>,
250
251    /// Templated bug tracker URL. Emitted as `bugs.url` in `package.json`.
252    pub bugs: Option<String>,
253
254    /// npm `man` page list, emitted verbatim into `package.json` as `man` (a
255    /// path or array of paths to troff-formatted man pages npm installs).
256    pub man: Option<Vec<String>>,
257
258    /// npm `contributors` list, emitted verbatim into `package.json` as
259    /// `contributors` (each entry a name or `Name <email> (url)` string).
260    pub contributors: Option<Vec<String>>,
261
262    /// NPM access level for scoped packages. Accepts `"public"` /
263    /// `"restricted"`. Scoped packages on npmjs.org default to
264    /// `restricted` unless this is set to `public`.
265    pub access: Option<String>,
266
267    /// NPM dist-tag for the publish (default `latest`). Templated.
268    pub tag: Option<String>,
269
270    /// Archive format the `postinstall` script downloads
271    /// (`tgz`, `tar.gz`, `tar`, `zip`, `binary`). Default `tgz`. Only consulted
272    /// in `postinstall` mode.
273    pub format: Option<String>,
274
275    /// Override the download URL emitted into the postinstall script
276    /// (templated). When unset, anodizer derives the URL from the
277    /// release context. Only consulted in `postinstall` mode.
278    pub url_template: Option<String>,
279
280    /// Version of the native binary the `postinstall` script downloads, when it
281    /// differs from the published npm package version. Feeds the `{{ Version }}`
282    /// var of the derived (or `url_template`) download URL, so the npm package
283    /// can be re-published at a new version while still fetching a pinned binary
284    /// release. Falls back to the release version when unset. Only consulted in
285    /// `postinstall` mode.
286    pub binary_version: Option<String>,
287
288    /// amd64 microarchitecture variant filter (`v1` / `v2` / `v3` / `v4`).
289    /// When set, an amd64 artifact is included only when its `amd64_variant`
290    /// metadata matches (artifacts without the metadata always pass). Steers
291    /// which tuned build lands in each platform package. Typed as
292    /// [`Amd64Variant`], so any value outside `v1`..`v4` is rejected at parse
293    /// time. Default `v1`, mirroring the homebrew/winget/krew/nix/aur peers.
294    pub amd64_variant: Option<Amd64Variant>,
295
296    /// ARM version filter (e.g. `6`, `7`). When set, a 32-bit ARM artifact is
297    /// included only when its `arm_variant` metadata matches (artifacts without
298    /// the metadata always pass). Mirrors the homebrew/winget/krew/nix/aur
299    /// peers; defaults to `6` when unset.
300    pub arm_variant: Option<String>,
301
302    /// Additional files to include in the published package alongside the
303    /// generated metadata. Default `["README*", "LICENSE*"]` (applied at the
304    /// `Default` pass).
305    pub extra_files: Option<Vec<String>>,
306
307    /// Template-rendered file mappings (`src` may be a glob; rendered
308    /// contents written to `dst`).
309    pub templated_extra_files: Option<Vec<NpmTemplatedExtraFile>>,
310
311    /// Free-form root-level `package.json` fields. Shallow-merged into
312    /// the generated `package.json` (config keys win over generated ones).
313    /// Useful for `mcpName`, `funding`, or other npm metadata fields
314    /// anodizer does not surface as first-class options.
315    pub extra: Option<HashMap<String, serde_json::Value>>,
316
317    /// Override the registry endpoint (default `https://registry.npmjs.org`).
318    pub registry: Option<String>,
319
320    /// Auth token for the registry. Falls back to the `NPM_TOKEN` env var
321    /// when unset. Stored in `.npmrc` as `//<registry>/:_authToken=...`
322    /// at publish time and never passed via argv.
323    pub token: Option<String>,
324
325    /// Credential-selection strategy: `auto` (default) decides per package by
326    /// probing the registry for the package's existence; `token` always uses
327    /// the token; `oidc` always uses Trusted Publishing with no token fallback.
328    /// See [`NpmAuthMode`]. Absent in existing configs resolves to `auto`.
329    #[serde(default)]
330    pub auth: NpmAuthMode,
331
332    /// Skip this publisher. Accepts bool or template string.
333    /// Accepts the legacy `disable:` spelling via serde alias for back-compat.
334    #[serde(
335        default,
336        alias = "disable",
337        deserialize_with = "deserialize_string_or_bool_opt"
338    )]
339    pub skip: Option<StringOrBool>,
340
341    /// Override whether this publisher failing should fail the overall release.
342    ///
343    /// Default: `true` — NPM is a Manager-group publisher (one-way
344    /// 72-hour unpublish window), so a failed publish aborts by default
345    /// to avoid surprising the operator with a half-released version.
346    /// Set to `false` to log failures but continue.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub required: Option<bool>,
349
350    /// Template-conditional gate: when the rendered result is falsy
351    /// (`"false"` / `"0"` / `"no"` / empty), the NPM publisher entry is
352    /// skipped. Render failure hard-errors.
353    #[serde(rename = "if")]
354    pub if_condition: Option<String>,
355    /// When `true`, a triggered rollback leaves this publisher's work in
356    /// place rather than attempting to undo it. Default `false`.
357    pub retain_on_rollback: Option<bool>,
358}
359
360/// Default for [`NpmConfig::libc_aware`] — emit musl and glibc linux
361/// packages separately.
362fn default_libc_aware() -> bool {
363    true
364}
365
366impl Default for NpmConfig {
367    fn default() -> Self {
368        Self {
369            id: None,
370            ids: None,
371            targets: None,
372            mode: NpmMode::default(),
373            scope: None,
374            platform_name_template: None,
375            skip_metapackage: None,
376            metapackage: None,
377            bin: None,
378            bins: None,
379            platform_bin_dir: None,
380            shim_env: None,
381            libc_aware: default_libc_aware(),
382            name: None,
383            description: None,
384            homepage: None,
385            keywords: None,
386            license: None,
387            author: None,
388            engines: None,
389            files: None,
390            provenance: None,
391            repository_url: None,
392            bugs: None,
393            man: None,
394            contributors: None,
395            access: None,
396            tag: None,
397            format: None,
398            url_template: None,
399            binary_version: None,
400            amd64_variant: None,
401            arm_variant: None,
402            extra_files: None,
403            templated_extra_files: None,
404            extra: None,
405            registry: None,
406            token: None,
407            auth: NpmAuthMode::default(),
408            skip: None,
409            required: None,
410            if_condition: None,
411            retain_on_rollback: None,
412        }
413    }
414}
415
416/// Template-rendered file mapping for [`NpmConfig::templated_extra_files`].
417#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
418#[serde(default, deny_unknown_fields)]
419pub struct NpmTemplatedExtraFile {
420    /// Source path (may be a glob; relative to the project root).
421    pub src: String,
422    /// Destination path inside the published package.
423    pub dst: String,
424}