From 4f46c65d32e98b8e09a837e916cc74658422127f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 30 Jul 2026 16:22:22 -0700 Subject: [PATCH] docs: lead with npm's browser 2FA flow; support NPM_CONFIG_OTP npm is moving away from authenticator codes toward WebAuthn (passkeys and security keys), so the browser approval flow is what most people will hit. Restructure the 2FA docs around it and demote --otp / --otp-secret to a collapsed legacy fallback. Also read NPM_CONFIG_OTP as a fallback for --otp. npm honors it on its own anyway, but reading it here keeps our "does this run need interactive 2FA?" checks in sync, so it no longer prints the browser-approval reminder or warms npm's auth cache when a code was already supplied via the env var. --- .bumpy/npm-otp-env-var.md | 5 +++++ README.md | 41 ++++++++++++++++++++++++++++----------- src/args.ts | 2 +- src/core.ts | 4 +++- src/interactive.ts | 4 ++-- 5 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 .bumpy/npm-otp-env-var.md diff --git a/.bumpy/npm-otp-env-var.md b/.bumpy/npm-otp-env-var.md new file mode 100644 index 0000000..0787af1 --- /dev/null +++ b/.bumpy/npm-otp-env-var.md @@ -0,0 +1,5 @@ +--- +'fledgling': patch +--- + +Read npm's own `NPM_CONFIG_OTP` env var as a fallback for `--otp`, so a 2FA code supplied that way also suppresses the interactive browser-approval prompt instead of fledgling assuming it still needs one. Docs now lead with npm's browser flow — approving with a passkey or security key is how most people will do this, and npm is moving away from authenticator codes — with the `--otp` / `--otp-secret` options kept but framed as the legacy fallback. diff --git a/README.md b/README.md index d681453..51192cb 100644 --- a/README.md +++ b/README.md @@ -169,37 +169,56 @@ Running bare `npx fledgling` (no subcommand) in a terminal drops you into the sa | `--force` | Replace an existing trusted publisher (revoke + re-create) | | `--placeholder-version ` | Placeholder version (default: `0.0.0`) | | `--tag ` | dist-tag for placeholders (default: `latest`) | -| `--otp ` | npm 2FA one-time password, used for every npm call this run | -| `--otp-secret ` | TOTP secret to generate 2FA codes from as needed (also `$FLEDGLING_OTP_SECRET`) | +| `--otp ` | *(legacy)* npm 2FA one-time password, used for every npm call this run (also `$NPM_CONFIG_OTP`) | +| `--otp-secret ` | *(legacy)* TOTP secret to generate 2FA codes from as needed (also `$FLEDGLING_OTP_SECRET`) | -### 2FA / one-time passwords +By default you don't pass either — npm handles 2FA itself via the browser. See [2FA](#2fa). -npm requires 2FA to claim names and configure trusted publishing. By default fledgling -just lets **npm handle it interactively** — it opens your browser to approve, and caches -that approval for ~5 minutes, so one approval covers the whole run. When prompted, tick -**"don't ask again for 5 minutes"** so it doesn't ask per-package. +### 2FA -For non-interactive runs (CI, scripts) there's no browser, so pass a code yourself: +npm requires 2FA to claim names and configure trusted publishing, and **the browser flow is +the normal path** — you won't pass anything to fledgling. npm kicks you out to the web to +approve with whatever your account uses (passkey, security key, or an authenticator code), +then caches that approval for ~5 minutes, so a single approval covers the whole run. When +prompted, tick **"don't ask again for 5 minutes"** so it doesn't ask once per package. + +That's it for most people. fledgling stays out of the way — it just runs npm with the +terminal attached and lets npm prompt. + +
+Passing a one-time code instead (legacy) + +npm is moving away from authenticator codes in favor of WebAuthn (passkeys and security +keys), so treat this as a fallback that's on its way out — for a shell with no browser, or +an account still on TOTP. Note that WebAuthn can't be automated headlessly at all, so if +your account has moved off codes, these options simply won't apply. - **`--otp `** — a single one-time password, reused for every npm call in the run. +- **`NPM_CONFIG_OTP=`** — same thing as an env var. It's npm's own config env var, and + it keeps the code out of your process list. - **`--otp-secret `** — your authenticator's TOTP secret (base32); fledgling generates a fresh code for each npm call. Prefer the **`FLEDGLING_OTP_SECRET`** env var over the flag so the secret doesn't land in your shell history or process list. -Pull credentials straight from a password manager — e.g. 1Password's CLI (`op`). Read the -**generated code** with `?attribute=otp` and pass it to `--otp`: +A single code expires in ~30s, so `--otp` / `NPM_CONFIG_OTP` may not survive a long run +across many packages — `--otp-secret` / `FLEDGLING_OTP_SECRET` mints a fresh one per call. + +Pull either straight from a password manager — e.g. 1Password's CLI (`op`). The **generated +code** comes from `?attribute=otp`: ```sh fledgling sync --otp "$(op read "op://Private/npm/Security/one-time password?attribute=otp")" ``` …or read the **secret itself** — the field's value with no attribute, an `otpauth://` URI — -into `FLEDGLING_OTP_SECRET`, and fledgling mints a fresh code for every npm call: +into `FLEDGLING_OTP_SECRET`: ```sh FLEDGLING_OTP_SECRET="$(op read "op://Private/npm/Security/one-time password")" fledgling sync ``` +
+ ### Config flags Better set once in `package.json` (see [Configuration](#configuration)); as flags they override the config for that run. diff --git a/src/args.ts b/src/args.ts index a2fa728..bb878ef 100644 --- a/src/args.ts +++ b/src/args.ts @@ -21,7 +21,7 @@ export const npmArgs = { force: { type: 'boolean', description: 'Replace an existing trusted publisher (revoke + re-create)' }, 'placeholder-version': { type: 'string', default: '0.0.0', description: 'Placeholder version to publish' }, tag: { type: 'string', description: 'dist-tag for placeholders' }, - otp: { type: 'string', description: 'npm 2FA one-time password (used for every npm call this run)' }, + otp: { type: 'string', description: 'npm 2FA one-time password, used for every npm call this run (also $NPM_CONFIG_OTP)' }, 'otp-secret': { type: 'string', description: 'TOTP secret to generate 2FA codes from (use $FLEDGLING_OTP_SECRET to avoid shell history)' }, // config — best set once in package.json "fledgling" (run `fledgling init`); flags override. // No gunshi defaults here, so config can fill them in. diff --git a/src/core.ts b/src/core.ts index 1d1778c..42299c5 100644 --- a/src/core.ts +++ b/src/core.ts @@ -82,7 +82,9 @@ export function buildSettings( contextIds: values['context-id'] ?? config.contextIds, version: values['placeholder-version'] ?? '0.0.0', tag: values.tag, - otp: values.otp, + // NPM_CONFIG_OTP is npm's own env var — npm would honor it on its own, but reading it + // here too keeps fledgling's "do we need interactive 2FA?" checks in sync with reality. + otp: values.otp ?? process.env.NPM_CONFIG_OTP, otpSecret: values['otp-secret'] ?? process.env.FLEDGLING_OTP_SECRET, }; } diff --git a/src/interactive.ts b/src/interactive.ts index f028d4e..ba94ff8 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -261,7 +261,7 @@ export async function runWizard(values: Record, selectors: string[] contextIds, version: values['placeholder-version'] ?? '0.0.0', tag: values.tag, - otp: values.otp, + otp: values.otp ?? process.env.NPM_CONFIG_OTP, otpSecret: values['otp-secret'] ?? process.env.FLEDGLING_OTP_SECRET, }; @@ -269,7 +269,7 @@ export async function runWizard(values: Record, selectors: string[] // publish, the claim's `npm publish` warms that cache for the trust write seconds // later, so neither re-prompts. When we're only setting up trust (no claim), nothing // has authenticated yet and our trust reads can't prompt — so warm the cache once - // here against an existing package. (Skipped when --otp / --otp-secret was passed.) + // here against an existing package. (Skipped when a code/secret was supplied — flag or env.) const interactiveAuth = apply && !settings.otp && !settings.otpSecret; if (interactiveAuth && (!skipTrust || !skipPublish)) p.log.info(otpBoxReminder); if (interactiveAuth && !skipTrust && skipPublish) {