bwx 0.8.0 → 0.10.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladislav Forsh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -69,15 +69,15 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
69
69
 
70
70
  | Command | Description |
71
71
  |---------|-------------|
72
- | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`), `--raw` |
72
+ | `bwx get <field> <item>` / `bwx get <ref>` | Get a field or discovered reference, `--raw` |
73
73
  | `bwx get totp <item>` | TOTP code (`--fresh [n]`, `--seed`) |
74
74
  | `bwx get <card field> <item>` | Card field (`number`, `cvv`, `cardholder`, `brand`, `expiry`, …) |
75
75
  | `bwx field <name> <item>` | Get custom field by name (same argument order as `get`), `--raw` |
76
- | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
76
+ | `bwx search <query>` / `bwx find <query>` | Search items (`--fields`, `--type`, `--folder`, `--limit`, `--full-items`) |
77
77
  | `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
78
78
  | `bwx folders` | List folder names and IDs |
79
79
  | `bwx trash` | List items in the trash |
80
- | `bwx run --env NAME=<field>:<item> -- <cmd>` | Run a command with secrets in its environment |
80
+ | `bwx run --env NAME=<field>:<item> -- <cmd>` / `--env NAME=<ref>` | Run a command with secrets in its environment |
81
81
  | `bwx attach list <item>` | List attachments for an item |
82
82
  | `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
83
83
 
@@ -104,6 +104,63 @@ error: No item matching "GitHub PAT vforsh old". Did you mean:
104
104
  3683fb9a-… note GitHub PAT (yaforsh) [codex-monitor-pr]
105
105
  ```
106
106
 
107
+ ### Discover fields without values
108
+
109
+ `search` (alias `find`) accepts `--fields` to return only item `id`, `type`, `name`,
110
+ and `fields`. Each field has `kind` (`builtin` or `custom`), `name`, `type`, and a
111
+ consumable `ref`. Even usernames and URIs are described without their values in this
112
+ mode. `--fields` cannot be combined with `--full-items`.
113
+
114
+ ```bash
115
+ bwx find "Deploy token" --fields --json
116
+ # { "data": [{ "id": "11111111-2222-3333-4444-555555555555", "type": "note",
117
+ # "name": "Deploy token", "fields": [{ "kind": "builtin", "name": "notes",
118
+ # "type": "text", "ref": "bwx://11111111-2222-3333-4444-555555555555/builtin/notes" }] }],
119
+ # "meta": { "total": 1, "shown": 1, "truncated": false } }
120
+
121
+ # Copy the ref from discovery. Only the child receives the token.
122
+ bwx run --env 'TOKEN=bwx://11111111-2222-3333-4444-555555555555/builtin/notes' -- ./deploy.sh
123
+
124
+ # Or pipe the referenced bytes directly to a consumer; get itself prints the value.
125
+ bwx get 'bwx://11111111-2222-3333-4444-555555555555/builtin/notes' --raw | ./consume-token
126
+ ```
127
+
128
+ Supported capabilities:
129
+
130
+ - Nonempty `notes` on every item type, including Secure Notes containing API tokens.
131
+ - Login `username`, `password`, first `uri`, and `totp` when present. A TOTP reference
132
+ resolves to a code using the existing local computation or `bw` fallback; it does not
133
+ carry the seed. `get <totp-ref>` also supports `--fresh` and `--seed`.
134
+ - Card `number`, `cvv`, `cardholder`, `brand`, `expiry`, `expMonth`, `expYear` when
135
+ available, with the same normalization as `get`. `expiry` needs both month and year.
136
+ - Custom text, hidden, and boolean fields on every item type, including empty values
137
+ (`null` is read as an empty string). Linked and unknown custom types are omitted.
138
+ Identity-specific and full-item fields are not advertised.
139
+
140
+ Field `type` is `text`, `hidden`, `boolean`, or `totp`; these are capability labels,
141
+ not a claim that text fields are safe to print. Notes often hold secrets.
142
+
143
+ References use `bwx://<item-id>/builtin/<canonical-field>` or
144
+ `bwx://<item-id>/custom/<zero-based-index>/<percent-encoded-name>`. Custom names may
145
+ contain spaces, colons, slashes, percent signs, Unicode, or be empty. A custom field
146
+ named `password` has its own reference and never resolves to the login password.
147
+ Copy references verbatim and quote them in shell commands.
148
+
149
+ Item renaming does not change references: reads use the exact ID and verify it in the
150
+ response. Custom references address a position and check the name there, which also
151
+ distinguishes duplicate names. Rediscover after renaming/reordering/removing custom
152
+ fields; a changed name at that position fails, while swapping identical names can
153
+ retarget the reference because Bitwarden supplies no custom-field IDs. References
154
+ resolve current values from the local vault; they are not snapshots or access grants.
155
+
156
+ Filters, the default 50-item cap, `--limit 0`, and truncation warnings/meta work as usual.
157
+ Human output groups fields under each item; `--plain` has four tab-separated columns:
158
+ `id`, `type`, JSON-quoted `name`, and a compact JSON `fields` array, one item per line.
159
+ JSON escaping preserves tabs/newlines in names without breaking rows. Discovery uses
160
+ the existing list response without extra per-item reads. Vault errors and malformed
161
+ responses are reported without raw diagnostics or value-bearing parse excerpts,
162
+ including under `-v`.
163
+
107
164
  ### Piping a secret
108
165
 
109
166
  `get` and `field` end their output with a newline, which reads fine in a terminal and
@@ -202,6 +259,31 @@ process](#secrets-into-a-child-process):
202
259
  bwx run --env NUM=number:Visa --env CVV=cvv:Visa --env EXP=expiry:Visa -- ./pay.sh
203
260
  ```
204
261
 
262
+ Storing a card:
263
+
264
+ ```bash
265
+ bwx create --type card --name "Visa Work" --cardholder "Vladislav Forsh" \
266
+ --expiry 08/29 --number-stdin --cvv-env CARD_CVV < number.txt
267
+ bwx edit "Visa Work" --expiry 09/31 --cvv-stdin # changes only these fields
268
+ bwx edit "Visa Work" --rm-cvv --rm-brand
269
+ ```
270
+
271
+ The number and CVV take the same sources as a password (`--number`, `--number-stdin`,
272
+ `--number-file`, `--number-env`; `--cvv*` likewise). Values are checked before the write
273
+ and errors never echo them:
274
+
275
+ - Number: spaces and dashes are stripped, then it must be digits and pass a Luhn check
276
+ (`--no-validate` skips Luhn for test or private-label numbers).
277
+ - CVV: 3–4 digits.
278
+ - Expiry: `--expiry MM/YY|MM/YYYY`, or `--exp-month 1-12` / `--exp-year YYYY`; a two-digit
279
+ year becomes `20YY`.
280
+ - Brand: any casing of `Visa`, `Mastercard`, `Amex`, `Discover`, `Diners Club`, `JCB`,
281
+ `Maestro`, `UnionPay`, `RuPay`, `Mir`, `Other`. Omitted, it is inferred from the number
282
+ when the prefix is unambiguous — on `edit`, only onto a card that has no brand yet.
283
+
284
+ `edit` clears with `--rm-number`, `--rm-cvv`, `--rm-cardholder`, `--rm-brand`,
285
+ `--rm-expiry`. Card flags on a non-card item, and login flags on a card, are usage errors.
286
+
205
287
  ### Listings are capped
206
288
 
207
289
  Listings stop at 50 items by default — an uncapped `bwx list` returns the whole vault,
@@ -238,11 +320,15 @@ bwx run --env DB_PASS=password:'Prod DB' --env TOKEN='API Key:Acme' -- ./deploy.
238
320
 
239
321
  - `--env NAME=<field>:<item>` is repeatable; the field resolves exactly as in `bwx get`
240
322
  (built-ins first, otherwise a custom field name).
323
+ - `--env NAME=<ref>` accepts references from `search --fields`, including explicit
324
+ custom-field references. Several references to the same item share one vault read.
241
325
  - The item may contain colons — only the first one separates field from item.
242
326
  - Use `--` before the command whenever it takes its own flags.
243
327
  - The child inherits stdio and bwx exits with the child's status (`128 + signal` when killed).
244
328
  - `-v` logs the injected variable *names*; values are never logged.
245
329
 
330
+ The child inherits stdout/stderr, so choose a consumer that does not print its secrets.
331
+
246
332
  ### Vault freshness
247
333
 
248
334
  `bw` answers reads from the local vault copy, so a vault that has not synced in a while
@@ -260,15 +346,16 @@ The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no ext
260
346
 
261
347
  | Command | Description |
262
348
  |---------|-------------|
263
- | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--totp-env`, `--field-file k=path`) |
264
- | `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--totp`, `--rm-totp`, `--add-field-file k=path`, `--rm-field name`) |
349
+ | `bwx create` | Create item (`--type login\|note\|card`, `--name`, `--username`, `--password-stdin`, `--totp-env`, `--number-stdin`, `--cvv-env`, `--expiry`, `--field-file k=path`) |
350
+ | `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--totp`, `--rm-totp`, `--expiry`, `--cvv-stdin`, `--rm-cvv`, `--add-field-file k=path`, `--rm-field name`) |
265
351
  | `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
266
352
  | `bwx restore <item>` | Restore an item from the trash |
267
353
  | `bwx generate` | Generate a password or passphrase (local, no vault access) |
268
354
 
269
355
  Login-only flags (`--username`, any `--password-*` or `--totp-*`, `--uri`) require a login
270
- item. A secure note has nowhere to put them, so passing them to one is an error rather
271
- than an exit 0 over a credential that was silently dropped.
356
+ item, and card flags (`--cardholder`, `--brand`, `--expiry`, `--exp-*`, `--number*`, `--cvv*`,
357
+ `--rm-*` for them) a card item. Any other type has nowhere to put them, so passing them is
358
+ an error rather than an exit 0 over a value that was silently dropped.
272
359
 
273
360
  `bwx delete` requires `--force` whenever it is not talking to a terminal. The confirmation
274
361
  prompt cannot render for a script or an agent, so without it `--force` would be decorative
@@ -325,6 +412,8 @@ Supported source flags:
325
412
 
326
413
  - Passwords: `--password-stdin`, `--password-file <path>`, `--password-env <name>`
327
414
  - TOTP secrets: `--totp-stdin`, `--totp-file <path>`, `--totp-env <name>`
415
+ - Card numbers: `--number-stdin`, `--number-file <path>`, `--number-env <name>`
416
+ - CVVs: `--cvv-stdin`, `--cvv-file <path>`, `--cvv-env <name>`
328
417
  - Notes: `--notes-file <path>`
329
418
  - Create fields: `--field-file k=path`, `--field-env k=ENV`
330
419
  - Edit fields: `--add-field-file k=path`, `--add-field-env k=ENV`
@@ -383,3 +472,7 @@ Breaking changes, all of them cases where the old behavior failed quietly:
383
472
  ## Stack
384
473
 
385
474
  Bun, TypeScript, Commander, Zod, picocolors.
475
+
476
+ ## License
477
+
478
+ MIT — see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,15 +1,34 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.8.0",
4
- "type": "module",
3
+ "version": "0.10.0",
4
+ "description": "Extended Bitwarden CLI for scripts and agents — auto-unlock via macOS Keychain, session caching, local TOTP, structured output",
5
5
  "keywords": [
6
6
  "bitwarden",
7
+ "bitwarden-cli",
7
8
  "cli",
8
9
  "vault",
9
10
  "secrets",
11
+ "secrets-management",
12
+ "totp",
13
+ "keychain",
14
+ "macos",
10
15
  "ai-agents",
11
16
  "bun"
12
17
  ],
18
+ "license": "MIT",
19
+ "author": "Vladislav Forsh <vforsh@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/vforsh/bwx.git"
23
+ },
24
+ "homepage": "https://github.com/vforsh/bwx#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/vforsh/bwx/issues"
27
+ },
28
+ "type": "module",
29
+ "engines": {
30
+ "bun": ">=1.0.0"
31
+ },
13
32
  "files": [
14
33
  "bin",
15
34
  "src",
@@ -24,10 +43,8 @@
24
43
  "cmd": "bun run bin/bwx"
25
44
  },
26
45
  "devDependencies": {
27
- "@types/bun": "latest"
28
- },
29
- "peerDependencies": {
30
- "typescript": "^5"
46
+ "@types/bun": "latest",
47
+ "typescript": "^7"
31
48
  },
32
49
  "dependencies": {
33
50
  "commander": "^14.0.3",
@@ -23,8 +23,9 @@ bwx field "API Key" "Acme" # custom-field-only lookup, same arg ord
23
23
  bwx get password "GitHub" --raw # no trailing newline, for piping into stdin
24
24
 
25
25
  bwx search github # fuzzy search, human table
26
- bwx search github --json # id/type/name/username only — no secrets
26
+ bwx search github --json # id/type/name/username/hasTotp — no secret contents
27
27
  bwx search github --type login --limit 5
28
+ bwx find "Deploy token" --fields --json # available fields and refs, never their values
28
29
 
29
30
  # hand a secret to a child process instead of printing it
30
31
  bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
@@ -40,15 +41,19 @@ bwx create --name "API Key" --notes "sk_live_abc123" --field account=acme
40
41
  printf '%s' "$TOKEN" | bwx create --type login --name "GitHub" --username user --password-stdin --uri https://github.com
41
42
  bwx create --name "Acme" --field-file '!API Key=./token.txt' --field-env account=ACME_ACCOUNT
42
43
  echo "secret" | bwx create --name "Piped Note"
44
+ bwx create --type card --name "Visa Work" --cardholder "Vladislav Forsh" --expiry 08/29 --number-env CARD_NUM --cvv-env CARD_CVV
43
45
  ```
44
46
 
45
47
  ## Commands
46
48
 
47
- ### `bwx get <field> <item>`
49
+ ### `bwx get <field> <item>` / `bwx get <ref>`
48
50
 
49
51
  Built-in fields: `password | username | totp | notes | uri | item`.
50
52
  Card items additionally take `number | cvv | cardholder | brand | expiry | expMonth | expYear`.
51
53
  Anything else is looked up as a custom field. Lists available fields on miss.
54
+ Discovered `bwx://` references also work as the sole argument, including with `--raw`.
55
+ `get` prints the referenced value: pipe it straight to a consumer instead of capturing
56
+ it in an agent's context. TOTP references support `--fresh` and `--seed` as usual.
52
57
 
53
58
  A miss suggests near matches; an ambiguous name lists candidates with IDs. Read the
54
59
  suggestions instead of retrying variations — the answer is usually in them.
@@ -119,6 +124,7 @@ card field.
119
124
  ### `bwx search <query>` / `bwx list [type]`
120
125
 
121
126
  Flags: `--type login|note|card|identity`, `--folder <name|id>`, `--limit <n>`, `--full-items`
127
+ `search` has alias `find` and additionally accepts `--fields` for field discovery.
122
128
 
123
129
  Human, `--plain`, and `--json` output all carry the same reduced projection — `id`, `type`,
124
130
  `name`, `username`, `hasTotp` — so listings never leak passwords, notes, or custom fields.
@@ -133,12 +139,56 @@ logged sessions.
133
139
  reported on stderr and in `meta` on the JSON envelope — check it before concluding a listing
134
140
  was complete. `--limit` rejects non-numeric values rather than ignoring them.
135
141
 
142
+ ### Discovery → consume a reference
143
+
144
+ Use `bwx search <query> --fields --json` to find an item and its available fields
145
+ without reading `get item` or `--full-items`. Output is an explicit allowlist:
146
+ item `id`, `type`, `name`, and `fields`; each field has only `kind`, `name`, `type`,
147
+ and `ref`. Username/URI values are omitted too. `--fields` conflicts with `--full-items`.
148
+
149
+ ```bash
150
+ bwx find "Deploy token" --fields --json
151
+ # A Secure Note with content advertises:
152
+ # { "kind": "builtin", "name": "notes", "type": "text",
153
+ # "ref": "bwx://11111111-2222-3333-4444-555555555555/builtin/notes" }
154
+
155
+ # Copy that ref into run. The token goes only to the consumer's environment.
156
+ bwx run --env 'TOKEN=bwx://11111111-2222-3333-4444-555555555555/builtin/notes' -- ./deploy.sh
157
+
158
+ # If the consumer needs stdin, pipe directly; do not inspect get's stdout.
159
+ bwx get 'bwx://11111111-2222-3333-4444-555555555555/builtin/notes' --raw | ./consume-token
160
+ ```
161
+
162
+ - Nonempty `notes` appear on all item types. **API tokens commonly live in notes on
163
+ Secure Notes**; `text` describes the field type and does not mean its content is public.
164
+ - Logins advertise nonempty `username`, `password`, first `uri`, and `totp`. TOTP
165
+ references yield codes through the existing computation/fallback, never the seed
166
+ unless explicitly consumed with `get <ref> --seed`.
167
+ - Cards advertise available canonical card fields with existing normalization;
168
+ `expiry` requires both month and year. Identity-specific/full-item fields are omitted.
169
+ - Custom text/hidden/boolean fields on any item type appear even with empty values;
170
+ null values resolve as empty strings. Linked/unknown types are omitted.
171
+ - `kind` separates builtins and custom fields; custom `password`, `totp`, `item`, or
172
+ `cvv` references select the custom field. Types: `text`, `hidden`, `boolean`, `totp`.
173
+ - Builtin syntax: `bwx://<item-id>/builtin/<canonical-field>`. Custom syntax:
174
+ `bwx://<item-id>/custom/<zero-based-index>/<percent-encoded-name>`. Copy verbatim and
175
+ shell-quote; empty names, duplicate names, Unicode and separators work.
176
+ - Item renaming preserves refs. Custom refs check the name at the recorded position:
177
+ rediscover after changing field names/order. Swapping identical names can retarget
178
+ refs, since Bitwarden has no custom-field IDs. Refs read current local values and
179
+ require ordinary vault access; they are not snapshots or access grants.
180
+ - Filters, caps, truncation warnings and JSON `meta` remain available. `--plain` emits
181
+ one item per line: `id`, `type`, JSON-quoted `name`, JSON `fields`, separated by tabs.
182
+ Human output groups fields under items. Neither prints values.
183
+ - Discovery and reference-read failures suppress raw vault diagnostics and parse
184
+ excerpts, including in verbose mode. They retain the error exit code.
185
+
136
186
  ### `bwx folders`
137
187
 
138
188
  Lists folder names and IDs. `--folder` accepts either on `list`, `search`, `create`, and
139
189
  `edit`; `none` selects unfiled items.
140
190
 
141
- ### `bwx run --env NAME=<field>:<item> -- <command>`
191
+ ### `bwx run --env NAME=<field>:<item> -- <command>` / `--env NAME=<ref>`
142
192
 
143
193
  Resolves secrets and injects them into the child's environment only — not stdout, not argv.
144
194
  This is the preferred way to give a subprocess a credential.
@@ -149,12 +199,14 @@ bwx run --env TOKEN='API Key:Acme' --env USER=username:Acme -- ./deploy.sh
149
199
  ```
150
200
 
151
201
  - `--env` is repeatable; the field resolves as in `bwx get` (built-in, else card, else custom field).
202
+ - A discovered `bwx://` ref selects an exact item ID and explicit builtin/custom field.
152
203
  - Only the first `:` splits field from item, so `password:http://my.router` works.
153
204
  - Put `--` before the command when it takes its own flags.
154
205
  - Child stdio is inherited; bwx exits with the child's status (`128 + signal` if killed).
155
206
  - `-v` logs injected variable names only, never values.
156
207
  - Several variables from one item cost a single vault read, so group them here rather
157
208
  than issuing separate `bwx get` calls.
209
+ - Child stdout/stderr are inherited: use a consumer that does not print its credentials.
158
210
 
159
211
  ### `bwx attach list <item>`
160
212
 
@@ -166,10 +218,10 @@ Downloads an attachment by ID or filename. Defaults to saving as the attachment
166
218
 
167
219
  ### `bwx create [options]`
168
220
 
169
- 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`
221
+ Flags: `--type login|note|card`, `--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), card flags (below), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder <name|id>`, `--favorite`, `--from-json`
170
222
 
171
- `--username`, any `--password-*` or `--totp-*`, and `--uri` require `--type login`; a note
172
- cannot hold them and passing them to one is now an error rather than a silent drop.
223
+ `--username`, any `--password-*` or `--totp-*`, and `--uri` require `--type login`; card
224
+ flags require `--type card`. Passing either to another type is an error, not a silent drop.
173
225
 
174
226
  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.
175
227
 
@@ -181,7 +233,22 @@ bwx edit "API Key" --add-field region=us-east-1
181
233
  printf '%s' "$TOKEN" | bwx edit "GitHub" --password-stdin --uri https://github.com
182
234
  bwx edit "Note" --rm-field old-key
183
235
  ```
184
- 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`
236
+ 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`, card flags and `--rm-number|cvv|cardholder|brand|expiry` (card items only)
237
+
238
+ ### Storing a card
239
+
240
+ ```bash
241
+ bwx create --type card --name "Visa Work" --cardholder "Vladislav Forsh" --brand Visa \
242
+ --expiry 08/29 --number-stdin --cvv-env CARD_CVV < number.txt
243
+ bwx edit "Visa Work" --expiry 09/31 --cvv-stdin # merges; other fields untouched
244
+ bwx edit "Visa Work" --rm-cvv --rm-brand
245
+ ```
246
+
247
+ Card flags: `--cardholder`, `--brand` (case-insensitive: Visa, Mastercard, Amex, Discover,
248
+ Diners Club, JCB, Maestro, UnionPay, RuPay, Mir, Other; inferred from the number if
249
+ omitted), `--expiry MM/YY|MM/YYYY` or `--exp-month`/`--exp-year`, `--number[-stdin|-file|-env]`,
250
+ `--cvv[-stdin|-file|-env]`. Number must pass Luhn (`--no-validate` skips it); CVV is 3–4
251
+ digits. Errors never echo the number or CVV.
185
252
 
186
253
  ### Storing a TOTP secret
187
254
 
@@ -266,6 +333,7 @@ even under `--json`; `-q` silences them.
266
333
  ## Safety rules
267
334
 
268
335
  - Never use `--full-items` (or `get item` for discovery) in logged sessions.
336
+ - Discover with `search/find --fields`; pass its `ref` straight to `run --env NAME=<ref>`.
269
337
  - Prefer `bwx run --env ...` over `export VAR="$(bwx get ...)"`; secrets never touch stdout or argv.
270
338
  - Prefer `--password-generate` over generating a secret yourself and passing it in.
271
339
  - Request the narrowest field, resolving an exact item ID first.
@@ -0,0 +1,180 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+
3
+ /**
4
+ * Normalizes card values on their way into the vault. Every check runs before
5
+ * the write, because a mistyped number or expiry is otherwise discovered at the
6
+ * checkout it was supposed to pay for.
7
+ *
8
+ * Errors name the flag and the rule, never the value: a card number or CVV in
9
+ * an error message ends up in a terminal, a transcript, or a log.
10
+ */
11
+
12
+ /** Bitwarden's own brand values, as its clients write and filter on them. */
13
+ export const CARD_BRANDS = [
14
+ "Visa",
15
+ "Mastercard",
16
+ "Amex",
17
+ "Discover",
18
+ "Diners Club",
19
+ "JCB",
20
+ "Maestro",
21
+ "UnionPay",
22
+ "RuPay",
23
+ "Mir",
24
+ "Other",
25
+ ] as const;
26
+
27
+ export type CardBrand = (typeof CARD_BRANDS)[number];
28
+
29
+ /** Spellings people reach for that are not the brand's canonical name. */
30
+ const BRAND_ALIASES: Record<string, CardBrand> = {
31
+ mc: "Mastercard",
32
+ americanexpress: "Amex",
33
+ diners: "Diners Club",
34
+ cup: "UnionPay",
35
+ };
36
+
37
+ function brandKey(value: string): string {
38
+ return value.toLowerCase().replaceAll(/[-_\s]/g, "");
39
+ }
40
+
41
+ const BRAND_BY_KEY: Record<string, CardBrand> = {
42
+ ...Object.fromEntries(CARD_BRANDS.map((brand) => [brandKey(brand), brand])),
43
+ ...BRAND_ALIASES,
44
+ };
45
+
46
+ /**
47
+ * Maps any casing of a brand to the value Bitwarden stores. An unknown brand is
48
+ * rejected rather than stored as typed: Bitwarden's clients match brand values
49
+ * exactly, so "visa" would show as no brand at all.
50
+ */
51
+ export function normalizeBrand(raw: string): CardBrand {
52
+ const brand = BRAND_BY_KEY[brandKey(raw)];
53
+ if (!brand) {
54
+ throw new CliError(
55
+ `Invalid --brand "${raw}". Valid: ${CARD_BRANDS.join(", ")}`,
56
+ ExitCode.BadArgs,
57
+ );
58
+ }
59
+ return brand;
60
+ }
61
+
62
+ /**
63
+ * IIN prefixes, most specific first so a range nested inside a broader one wins.
64
+ * Ambiguous prefixes (RuPay shares ranges with Discover and Maestro) are left
65
+ * out: no brand is better than a confidently wrong one.
66
+ */
67
+ const BRAND_PREFIXES: Array<[RegExp, CardBrand]> = [
68
+ [/^3[47]/, "Amex"],
69
+ [/^3(0[0-5]|[68])/, "Diners Club"],
70
+ [/^35(2[89]|[3-8])/, "JCB"],
71
+ [/^220[0-4]/, "Mir"],
72
+ [/^4/, "Visa"],
73
+ [/^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)/, "Mastercard"],
74
+ [/^(6011|65|64[4-9]|622(12[6-9]|1[3-9]|[2-8]|9[01]|92[0-5]))/, "Discover"],
75
+ [/^62/, "UnionPay"],
76
+ [/^(5018|5020|5038|5893|6304|6759|676[1-3])/, "Maestro"],
77
+ ];
78
+
79
+ /** The brand a normalized number belongs to, or `null` when it is unclear. */
80
+ export function inferBrand(number: string): CardBrand | null {
81
+ return BRAND_PREFIXES.find(([prefix]) => prefix.test(number))?.[1] ?? null;
82
+ }
83
+
84
+ export interface CardNumberOptions {
85
+ /** False skips the Luhn check, for test or private-label numbers. */
86
+ luhn: boolean;
87
+ }
88
+
89
+ /**
90
+ * Strips the spaces and dashes a number is usually written with, then requires
91
+ * digits and a valid Luhn checksum — the check every real card number passes and
92
+ * a single mistyped digit fails.
93
+ */
94
+ export function normalizeCardNumber(
95
+ raw: string,
96
+ options: CardNumberOptions,
97
+ ): string {
98
+ const number = raw.replaceAll(/[\s-]/g, "");
99
+ if (!/^\d{8,19}$/.test(number)) {
100
+ throw new CliError(
101
+ "Invalid card number: expected 8–19 digits (spaces and dashes are allowed).",
102
+ ExitCode.BadArgs,
103
+ );
104
+ }
105
+ if (options.luhn && !passesLuhn(number)) {
106
+ throw new CliError(
107
+ "Invalid card number: Luhn checksum failed. Pass --no-validate to store it anyway.",
108
+ ExitCode.BadArgs,
109
+ );
110
+ }
111
+ return number;
112
+ }
113
+
114
+ function passesLuhn(number: string): boolean {
115
+ let sum = 0;
116
+ for (let i = 0; i < number.length; i++) {
117
+ let digit = Number(number[number.length - 1 - i]);
118
+ if (i % 2 === 1) {
119
+ digit *= 2;
120
+ if (digit > 9) digit -= 9;
121
+ }
122
+ sum += digit;
123
+ }
124
+ return sum % 10 === 0;
125
+ }
126
+
127
+ export function normalizeCvv(raw: string): string {
128
+ const cvv = raw.trim();
129
+ if (!/^\d{3,4}$/.test(cvv)) {
130
+ throw new CliError("Invalid CVV: expected 3 or 4 digits.", ExitCode.BadArgs);
131
+ }
132
+ return cvv;
133
+ }
134
+
135
+ export interface CardExpiry {
136
+ /** As Bitwarden stores it: the month number, unpadded ("3"). */
137
+ month: string;
138
+ /** Always four digits. */
139
+ year: string;
140
+ }
141
+
142
+ /** `MM/YY` or `MM/YYYY`; a one-digit month is accepted too. */
143
+ export function parseExpiry(raw: string): CardExpiry {
144
+ const match = /^\s*(\d{1,2})\s*\/\s*(\d{2}|\d{4})\s*$/.exec(raw);
145
+ if (!match) {
146
+ throw new CliError(
147
+ `Invalid --expiry "${raw}". Use MM/YY or MM/YYYY.`,
148
+ ExitCode.BadArgs,
149
+ );
150
+ }
151
+ return {
152
+ month: parseExpMonth(match[1]!, "--expiry"),
153
+ year: parseExpYear(match[2]!, "--expiry"),
154
+ };
155
+ }
156
+
157
+ export function parseExpMonth(raw: string, flag = "--exp-month"): string {
158
+ const month = /^\s*\d{1,2}\s*$/.test(raw) ? Number(raw) : Number.NaN;
159
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
160
+ throw new CliError(
161
+ `Invalid month in ${flag} "${raw}". Use 1–12.`,
162
+ ExitCode.BadArgs,
163
+ );
164
+ }
165
+ return String(month);
166
+ }
167
+
168
+ /**
169
+ * A two-digit year is expanded to this century: cards are issued for a few
170
+ * years at most, so `29` cannot reasonably mean 1929.
171
+ */
172
+ export function parseExpYear(raw: string, flag = "--exp-year"): string {
173
+ const year = raw.trim();
174
+ if (/^\d{4}$/.test(year)) return year;
175
+ if (/^\d{2}$/.test(year)) return `20${year}`;
176
+ throw new CliError(
177
+ `Invalid year in ${flag} "${raw}". Use YYYY (or YY).`,
178
+ ExitCode.BadArgs,
179
+ );
180
+ }
@@ -0,0 +1,59 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+ import { CARD_FIELDS, readCardValue } from "./cards.ts";
3
+ import { builtinReference, customReference, ITEM_ID_RE } from "./references.ts";
4
+ import { BwItemSchema, BwItemType, customFieldTypeLabel, itemTypeLabel } from "./types.ts";
5
+
6
+ export interface DiscoveredField {
7
+ kind: "builtin" | "custom";
8
+ name: string;
9
+ type: "text" | "hidden" | "boolean" | "totp";
10
+ ref: string;
11
+ }
12
+
13
+ export interface ItemDiscovery {
14
+ id: string;
15
+ type: string;
16
+ name: string;
17
+ fields: DiscoveredField[];
18
+ }
19
+
20
+ function present(value: unknown): boolean {
21
+ return typeof value === "string" && value !== "";
22
+ }
23
+
24
+ /** Explicit allowlist: no spreading raw items or carrying field values forward. */
25
+ export function discoverItem(raw: Record<string, unknown>): ItemDiscovery {
26
+ const item = BwItemSchema.parse(raw);
27
+ if (!ITEM_ID_RE.test(item.id)) {
28
+ throw new CliError("Invalid vault item ID", ExitCode.BwError);
29
+ }
30
+ const fields: DiscoveredField[] = [];
31
+ const addBuiltin = (name: string, type: DiscoveredField["type"], available: boolean) => {
32
+ if (!available) return;
33
+ fields.push({ kind: "builtin", name, type, ref: builtinReference(item.id, name) });
34
+ };
35
+ addBuiltin("notes", "text", present(item.notes));
36
+ if (item.type === BwItemType.Login) {
37
+ addBuiltin("username", "text", present(item.login?.username));
38
+ addBuiltin("password", "hidden", present(item.login?.password));
39
+ addBuiltin("uri", "text", present(item.login?.uris?.[0]?.uri));
40
+ addBuiltin("totp", "totp", present(item.login?.totp));
41
+ }
42
+ if (item.type === BwItemType.Card && item.card) {
43
+ for (const name of CARD_FIELDS) {
44
+ const type = name === "number" || name === "cvv" ? "hidden" : "text";
45
+ addBuiltin(name, type, readCardValue(item.card, name) !== null);
46
+ }
47
+ }
48
+ for (const [index, field] of (item.fields ?? []).entries()) {
49
+ const type = customFieldTypeLabel(field.type);
50
+ if (type === undefined) continue; // Linked/unknown types have no supported value reader.
51
+ fields.push({
52
+ kind: "custom",
53
+ name: field.name,
54
+ type,
55
+ ref: customReference(item.id, index, field.name),
56
+ });
57
+ }
58
+ return { id: item.id, type: itemTypeLabel(item.type), name: item.name, fields };
59
+ }