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}