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) {