Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/npm-otp-env-var.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 30 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <v>` | Placeholder version (default: `0.0.0`) |
| `--tag <tag>` | dist-tag for placeholders (default: `latest`) |
| `--otp <code>` | npm 2FA one-time password, used for every npm call this run |
| `--otp-secret <secret>` | TOTP secret to generate 2FA codes from as needed (also `$FLEDGLING_OTP_SECRET`) |
| `--otp <code>` | *(legacy)* npm 2FA one-time password, used for every npm call this run (also `$NPM_CONFIG_OTP`) |
| `--otp-secret <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.

<details>
<summary><strong>Passing a one-time code instead (legacy)</strong></summary>

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 <code>`** — a single one-time password, reused for every npm call in the run.
- **`NPM_CONFIG_OTP=<code>`** — 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 <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
```

</details>

### Config flags

Better set once in `package.json` (see [Configuration](#configuration)); as flags they override the config for that run.
Expand Down
2 changes: 1 addition & 1 deletion src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,15 +261,15 @@ export async function runWizard(values: Record<string, any>, 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,
};

// npm manages 2FA itself — an interactive browser approval, cached ~5 min. When we
// 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) {
Expand Down