bwx 0.6.3 → 0.7.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/README.md CHANGED
@@ -71,6 +71,7 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
71
71
  |---------|-------------|
72
72
  | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
73
73
  | `bwx get totp <item>` | TOTP code (`--fresh [n]`, `--seed`) |
74
+ | `bwx get <card field> <item>` | Card field (`number`, `cvv`, `cardholder`, `brand`, `expiry`, …) |
74
75
  | `bwx field <name> <item>` | Get custom field by name (same argument order as `get`) |
75
76
  | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
76
77
  | `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
@@ -139,6 +140,45 @@ Spaces, hyphens, and `=` padding are stripped and case is normalized; an `otpaut
139
140
  is stored verbatim so its parameters survive. Malformed secrets are rejected before the
140
141
  write, because a bad seed is otherwise discovered at the login it was supposed to unlock.
141
142
 
143
+ ### Cards
144
+
145
+ Card items keep their values in a `card` object that `bw get <field>` cannot reach, so
146
+ bwx reads them from the item itself — one vault read, no extra `bw` spawn:
147
+
148
+ ```bash
149
+ bwx get number "Visa" # 4111111111111111
150
+ bwx get cvv "Visa" # 123
151
+ bwx get cardholder "Visa" # Vladislav Forsh
152
+ bwx get brand "Visa" # Visa
153
+ bwx get expiry "Visa" # 03/2030
154
+ bwx get expMonth "Visa" # 03
155
+ bwx get expYear "Visa" # 2030
156
+ ```
157
+
158
+ Names match leniently on case, dashes, and underscores, and bw's own JSON keys work too,
159
+ so a name copied out of `bwx get item` resolves (`code` → `cvv`, `cardholderName` →
160
+ `cardholder`, `exp-month` → `expMonth`).
161
+
162
+ Two values are normalized rather than passed through, because the stored form is not the
163
+ usable one:
164
+
165
+ - `expMonth` is zero-padded — bw stores the month as typed (`3`), and every `MM` field
166
+ wants `03`.
167
+ - `expiry` is composed from month and year, and is withheld entirely if either is missing
168
+ rather than handing back half a date.
169
+
170
+ A card field only means the card object **on a card item**. `number`, `code`, and `brand`
171
+ are plausible custom field names, so on any other item type they resolve as custom fields
172
+ exactly as before — and if no such custom field exists, the error says which kind of item
173
+ it actually is. `bwx field` is unchanged: custom fields only, never a card field.
174
+
175
+ Grouping several card fields into one read is worth it — see [Secrets into a child
176
+ process](#secrets-into-a-child-process):
177
+
178
+ ```bash
179
+ bwx run --env NUM=number:Visa --env CVV=cvv:Visa --env EXP=expiry:Visa -- ./pay.sh
180
+ ```
181
+
142
182
  ### Listings are capped
143
183
 
144
184
  Listings stop at 50 items by default — an uncapped `bwx list` returns the whole vault,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bwx
3
- description: Bitwarden Extended CLI — retrieve, create, edit, delete vault items with auto-unlock. Use when the agent needs passwords, API keys, TOTP codes, custom fields, or vault management. Triggers on mentions of Bitwarden, bw, bwx, vault, password, secrets, TOTP, save key/token.
3
+ description: Bitwarden Extended CLI — retrieve, create, edit, delete vault items with auto-unlock. Use when the agent needs passwords, API keys, TOTP codes, card details, custom fields, or vault management. Triggers on mentions of Bitwarden, bw, bwx, vault, password, secrets, TOTP, card number, CVV, save key/token.
4
4
  ---
5
5
 
6
6
  # bwx
@@ -17,6 +17,7 @@ bwx get username "GitHub" # username
17
17
  bwx get notes "Deploy key" # secure note content
18
18
  bwx get uri "GitHub" # first URI
19
19
  bwx get item "GitHub" # full item JSON
20
+ bwx get number "Visa" # card number (also: cvv, expiry, cardholder, brand)
20
21
  bwx get "API Key" "Acme" # custom field by name
21
22
  bwx field "API Key" "Acme" # custom-field-only lookup, same arg order
22
23
 
@@ -45,6 +46,7 @@ echo "secret" | bwx create --name "Piped Note"
45
46
  ### `bwx get <field> <item>`
46
47
 
47
48
  Built-in fields: `password | username | totp | notes | uri | item`.
49
+ Card items additionally take `number | cvv | cardholder | brand | expiry | expMonth | expYear`.
48
50
  Anything else is looked up as a custom field. Lists available fields on miss.
49
51
 
50
52
  A miss suggests near matches; an ambiguous name lists candidates with IDs. Read the
@@ -76,6 +78,27 @@ exactly are delegated to `bw get totp` instead of guessed at.
76
78
  Custom fields only, so an item whose custom field is named `password` is still reachable.
77
79
  Takes its arguments in the same order as `get` (this changed in 0.5.0).
78
80
 
81
+ ### Card fields
82
+
83
+ ```bash
84
+ bwx get number "Visa" # 4111111111111111
85
+ bwx get cvv "Visa" # 123 (bw's own key `code` works too)
86
+ bwx get cardholder "Visa" # Vladislav Forsh
87
+ bwx get brand "Visa" # Visa
88
+ bwx get expiry "Visa" # 03/2030 (composed; withheld if either half is missing)
89
+ bwx get expMonth "Visa" # 03 (zero-padded; bw stores "3")
90
+ bwx get expYear "Visa" # 2030
91
+ ```
92
+
93
+ Read from the fetched item — `bw` has no card fields — so they cost one vault read and
94
+ batch with each other under `bwx run`. Names match leniently on case, dashes, and
95
+ underscores, and bw's JSON keys resolve (`code`, `cardholderName`, `exp-month`).
96
+
97
+ A card field means the card object **only on a card item**. On any other type it resolves
98
+ as a custom field of that name, since `number` and `code` are ordinary field names; the
99
+ error names the item's actual type when no such field exists. `bwx field` never returns a
100
+ card field.
101
+
79
102
  ### `bwx search <query>` / `bwx list [type]`
80
103
 
81
104
  Flags: `--type login|note|card|identity`, `--folder <name|id>`, `--limit <n>`, `--full-items`
@@ -108,7 +131,7 @@ bwx run --env DB_PASS=password:'Prod DB' -- ./migrate.sh
108
131
  bwx run --env TOKEN='API Key:Acme' --env USER=username:Acme -- ./deploy.sh
109
132
  ```
110
133
 
111
- - `--env` is repeatable; the field resolves as in `bwx get` (built-in, else custom field).
134
+ - `--env` is repeatable; the field resolves as in `bwx get` (built-in, else card, else custom field).
112
135
  - Only the first `:` splits field from item, so `password:http://my.router` works.
113
136
  - Put `--` before the command when it takes its own flags.
114
137
  - Child stdio is inherited; bwx exits with the child's status (`128 + signal` if killed).
@@ -0,0 +1,116 @@
1
+ import type { BwCard } from "./types.ts";
2
+
3
+ /**
4
+ * Card items keep their values in a `card` object rather than in `login` or in
5
+ * custom fields, and `bw get <field>` has no equivalent for any of them — so
6
+ * these names are resolved here, from an item bwx has already fetched.
7
+ *
8
+ * A reader returns the value or `null`; naming the item and raising is the
9
+ * caller's job, since it holds the item name and the error type.
10
+ */
11
+ type CardReader = (card: BwCard) => string | null;
12
+
13
+ /** Canonical names, in the order help text and errors list them. */
14
+ export const CARD_FIELDS = [
15
+ "number",
16
+ "cvv",
17
+ "cardholder",
18
+ "brand",
19
+ "expiry",
20
+ "expMonth",
21
+ "expYear",
22
+ ] as const;
23
+
24
+ export type CardField = (typeof CARD_FIELDS)[number];
25
+
26
+ /**
27
+ * How each canonical field reads off the card object. Keyed by `CardField`, so
28
+ * adding a name to {@link CARD_FIELDS} without a reader fails to compile rather
29
+ * than at the one call that asks for it.
30
+ */
31
+ const CARD_READERS: Record<CardField, CardReader> = {
32
+ number: (card) => text(card.number),
33
+ cvv: (card) => text(card.code),
34
+ cardholder: (card) => text(card.cardholderName),
35
+ brand: (card) => text(card.brand),
36
+ expiry: readExpiry,
37
+ expMonth: readExpMonth,
38
+ expYear: (card) => text(card.expYear),
39
+ };
40
+
41
+ /**
42
+ * Other spellings of the same fields: bw's own JSON keys, so a name copied out
43
+ * of `bwx get item` resolves, plus the words people reach for first.
44
+ */
45
+ const CARD_ALIASES: Record<string, CardField> = {
46
+ code: "cvv",
47
+ cvc: "cvv",
48
+ securityCode: "cvv",
49
+ cardholderName: "cardholder",
50
+ holder: "cardholder",
51
+ exp: "expiry",
52
+ expiration: "expiry",
53
+ };
54
+
55
+ /**
56
+ * Card names are matched leniently — case, dashes, and underscores all collapse
57
+ * — because `expMonth`, `exp-month`, and `expmonth` are the same request, and
58
+ * failing one of them would only teach the caller to try the other two.
59
+ */
60
+ function normalize(field: string): string {
61
+ return field.toLowerCase().replaceAll(/[-_\s]/g, "");
62
+ }
63
+
64
+ /**
65
+ * Every accepted spelling to its canonical field, derived so that the two
66
+ * tables above stay the only place a card field name is written down.
67
+ */
68
+ const CANONICAL_BY_SPELLING: Record<string, CardField> = {
69
+ ...Object.fromEntries(CARD_FIELDS.map((field) => [normalize(field), field])),
70
+ ...Object.fromEntries(
71
+ Object.entries(CARD_ALIASES).map(([alias, field]) => [normalize(alias), field]),
72
+ ),
73
+ };
74
+
75
+ /** True when `field` names a card field rather than a login field or a custom one. */
76
+ export function isCardField(field: string): boolean {
77
+ return normalize(field) in CANONICAL_BY_SPELLING;
78
+ }
79
+
80
+ /** Reads one card field, or `null` when the card carries no value for it. */
81
+ export function readCardValue(card: BwCard, field: string): string | null {
82
+ const canonical = CANONICAL_BY_SPELLING[normalize(field)];
83
+ return canonical ? CARD_READERS[canonical](card) : null;
84
+ }
85
+
86
+ /**
87
+ * bw stores the month as the digit it was entered as ("3"), which is not what a
88
+ * `MM` field or an expiry string wants. Padding here means every consumer gets
89
+ * the two-digit form rather than each one remembering to pad.
90
+ */
91
+ function readExpMonth(card: BwCard): string | null {
92
+ return text(card.expMonth)?.padStart(2, "0") ?? null;
93
+ }
94
+
95
+ /**
96
+ * Composed rather than stored: Bitwarden keeps month and year apart, and a
97
+ * caller filling one expiry box should not have to join them itself. The year
98
+ * is passed through as stored, so a vault holding `30` yields `03/30`.
99
+ */
100
+ function readExpiry(card: BwCard): string | null {
101
+ const month = readExpMonth(card);
102
+ const year = text(card.expYear);
103
+ // Half an expiry is worse than none — it would read as a complete date.
104
+ return month && year ? `${month}/${year}` : null;
105
+ }
106
+
107
+ /**
108
+ * Normalizes a stored value to a non-empty string. Months and years arrive as
109
+ * either strings or numbers depending on how the item was created, and an empty
110
+ * string means "not set" just as much as `null` does.
111
+ */
112
+ function text(value: string | number | null | undefined): string | null {
113
+ if (value === null || value === undefined) return null;
114
+ const trimmed = String(value).trim();
115
+ return trimmed === "" ? null : trimmed;
116
+ }
package/src/bw/fields.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import pc from "picocolors";
2
2
  import { CliError, ExitCode } from "../cli/errors.ts";
3
3
  import type { GlobalOptions } from "../cli/types.ts";
4
+ import { isCardField, readCardValue, CARD_FIELDS } from "./cards.ts";
4
5
  import { summarizeItem, type ItemSummary } from "./items.ts";
5
6
  import { runBwOrThrow } from "./runner.ts";
6
7
  import { withSession } from "./session.ts";
@@ -12,10 +13,10 @@ import {
12
13
  TotpUnsupportedError,
13
14
  type TotpCode,
14
15
  } from "./totp.ts";
15
- import { BwItemSchema, type BwItem } from "./types.ts";
16
+ import { BwItemSchema, BwItemType, itemTypeLabel, type BwItem } from "./types.ts";
16
17
 
17
- /** Fields `bw get` resolves natively; anything else is a custom field name. */
18
- const BUILTIN_FIELDS = [
18
+ /** Fields `bw get` resolves natively; anything else is a card or custom field. */
19
+ export const BUILTIN_FIELDS = [
19
20
  "password",
20
21
  "username",
21
22
  "totp",
@@ -71,7 +72,15 @@ export async function readItemField(
71
72
  return (await nextCode()).code;
72
73
  }
73
74
 
74
- return isBuiltinField(field) && !options?.customOnly
75
+ if (options?.customOnly) return readCustomField(field, item, opts);
76
+
77
+ // `bw get` has no card fields, so they are read from the item itself —
78
+ // which also settles whether this item is a card at all.
79
+ if (isCardField(field)) {
80
+ return extractField(await fetchItem(item, opts), field, options);
81
+ }
82
+
83
+ return isBuiltinField(field)
75
84
  ? readBuiltinField(field, item, opts)
76
85
  : readCustomField(field, item, opts);
77
86
  });
@@ -266,7 +275,19 @@ function extractField(
266
275
  field: string,
267
276
  options?: ReadFieldOptions,
268
277
  ): string {
269
- if (!isBuiltinField(field) || options?.customOnly) {
278
+ if (options?.customOnly) return pickCustomField(item, field);
279
+
280
+ // A card field only means the card object on a card item. On anything else
281
+ // `number` or `code` is just a custom field name, and stealing it here would
282
+ // break lookups that worked before cards were understood at all — so the
283
+ // name is resolved as one, and the miss says what the item actually is.
284
+ if (isCardField(field)) {
285
+ return item.type === BwItemType.Card
286
+ ? extractCardField(item, field)
287
+ : pickCustomField(item, field, cardFieldHint(item, field));
288
+ }
289
+
290
+ if (!isBuiltinField(field)) {
270
291
  return pickCustomField(item, field);
271
292
  }
272
293
 
@@ -274,7 +295,7 @@ function extractField(
274
295
 
275
296
  // Named exhaustively rather than defaulted: a chain ending in `password`
276
297
  // would hand back the password for any field it failed to recognize.
277
- let value: string | null;
298
+ let value: string | null | undefined;
278
299
  switch (field) {
279
300
  case "notes":
280
301
  value = item.notes;
@@ -332,16 +353,35 @@ async function readCustomField(
332
353
  return pickCustomField(await fetchItem(item, opts), fieldName);
333
354
  }
334
355
 
335
- function pickCustomField(item: BwItem, fieldName: string): string {
356
+ function extractCardField(item: BwItem, field: string): string {
357
+ const value = item.card ? readCardValue(item.card, field) : null;
358
+ if (!value) {
359
+ throw new FieldMissingError(
360
+ `No ${field} found for item: ${item.name}`,
361
+ ExitCode.NotFound,
362
+ );
363
+ }
364
+
365
+ return value;
366
+ }
367
+
368
+ /** Why a card field found nothing on an item that is not a card. */
369
+ function cardFieldHint(item: BwItem, field: string): string {
370
+ return `Note: "${field}" reads a card field on card items, but "${item.name}" is a ${itemTypeLabel(item.type)} item. Card fields: ${CARD_FIELDS.join(", ")}.`;
371
+ }
372
+
373
+ /** `hint` is appended to a miss, for a name that could have meant something else. */
374
+ function pickCustomField(item: BwItem, fieldName: string, hint?: string): string {
336
375
  const fields = item.fields ?? [];
337
376
  const match = fields.find((f) => f.name === fieldName);
338
377
 
339
378
  if (!match) {
340
379
  const available = fields.map((f) => f.name).join(", ");
380
+ const miss = available
381
+ ? `Field "${fieldName}" not found. Available: ${available}`
382
+ : `Field "${fieldName}" not found (item has no custom fields)`;
341
383
  throw new FieldMissingError(
342
- available
343
- ? `Field "${fieldName}" not found. Available: ${available}`
344
- : `Field "${fieldName}" not found (item has no custom fields)`,
384
+ hint ? `${miss}\n\n${hint}` : miss,
345
385
  ExitCode.NotFound,
346
386
  );
347
387
  }
package/src/bw/items.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BW_TYPE_LABELS } from "./types.ts";
1
+ import { itemTypeLabel } from "./types.ts";
2
2
 
3
3
  /**
4
4
  * The only shape of a vault item bwx prints when the caller did not ask for a
@@ -25,7 +25,7 @@ export function summarizeItem(item: Record<string, unknown>): ItemSummary {
25
25
 
26
26
  return {
27
27
  id: String(item.id ?? ""),
28
- type: BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`,
28
+ type: itemTypeLabel(item.type as number),
29
29
  name: String(item.name ?? ""),
30
30
  username: typeof username === "string" && username ? username : null,
31
31
  hasTotp: typeof totp === "string" && totp.length > 0,
package/src/bw/types.ts CHANGED
@@ -30,6 +30,11 @@ export const BW_TYPE_LABELS: Record<number, string> = {
30
30
  4: "identity",
31
31
  };
32
32
 
33
+ /** The label for an item type, naming the raw number for one bwx predates. */
34
+ export function itemTypeLabel(type: number): string {
35
+ return BW_TYPE_LABELS[type] ?? `type:${type}`;
36
+ }
37
+
33
38
  export const BW_TYPE_FROM_NAME: Record<string, BwItemType> = {
34
39
  login: BwItemType.Login,
35
40
  logins: BwItemType.Login,
@@ -73,17 +78,43 @@ export interface BwLogin {
73
78
  uris: BwUri[] | null;
74
79
  }
75
80
 
81
+ // --- Card ---
82
+
83
+ /**
84
+ * Values arrive as strings from `bw`, but `expMonth`/`expYear` are numbers in
85
+ * some hand-written and imported items, so both are accepted rather than
86
+ * failing the whole item parse over a field the caller may not even want. Every
87
+ * key is optional for the same reason — see the schema below.
88
+ */
89
+ export interface BwCard {
90
+ cardholderName?: string | null;
91
+ brand?: string | null;
92
+ number?: string | null;
93
+ expMonth?: string | number | null;
94
+ expYear?: string | number | null;
95
+ code?: string | null;
96
+ }
97
+
76
98
  // --- Item (loose schema — passthrough for edit round-trip) ---
77
99
 
100
+ /**
101
+ * `bw get item` omits keys it has no value for rather than nulling them — an
102
+ * unfiled card has no `folderId`, a login without 2FA no `totp` — so every key
103
+ * but an item's identity is optional as well as nullable. Requiring them made
104
+ * an absent key fail the parse and took down reads of the fields that were
105
+ * present.
106
+ */
107
+ const OmittableTextSchema = z.string().nullable().optional();
108
+
78
109
  export const BwItemSchema = z
79
110
  .object({
80
111
  id: z.string(),
81
- organizationId: z.string().nullable(),
82
- folderId: z.string().nullable(),
112
+ organizationId: OmittableTextSchema,
113
+ folderId: OmittableTextSchema,
83
114
  type: z.number(),
84
115
  name: z.string(),
85
- notes: z.string().nullable(),
86
- favorite: z.boolean(),
116
+ notes: OmittableTextSchema,
117
+ favorite: z.boolean().optional(),
87
118
  fields: z
88
119
  .array(
89
120
  z.object({
@@ -92,12 +123,13 @@ export const BwItemSchema = z
92
123
  type: z.number(),
93
124
  }),
94
125
  )
95
- .nullable(),
126
+ .nullable()
127
+ .optional(),
96
128
  login: z
97
129
  .object({
98
- username: z.string().nullable(),
99
- password: z.string().nullable(),
100
- totp: z.string().nullable(),
130
+ username: OmittableTextSchema,
131
+ password: OmittableTextSchema,
132
+ totp: OmittableTextSchema,
101
133
  uris: z
102
134
  .array(
103
135
  z.object({
@@ -105,8 +137,23 @@ export const BwItemSchema = z
105
137
  uri: z.string(),
106
138
  }),
107
139
  )
108
- .nullable(),
140
+ .nullable()
141
+ .optional(),
142
+ })
143
+ .passthrough()
144
+ .nullable()
145
+ .optional(),
146
+ card: z
147
+ .object({
148
+ cardholderName: OmittableTextSchema,
149
+ brand: OmittableTextSchema,
150
+ number: OmittableTextSchema,
151
+ // Numeric in items written by hand or by an importer.
152
+ expMonth: z.union([z.string(), z.number()]).nullable().optional(),
153
+ expYear: z.union([z.string(), z.number()]).nullable().optional(),
154
+ code: OmittableTextSchema,
109
155
  })
156
+ .passthrough()
110
157
  .nullable()
111
158
  .optional(),
112
159
  reprompt: z.number().optional(),
@@ -1,7 +1,8 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData, emitLog } from "../io.ts";
4
- import { readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
4
+ import { BUILTIN_FIELDS, readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
5
+ import { CARD_FIELDS } from "../../bw/cards.ts";
5
6
  import { CliError, ExitCode } from "../errors.ts";
6
7
  import type { GlobalOptions } from "../types.ts";
7
8
 
@@ -12,7 +13,10 @@ export function registerGet(program: Command): void {
12
13
  program
13
14
  .command("get")
14
15
  .description("Get a field from a vault item (built-in or custom)")
15
- .argument("<field>", "Field: password | username | totp | notes | uri | item | <custom>")
16
+ .argument(
17
+ "<field>",
18
+ `Field: ${BUILTIN_FIELDS.join(" | ")} | <custom>. Card items also take: ${CARD_FIELDS.join(" | ")}`,
19
+ )
16
20
  .argument("<item>", "Item name or ID")
17
21
  .option(
18
22
  "--fresh [seconds]",
@@ -70,7 +70,8 @@ export function parseEnvSpecs(raws: string[]): EnvSpec[] {
70
70
  /**
71
71
  * Splits `NAME=<field>:<item>` on the first `=` and then the first `:`, so item
72
72
  * names may contain colons (`password:http://my.router`) while field names may not.
73
- * Fields resolve exactly as in `bwx get`: built-ins first, otherwise custom fields.
73
+ * Fields resolve exactly as in `bwx get`: built-ins first, then card fields on a card
74
+ * item, otherwise custom fields.
74
75
  */
75
76
  export function parseEnvSpec(raw: string): EnvSpec {
76
77
  const eq = raw.indexOf("=");