bwx 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, sessio
17
17
  The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
18
18
 
19
19
  - **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
20
- - **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`, passed to `bw` through `BW_SESSION` so it never appears in a process command line
20
+ - **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`; concurrent callers share one bounded login/unlock attempt, and the token reaches `bw` through `BW_SESSION` rather than a process command line
21
21
  - **Structured output** — `--json` and `--plain` flags for machine-parseable output
22
22
  - **Leak-resistant** — listings never print secrets, `bwx run` hands them to a child process instead of stdout
23
23
  - **No interaction** — credentials from config + Keychain, never prompts
@@ -70,6 +70,7 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
70
70
  | Command | Description |
71
71
  |---------|-------------|
72
72
  | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
73
+ | `bwx get totp <item>` | TOTP code (`--fresh [n]`, `--seed`) |
73
74
  | `bwx field <name> <item>` | Get custom field by name (same argument order as `get`) |
74
75
  | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
75
76
  | `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
@@ -79,10 +80,17 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
79
80
  | `bwx attach list <item>` | List attachments for an item |
80
81
  | `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
81
82
 
82
- `search` and `list` print the same reduced projection — id, type, name, username — in
83
- human, `--plain`, and `--json` form, so asking for machine-readable output never widens
84
- what is exposed. `--full-items` is the explicit opt-in to complete item objects, which
85
- carry passwords, notes, and custom fields.
83
+ `search` and `list` print the same reduced projection — id, type, name, username,
84
+ hasTotp — in human, `--plain`, and `--json` form, so asking for machine-readable output
85
+ never widens what is exposed. `--full-items` is the explicit opt-in to complete item
86
+ objects, which carry passwords, notes, and custom fields.
87
+
88
+ `hasTotp` reports that an item has a TOTP secret without carrying it, so finding your 2FA
89
+ items does not mean dumping the vault:
90
+
91
+ ```bash
92
+ bwx list --json --limit 0 | jq -r '.data[] | select(.hasTotp) | .name'
93
+ ```
86
94
 
87
95
  A lookup that finds nothing suggests what you probably meant, and one that matches
88
96
  several items lists them, so neither ends in a guessing round:
@@ -95,6 +103,42 @@ error: No item matching "GitHub PAT vforsh old". Did you mean:
95
103
  3683fb9a-… note GitHub PAT (yaforsh) [codex-monitor-pr]
96
104
  ```
97
105
 
106
+ ### TOTP
107
+
108
+ Codes are computed locally from the stored secret. A TOTP therefore costs no extra `bw`
109
+ spawn and batches with the other fields of the same item, and bwx can report how long the
110
+ code has left.
111
+
112
+ ```bash
113
+ bwx get totp "AWS" # code on stdout, "Valid for 17s" on stderr
114
+ bwx get totp "AWS" --json # { "data": "123456", "meta": { "secondsRemaining": 17, "period": 30 } }
115
+ bwx get totp "AWS" --fresh # wait for the next window if under 5s remain
116
+ bwx get totp "AWS" --fresh 15 # ...or under 15s
117
+ bwx get totp "AWS" --seed # the stored secret, for moving it to another authenticator
118
+ ```
119
+
120
+ The remainder matters more than it looks: a vault read plus the time to actually use the
121
+ code often leaves single-digit seconds, and a code that expires in transit fails the login
122
+ without saying why. `--fresh` sleeps at most one period and hands back a full window.
123
+
124
+ Bare base32 and `otpauth://` URIs are both understood, including non-default `period`,
125
+ `digits`, and `algorithm` (SHA1/SHA256/SHA512). Secrets bwx will not compute exactly —
126
+ `steam://`, unknown algorithms, anything that fails to decode — fall back to `bw get totp`
127
+ rather than being guessed at, since a wrong code is indistinguishable from a right one
128
+ until the login fails.
129
+
130
+ Storing a secret:
131
+
132
+ ```bash
133
+ BWX_SEED="JBSWY3DPEHPK3PXP" bwx create --type login --name "AWS" --username me --totp-env BWX_SEED
134
+ bwx edit "AWS" --totp "otpauth://totp/AWS:me?secret=JBSWY3DPEHPK3PXP&issuer=AWS"
135
+ bwx edit "AWS" --rm-totp
136
+ ```
137
+
138
+ Spaces, hyphens, and `=` padding are stripped and case is normalized; an `otpauth://` URI
139
+ is stored verbatim so its parameters survive. Malformed secrets are rejected before the
140
+ write, because a bad seed is otherwise discovered at the login it was supposed to unlock.
141
+
98
142
  ### Listings are capped
99
143
 
100
144
  Listings stop at 50 items by default — an uncapped `bwx list` returns the whole vault,
@@ -153,15 +197,15 @@ The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no ext
153
197
 
154
198
  | Command | Description |
155
199
  |---------|-------------|
156
- | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--field-file k=path`) |
157
- | `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--add-field-file k=path`, `--rm-field name`) |
200
+ | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--totp-env`, `--field-file k=path`) |
201
+ | `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--totp`, `--rm-totp`, `--add-field-file k=path`, `--rm-field name`) |
158
202
  | `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
159
203
  | `bwx restore <item>` | Restore an item from the trash |
160
204
  | `bwx generate` | Generate a password or passphrase (local, no vault access) |
161
205
 
162
- Login-only flags (`--username`, any `--password-*`, `--uri`) require a login item. A
163
- secure note has nowhere to put them, so passing them to one is an error rather than an
164
- exit 0 over a credential that was silently dropped.
206
+ Login-only flags (`--username`, any `--password-*` or `--totp-*`, `--uri`) require a login
207
+ item. A secure note has nowhere to put them, so passing them to one is an error rather
208
+ than an exit 0 over a credential that was silently dropped.
165
209
 
166
210
  `bwx delete` requires `--force` whenever it is not talking to a terminal. The confirmation
167
211
  prompt cannot render for a script or an agent, so without it `--force` would be decorative
@@ -217,6 +261,7 @@ bwx create --name "Acme" --field-file '!API Key=./token.txt' --field-env account
217
261
  Supported source flags:
218
262
 
219
263
  - Passwords: `--password-stdin`, `--password-file <path>`, `--password-env <name>`
264
+ - TOTP secrets: `--totp-stdin`, `--totp-file <path>`, `--totp-env <name>`
220
265
  - Notes: `--notes-file <path>`
221
266
  - Create fields: `--field-file k=path`, `--field-env k=ENV`
222
267
  - Edit fields: `--add-field-file k=path`, `--add-field-env k=ENV`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
@@ -11,7 +11,8 @@ Extended Bitwarden CLI. Wraps `bw` with auto-unlock, session caching, and struct
11
11
 
12
12
  ```bash
13
13
  bwx get password "GitHub" # retrieve a password (auto-unlocks)
14
- bwx get totp "AWS (user@example.com)" # TOTP code
14
+ bwx get totp "AWS (user@example.com)" # TOTP code (+ seconds left, on stderr)
15
+ bwx get totp "AWS" --fresh # wait out a nearly-expired code first
15
16
  bwx get username "GitHub" # username
16
17
  bwx get notes "Deploy key" # secure note content
17
18
  bwx get uri "GitHub" # first URI
@@ -49,6 +50,27 @@ Anything else is looked up as a custom field. Lists available fields on miss.
49
50
  A miss suggests near matches; an ambiguous name lists candidates with IDs. Read the
50
51
  suggestions instead of retrying variations — the answer is usually in them.
51
52
 
53
+ ### `bwx get totp <item>`
54
+
55
+ Codes are computed locally from the stored secret, so a TOTP costs no extra `bw` spawn and
56
+ batches with other fields from the same item.
57
+
58
+ ```bash
59
+ bwx get totp "AWS" # code on stdout, "Valid for 17s" on stderr
60
+ bwx get totp "AWS" --json # { "data": "123456", "meta": { "secondsRemaining", "period" } }
61
+ bwx get totp "AWS" --fresh # wait for the next window if under 5s remain
62
+ bwx get totp "AWS" --fresh 15 # ...or under 15s
63
+ bwx get totp "AWS" --seed # the stored secret, for migrating to another app
64
+ ```
65
+
66
+ **Check `secondsRemaining` before using a code.** A `bw` read plus the time to hand the code
67
+ over routinely leaves under 5 seconds, and a code that expires in transit fails the login
68
+ with no useful error. `--fresh` sleeps at most one period and returns a full-window code.
69
+
70
+ Accepts bare base32 and `otpauth://` URIs, honoring their `period`, `digits`, and
71
+ `algorithm` (SHA1/SHA256/SHA512). `steam://` secrets and anything else bwx cannot compute
72
+ exactly are delegated to `bw get totp` instead of guessed at.
73
+
52
74
  ### `bwx field <name> <item>`
53
75
 
54
76
  Custom fields only, so an item whose custom field is named `password` is still reachable.
@@ -59,8 +81,13 @@ Takes its arguments in the same order as `get` (this changed in 0.5.0).
59
81
  Flags: `--type login|note|card|identity`, `--folder <name|id>`, `--limit <n>`, `--full-items`
60
82
 
61
83
  Human, `--plain`, and `--json` output all carry the same reduced projection — `id`, `type`,
62
- `name`, `username` — so listings never leak passwords, notes, or custom fields. `--full-items`
63
- opts into complete item objects and **does** include secrets; avoid it in logged sessions.
84
+ `name`, `username`, `hasTotp` — so listings never leak passwords, notes, or custom fields.
85
+ `--full-items` opts into complete item objects and **does** include secrets; avoid it in
86
+ logged sessions.
87
+
88
+ `hasTotp` says whether an item has a TOTP secret, never what it is. Use it to find 2FA items
89
+ (`bwx list --json --limit 0 | jq '.data[]|select(.hasTotp)'`) instead of reaching for
90
+ `--full-items`, which would dump the whole vault in plaintext to answer a boolean.
64
91
 
65
92
  **Listings stop at 50 items** unless you pass `--limit` (`--limit 0` for all). Truncation is
66
93
  reported on stderr and in `meta` on the JSON envelope — check it before concluding a listing
@@ -99,10 +126,10 @@ Downloads an attachment by ID or filename. Defaults to saving as the attachment
99
126
 
100
127
  ### `bwx create [options]`
101
128
 
102
- Flags: `--type login|note`, `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--uri` (repeatable), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder <name|id>`, `--favorite`, `--from-json`
129
+ Flags: `--type login|note`, `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--totp`, `--totp-stdin`, `--totp-file`, `--totp-env`, `--uri` (repeatable), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder <name|id>`, `--favorite`, `--from-json`
103
130
 
104
- `--username`, any `--password-*`, and `--uri` require `--type login`; a note cannot hold
105
- them and passing them to one is now an error rather than a silent drop.
131
+ `--username`, any `--password-*` or `--totp-*`, and `--uri` require `--type login`; a note
132
+ cannot hold them and passing them to one is now an error rather than a silent drop.
106
133
 
107
134
  Field syntax: `k=v` (text), `!k=v` (hidden), `bool:k=v` (boolean). The same key syntax works for `--field-file` and `--field-env`, where the value is a path or environment variable name. Stdin is read as `--notes` when piped; prefer `--password-stdin` for login passwords.
108
135
 
@@ -114,7 +141,24 @@ bwx edit "API Key" --add-field region=us-east-1
114
141
  printf '%s' "$TOKEN" | bwx edit "GitHub" --password-stdin --uri https://github.com
115
142
  bwx edit "Note" --rm-field old-key
116
143
  ```
117
- Flags: `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--uri` (replaces all), `--add-field k=v`, `--add-field-file k=path`, `--add-field-env k=ENV`, `--rm-field name`, `--folder <name|id>` (`none` unfiles), `--favorite/--no-favorite`, `--from-json`
144
+ Flags: `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--totp`, `--totp-stdin`, `--totp-file`, `--totp-env`, `--rm-totp`, `--uri` (replaces all), `--add-field k=v`, `--add-field-file k=path`, `--add-field-env k=ENV`, `--rm-field name`, `--folder <name|id>` (`none` unfiles), `--favorite/--no-favorite`, `--from-json`
145
+
146
+ ### Storing a TOTP secret
147
+
148
+ ```bash
149
+ BWX_SEED="JBSWY3DPEHPK3PXP" bwx create --type login --name "AWS" --username me --totp-env BWX_SEED
150
+ bwx edit "AWS" --totp "otpauth://totp/AWS:me?secret=JBSWY3DPEHPK3PXP&issuer=AWS"
151
+ bwx edit "AWS" --rm-totp
152
+ ```
153
+
154
+ Takes a bare base32 secret (spaces, hyphens, and `=` padding are stripped; case is
155
+ normalized) or a full `otpauth://` URI, which is stored verbatim so its `period`/`digits`/
156
+ `algorithm` survive. Malformed secrets are rejected **before** the write — a bad seed
157
+ otherwise sits in the vault until it fails the login it was meant to unlock, which is the
158
+ one moment it cannot be fixed.
159
+
160
+ Prefer `--totp-env` or `--totp-stdin` over `--totp`: a seed on the command line lands in
161
+ shell history and `ps` output.
118
162
 
119
163
  ### `bwx delete <item>`
120
164
 
@@ -186,7 +230,9 @@ even under `--json`; `-q` silences them.
186
230
  - Prefer `--password-generate` over generating a secret yourself and passing it in.
187
231
  - Request the narrowest field, resolving an exact item ID first.
188
232
  - Use `-q` for command substitution so unlock logs cannot contaminate merged output.
189
- - On writes, prefer `--password-stdin`, `--password-env`, `--field-file`, `--field-env`.
233
+ - On writes, prefer `--password-stdin`, `--password-env`, `--totp-stdin`, `--totp-env`, `--field-file`, `--field-env`.
234
+ - Check `secondsRemaining` on a TOTP, or pass `--fresh`, before handing the code anywhere.
235
+ - Use `hasTotp` on a listing to find 2FA items; never `--full-items` for that.
190
236
  - Never echo, log, checksum, or commit retrieved values; `unset` temporary variables.
191
237
 
192
238
  ## Auth
@@ -40,6 +40,7 @@ export interface NewItemOptions {
40
40
  notes?: string | null;
41
41
  username?: string | null;
42
42
  password?: string | null;
43
+ totp?: string | null;
43
44
  uris?: string[];
44
45
  fields?: ParsedField[];
45
46
  folderId?: string | null;
@@ -65,7 +66,7 @@ export function buildNewItem(options: NewItemOptions): Record<string, unknown> {
65
66
  item.login = {
66
67
  username: options.username ?? null,
67
68
  password: options.password ?? null,
68
- totp: null,
69
+ totp: options.totp ?? null,
69
70
  uris: options.uris?.length
70
71
  ? options.uris.map((uri) => ({ match: null, uri }))
71
72
  : null,
@@ -95,6 +96,8 @@ export interface PatchOptions {
95
96
  notes?: string;
96
97
  username?: string;
97
98
  password?: string;
99
+ /** `null` clears the stored secret; `undefined` leaves it alone. */
100
+ totp?: string | null;
98
101
  uris?: string[];
99
102
  addFields?: ParsedField[];
100
103
  rmFields?: string[];
@@ -115,10 +118,18 @@ export function patchItem(
115
118
  if (patch.favorite !== undefined) result.favorite = patch.favorite;
116
119
 
117
120
  // Login fields
118
- if (patch.username !== undefined || patch.password !== undefined || patch.uris !== undefined) {
119
- const login = (result.login as Record<string, unknown>) ?? {};
121
+ if (
122
+ patch.username !== undefined ||
123
+ patch.password !== undefined ||
124
+ patch.totp !== undefined ||
125
+ patch.uris !== undefined
126
+ ) {
127
+ // Copied, not aliased: `{ ...item }` above is shallow, so assigning into
128
+ // the original `login` would edit the caller's item as a side effect.
129
+ const login = { ...((result.login as Record<string, unknown>) ?? {}) };
120
130
  if (patch.username !== undefined) login.username = patch.username;
121
131
  if (patch.password !== undefined) login.password = patch.password;
132
+ if (patch.totp !== undefined) login.totp = patch.totp;
122
133
  if (patch.uris !== undefined) {
123
134
  login.uris = patch.uris.map((uri) => ({ match: null, uri }));
124
135
  }
package/src/bw/fields.ts CHANGED
@@ -4,6 +4,14 @@ import type { GlobalOptions } from "../cli/types.ts";
4
4
  import { summarizeItem, type ItemSummary } from "./items.ts";
5
5
  import { runBwOrThrow } from "./runner.ts";
6
6
  import { withSession } from "./session.ts";
7
+ import {
8
+ DEFAULT_PERIOD,
9
+ generateTotp,
10
+ parseTotpSecret,
11
+ secondsRemaining,
12
+ TotpUnsupportedError,
13
+ type TotpCode,
14
+ } from "./totp.ts";
7
15
  import { BwItemSchema, type BwItem } from "./types.ts";
8
16
 
9
17
  /** Fields `bw get` resolves natively; anything else is a custom field name. */
@@ -16,16 +24,15 @@ const BUILTIN_FIELDS = [
16
24
  "item",
17
25
  ] as const;
18
26
 
19
- /**
20
- * `totp` is a computed code, not stored data — only `bw get totp` can derive it,
21
- * so it is the one field that cannot be served from a fetched item.
22
- */
23
- const DERIVED_FIELDS = new Set(["totp"]);
24
-
25
27
  function isBuiltinField(field: string): boolean {
26
28
  return (BUILTIN_FIELDS as readonly string[]).includes(field);
27
29
  }
28
30
 
31
+ /** True when `field` should be read as the item's TOTP rather than a custom field. */
32
+ function isTotpField(field: string, options?: ReadFieldOptions): boolean {
33
+ return field === "totp" && !options?.customOnly;
34
+ }
35
+
29
36
  export interface ReadFieldOptions {
30
37
  /**
31
38
  * Always resolve `field` as a custom field name, so an item whose custom
@@ -58,17 +65,85 @@ export async function readItemField(
58
65
  opts: GlobalOptions,
59
66
  options?: ReadFieldOptions,
60
67
  ): Promise<string> {
61
- return withLookupHelp(item, opts, () =>
62
- isBuiltinField(field) && !options?.customOnly
68
+ return withLookupHelp(item, opts, async () => {
69
+ if (isTotpField(field, options)) {
70
+ return (await resolveTotp(await fetchItem(item, opts), item, opts)).code;
71
+ }
72
+
73
+ return isBuiltinField(field) && !options?.customOnly
63
74
  ? readBuiltinField(field, item, opts)
64
- : readCustomField(field, item, opts),
75
+ : readCustomField(field, item, opts);
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Reads a TOTP with the time it has left. A code handed over with three seconds
81
+ * on it is usually dead before it is pasted, so callers need the remainder to
82
+ * decide whether to wait for the next window.
83
+ */
84
+ export async function readTotp(
85
+ item: string,
86
+ opts: GlobalOptions,
87
+ ): Promise<TotpCode> {
88
+ return withLookupHelp(item, opts, async () =>
89
+ resolveTotp(await fetchItem(item, opts), item, opts),
65
90
  );
66
91
  }
67
92
 
93
+ /** The stored secret itself, for moving a seed back out to another authenticator. */
94
+ export async function readTotpSecret(
95
+ item: string,
96
+ opts: GlobalOptions,
97
+ ): Promise<string> {
98
+ return withLookupHelp(item, opts, async () => {
99
+ const fetched = await fetchItem(item, opts);
100
+ return requireStoredTotp(fetched);
101
+ });
102
+ }
103
+
104
+ function requireStoredTotp(item: BwItem): string {
105
+ const stored = item.login?.totp;
106
+ if (!stored) {
107
+ throw new FieldMissingError(
108
+ `No totp found for item: ${item.name}`,
109
+ ExitCode.NotFound,
110
+ );
111
+ }
112
+ return stored;
113
+ }
114
+
115
+ /**
116
+ * Computes the code from the secret already in hand, falling back to `bw get
117
+ * totp` for the shapes this codebase will not compute itself (see `totp.ts`).
118
+ * The fallback costs a second spawn, which is the correct trade against
119
+ * emitting a confidently wrong six digits.
120
+ */
121
+ async function resolveTotp(
122
+ fetched: BwItem,
123
+ item: string,
124
+ opts: GlobalOptions,
125
+ ): Promise<TotpCode> {
126
+ const stored = requireStoredTotp(fetched);
127
+
128
+ try {
129
+ return generateTotp(parseTotpSecret(stored));
130
+ } catch (err) {
131
+ if (!(err instanceof TotpUnsupportedError)) throw err;
132
+
133
+ return {
134
+ code: await readBuiltinField("totp", item, opts),
135
+ period: DEFAULT_PERIOD,
136
+ secondsRemaining: secondsRemaining(DEFAULT_PERIOD),
137
+ };
138
+ }
139
+ }
140
+
68
141
  /**
69
142
  * Resolves many fields at once, paying a single `bw get item` per distinct item
70
143
  * rather than one `bw` spawn per field. Each spawn costs ~2.5s, so a caller
71
- * pulling a username and password from one item halves its own runtime.
144
+ * pulling a username and password from one item halves its own runtime. `totp`
145
+ * joins that batch too, since the code is computed from the secret the fetch
146
+ * already returned.
72
147
  *
73
148
  * Returns values positionally aligned with `requests`.
74
149
  */
@@ -88,34 +163,25 @@ export async function readFields(
88
163
  for (const [item, indexes] of byItem) {
89
164
  // A single `bw` process at a time: each read may trigger an unlock, and
90
165
  // concurrent unlocks would race over the cached session.
91
- const derived = indexes.filter((i) => isDerived(requests[i]!));
92
- const stored = indexes.filter((i) => !isDerived(requests[i]!));
166
+ const fetched = await withLookupHelp(item, opts, () => fetchItem(item, opts));
93
167
 
94
- if (stored.length > 0) {
95
- const fetched = await withLookupHelp(item, opts, () =>
96
- fetchItem(item, opts),
97
- );
98
- for (const index of stored) {
99
- const request = requests[index]!;
168
+ for (const index of indexes) {
169
+ const request = requests[index]!;
170
+
171
+ if (isTotpField(request.field, request.options)) {
172
+ const totp = await withLookupHelp(item, opts, () =>
173
+ resolveTotp(fetched, item, opts),
174
+ );
175
+ values[index] = totp.code;
176
+ } else {
100
177
  values[index] = extractField(fetched, request.field, request.options);
101
178
  }
102
179
  }
103
-
104
- for (const index of derived) {
105
- const request = requests[index]!;
106
- values[index] = await withLookupHelp(item, opts, () =>
107
- readBuiltinField(request.field, item, opts),
108
- );
109
- }
110
180
  }
111
181
 
112
182
  return values;
113
183
  }
114
184
 
115
- function isDerived(request: FieldRequest): boolean {
116
- return !request.options?.customOnly && DERIVED_FIELDS.has(request.field);
117
- }
118
-
119
185
  /**
120
186
  * Turns bw's bare "Not found." and "More than one result" into errors that name
121
187
  * the candidates, so a caller can correct itself without a round of guessing.
@@ -165,14 +231,28 @@ function extractField(
165
231
 
166
232
  if (field === "item") return JSON.stringify(item);
167
233
 
168
- const value =
169
- field === "notes"
170
- ? item.notes
171
- : field === "uri"
172
- ? (item.login?.uris?.[0]?.uri ?? null)
173
- : field === "username"
174
- ? (item.login?.username ?? null)
175
- : (item.login?.password ?? null);
234
+ // Named exhaustively rather than defaulted: a chain ending in `password`
235
+ // would hand back the password for any field it failed to recognize.
236
+ let value: string | null;
237
+ switch (field) {
238
+ case "notes":
239
+ value = item.notes;
240
+ break;
241
+ case "uri":
242
+ value = item.login?.uris?.[0]?.uri ?? null;
243
+ break;
244
+ case "username":
245
+ value = item.login?.username ?? null;
246
+ break;
247
+ case "password":
248
+ value = item.login?.password ?? null;
249
+ break;
250
+ default:
251
+ throw new FieldMissingError(
252
+ `Field "${field}" cannot be read from a fetched item`,
253
+ ExitCode.NotFound,
254
+ );
255
+ }
176
256
 
177
257
  if (!value) {
178
258
  throw new FieldMissingError(
package/src/bw/items.ts CHANGED
@@ -10,16 +10,24 @@ export interface ItemSummary {
10
10
  type: string;
11
11
  name: string;
12
12
  username: string | null;
13
+ /**
14
+ * Whether the item carries a TOTP secret — the fact, never the secret.
15
+ * Without it, finding which items have 2FA means `--full-items`, which dumps
16
+ * every password and note in the vault to answer a boolean.
17
+ */
18
+ hasTotp: boolean;
13
19
  }
14
20
 
15
21
  export function summarizeItem(item: Record<string, unknown>): ItemSummary {
16
22
  const login = item.login as Record<string, unknown> | null | undefined;
17
23
  const username = login?.username;
24
+ const totp = login?.totp;
18
25
 
19
26
  return {
20
27
  id: String(item.id ?? ""),
21
28
  type: BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`,
22
29
  name: String(item.name ?? ""),
23
30
  username: typeof username === "string" && username ? username : null,
31
+ hasTotp: typeof totp === "string" && totp.length > 0,
24
32
  };
25
33
  }
package/src/bw/session.ts CHANGED
@@ -2,9 +2,16 @@ import { unlinkSync } from "node:fs";
2
2
  import { CliError, ExitCode } from "../cli/errors.ts";
3
3
  import type { GlobalOptions } from "../cli/types.ts";
4
4
  import { emitLog, emitWarn } from "../cli/io.ts";
5
- import { SESSION_FILE } from "../config/paths.ts";
5
+ import { SESSION_FILE, SESSION_LOCK_DIR } from "../config/paths.ts";
6
+ import { withProcessLock } from "../config/process-lock.ts";
6
7
  import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
7
- import { runBw, runBwOrThrow, setSession, getSession } from "./runner.ts";
8
+ import {
9
+ DEFAULT_TIMEOUT_MS,
10
+ runBw,
11
+ runBwOrThrow,
12
+ setSession,
13
+ getSession,
14
+ } from "./runner.ts";
8
15
  import { checkVaultFreshness } from "./freshness.ts";
9
16
  import { getStatus } from "./status.ts";
10
17
  import { BwStatusSchema } from "./types.ts";
@@ -96,13 +103,36 @@ export async function probeSessionState(
96
103
  }
97
104
 
98
105
  export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
99
- // Try cached session first
106
+ // Keep the ordinary valid-session path entirely outside the process lock.
100
107
  const cached = getSession() || loadCachedSession(opts);
101
108
  if (cached) {
102
109
  setSession(cached);
103
110
  if (await sessionWorks(cached)) return;
111
+ setSession(null);
104
112
  }
105
113
 
114
+ await withProcessLock(
115
+ {
116
+ path: SESSION_LOCK_DIR,
117
+ label: "Bitwarden session establishment",
118
+ waitMs: sessionLockWaitMs(opts),
119
+ },
120
+ async () => {
121
+ // A process that waited must use the winner's disk cache, not the stale
122
+ // token it may still have held in memory before entering the lock.
123
+ const winner = loadCachedSession(opts);
124
+ if (winner) {
125
+ setSession(winner);
126
+ if (await sessionWorks(winner)) return;
127
+ setSession(null);
128
+ }
129
+
130
+ await establishSession(opts);
131
+ },
132
+ );
133
+ }
134
+
135
+ async function establishSession(opts: GlobalOptions): Promise<void> {
106
136
  const status = await getStatus();
107
137
 
108
138
  if (status.status === "unlocked") {
@@ -152,6 +182,13 @@ export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
152
182
  saveCachedSession(token);
153
183
  }
154
184
 
185
+ /** Four sequential `bw` calls cover stale validation plus status/login/unlock. */
186
+ function sessionLockWaitMs(opts: GlobalOptions): number {
187
+ const perCall =
188
+ opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
189
+ return Math.min(Math.max(perCall * 4 + 10_000, 10_000), 5 * 60_000);
190
+ }
191
+
155
192
  export async function lockVault(): Promise<void> {
156
193
  await runBw(["lock"], { session: null });
157
194
  setSession(null);
@@ -197,9 +234,9 @@ async function withAuthRetry<T>(
197
234
  return await fn();
198
235
  } catch (err) {
199
236
  if (err instanceof CliError && err.exitCode === ExitCode.AuthFailed) {
200
- // Clear stale session and re-auth
237
+ // Do not unlink the shared cache here: another process may already have
238
+ // atomically replaced our stale token with a freshly unlocked session.
201
239
  setSession(null);
202
- clearCachedSession();
203
240
  await ensureUnlocked(opts);
204
241
  return await fn();
205
242
  }