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.
@@ -1,4 +1,4 @@
1
- import type { BwField, BwFieldType, BwItem, BwItemType, BwUri } from "./types.ts";
1
+ import type { BwCard, BwField, BwFieldType, BwItem, BwItemType, BwUri } from "./types.ts";
2
2
  import { BwFieldType as FT, BwItemType as IT } from "./types.ts";
3
3
 
4
4
  // --- Field flag parsing ---
@@ -35,21 +35,27 @@ export function parseFieldFlag(raw: string): ParsedField {
35
35
  // --- Build new item JSON ---
36
36
 
37
37
  export interface NewItemOptions {
38
- type?: "login" | "note";
38
+ type?: "login" | "note" | "card";
39
39
  name: string;
40
40
  notes?: string | null;
41
41
  username?: string | null;
42
42
  password?: string | null;
43
43
  totp?: string | null;
44
44
  uris?: string[];
45
+ card?: BwCard;
45
46
  fields?: ParsedField[];
46
47
  folderId?: string | null;
47
48
  favorite?: boolean;
48
49
  }
49
50
 
51
+ const ITEM_TYPE_BY_NAME = {
52
+ login: IT.Login,
53
+ note: IT.SecureNote,
54
+ card: IT.Card,
55
+ } as const;
56
+
50
57
  export function buildNewItem(options: NewItemOptions): Record<string, unknown> {
51
- const isLogin = options.type === "login";
52
- const itemType: BwItemType = isLogin ? IT.Login : IT.SecureNote;
58
+ const itemType: BwItemType = ITEM_TYPE_BY_NAME[options.type ?? "note"];
53
59
 
54
60
  const item: Record<string, unknown> = {
55
61
  organizationId: null,
@@ -62,7 +68,7 @@ export function buildNewItem(options: NewItemOptions): Record<string, unknown> {
62
68
  reprompt: 0,
63
69
  };
64
70
 
65
- if (isLogin) {
71
+ if (itemType === IT.Login) {
66
72
  item.login = {
67
73
  username: options.username ?? null,
68
74
  password: options.password ?? null,
@@ -71,6 +77,17 @@ export function buildNewItem(options: NewItemOptions): Record<string, unknown> {
71
77
  ? options.uris.map((uri) => ({ match: null, uri }))
72
78
  : null,
73
79
  };
80
+ } else if (itemType === IT.Card) {
81
+ item.card = {
82
+ cardholderName: null,
83
+ brand: null,
84
+ number: null,
85
+ expMonth: null,
86
+ expYear: null,
87
+ code: null,
88
+ ...options.card,
89
+ };
90
+ item.login = null;
74
91
  } else {
75
92
  item.secureNote = { type: 0 };
76
93
  item.login = null;
@@ -99,6 +116,8 @@ export interface PatchOptions {
99
116
  /** `null` clears the stored secret; `undefined` leaves it alone. */
100
117
  totp?: string | null;
101
118
  uris?: string[];
119
+ /** Each key: a value sets it, `null` clears it, absent leaves it alone. */
120
+ card?: BwCard;
102
121
  addFields?: ParsedField[];
103
122
  rmFields?: string[];
104
123
  /** `null` unfiles the item; `undefined` leaves its folder alone. */
@@ -136,6 +155,14 @@ export function patchItem(
136
155
  result.login = login;
137
156
  }
138
157
 
158
+ if (patch.card && Object.keys(patch.card).length > 0) {
159
+ // Copied for the same reason as `login` above.
160
+ result.card = {
161
+ ...((result.card as Record<string, unknown>) ?? {}),
162
+ ...patch.card,
163
+ };
164
+ }
165
+
139
166
  // Fields: remove then add
140
167
  let fields = Array.isArray(result.fields) ? [...(result.fields as BwField[])] : [];
141
168
  if (patch.rmFields?.length) {
package/src/bw/fields.ts CHANGED
@@ -4,6 +4,7 @@ import type { GlobalOptions } from "../cli/types.ts";
4
4
  import { isCardField, readCardValue, CARD_FIELDS } from "./cards.ts";
5
5
  import { summarizeItem, type ItemSummary } from "./items.ts";
6
6
  import { runBwOrThrow } from "./runner.ts";
7
+ import { withSafeVaultErrors } from "./safe-errors.ts";
7
8
  import { withSession } from "./session.ts";
8
9
  import {
9
10
  DEFAULT_PERIOD,
@@ -13,7 +14,9 @@ import {
13
14
  TotpUnsupportedError,
14
15
  type TotpCode,
15
16
  } from "./totp.ts";
16
- import { BwItemSchema, BwItemType, itemTypeLabel, type BwItem } from "./types.ts";
17
+ import {
18
+ BwItemSchema, BwItemType, customFieldTypeLabel, itemTypeLabel, type BwItem,
19
+ } from "./types.ts";
17
20
 
18
21
  /** Fields `bw get` resolves natively; anything else is a card or custom field. */
19
22
  export const BUILTIN_FIELDS = [
@@ -35,6 +38,10 @@ function isTotpField(field: string, options?: ReadFieldOptions): boolean {
35
38
  }
36
39
 
37
40
  export interface ReadFieldOptions {
41
+ /** Reference reads require an exact ID and never print raw vault diagnostics. */
42
+ exactItemId?: boolean;
43
+ /** Position plus name identifies a custom field, including duplicate names. */
44
+ customIndex?: number;
38
45
  /**
39
46
  * Always resolve `field` as a custom field name, so an item whose custom
40
47
  * field is called e.g. `password` is still reachable.
@@ -74,6 +81,16 @@ export async function readItemField(
74
81
  opts: GlobalOptions,
75
82
  options?: ReadFieldOptions,
76
83
  ): Promise<string> {
84
+ if (options?.exactItemId) {
85
+ return withSafeVaultErrors("Could not read field reference", async () => {
86
+ const values = await resolveFields([{ field, item, options }], opts);
87
+ const value = values[0]!;
88
+ if (options.verbatim || options.customOnly || isCardField(field)) return value;
89
+ const trimmed = value.trim();
90
+ if (!trimmed) throw new FieldMissingError("Field has no value", ExitCode.NotFound);
91
+ return trimmed;
92
+ });
93
+ }
77
94
  return withLookupHelp(item, opts, async () => {
78
95
  if (isTotpField(field, options)) {
79
96
  const nextCode = resolveTotpSource(await fetchItem(item, opts), item, opts);
@@ -95,6 +112,7 @@ export async function readItemField(
95
112
  }
96
113
 
97
114
  export interface ReadTotpOptions {
115
+ exactItemId?: boolean;
98
116
  /**
99
117
  * Wait for the next window when the code has fewer than this many seconds
100
118
  * left, rather than handing back one that expires on the way to the prompt.
@@ -115,21 +133,25 @@ export async function readTotp(
115
133
  opts: GlobalOptions,
116
134
  options?: ReadTotpOptions,
117
135
  ): Promise<TotpCode> {
118
- return withLookupHelp(item, opts, async () => {
119
- const nextCode = resolveTotpSource(await fetchItem(item, opts), item, opts);
136
+ const read = async () => {
137
+ const fetched = await fetchItem(item, opts, options?.exactItemId);
138
+ const nextCode = resolveTotpSource(fetched, item, opts);
120
139
  return awaitUsableWindow(nextCode, options);
121
- });
140
+ };
141
+ return withFieldLookup(item, opts, options?.exactItemId, read);
122
142
  }
123
143
 
124
144
  /** The stored secret itself, for moving a seed back out to another authenticator. */
125
145
  export async function readTotpSecret(
126
146
  item: string,
127
147
  opts: GlobalOptions,
148
+ exactItemId = false,
128
149
  ): Promise<string> {
129
- return withLookupHelp(item, opts, async () => {
130
- const fetched = await fetchItem(item, opts);
150
+ const read = async () => {
151
+ const fetched = await fetchItem(item, opts, exactItemId);
131
152
  return requireStoredTotp(fetched);
132
- });
153
+ };
154
+ return withFieldLookup(item, opts, exactItemId, read);
133
155
  }
134
156
 
135
157
  function requireStoredTotp(item: BwItem): string {
@@ -210,6 +232,16 @@ async function awaitUsableWindow(
210
232
  export async function readFields(
211
233
  requests: FieldRequest[],
212
234
  opts: GlobalOptions,
235
+ ): Promise<string[]> {
236
+ const read = () => resolveFields(requests, opts);
237
+ return requests.some((request) => request.options?.exactItemId)
238
+ ? withSafeVaultErrors("Could not read field reference", read)
239
+ : read();
240
+ }
241
+
242
+ async function resolveFields(
243
+ requests: FieldRequest[],
244
+ opts: GlobalOptions,
213
245
  ): Promise<string[]> {
214
246
  const values = new Array<string>(requests.length);
215
247
  const byItem = new Map<string, number[]>();
@@ -223,14 +255,20 @@ export async function readFields(
223
255
  for (const [item, indexes] of byItem) {
224
256
  // A single `bw` process at a time: each read may trigger an unlock, and
225
257
  // concurrent unlocks would race over the cached session.
226
- const fetched = await withLookupHelp(item, opts, () => fetchItem(item, opts));
258
+ const exact = indexes.some((index) => requests[index]!.options?.exactItemId);
259
+ const fetched = exact
260
+ ? await fetchItem(item, opts, true)
261
+ : await withLookupHelp(item, opts, () => fetchItem(item, opts));
227
262
 
228
263
  for (const index of indexes) {
229
264
  const request = requests[index]!;
230
265
 
231
266
  if (isTotpField(request.field, request.options)) {
232
267
  const nextCode = resolveTotpSource(fetched, item, opts);
233
- values[index] = (await withLookupHelp(item, opts, nextCode)).code;
268
+ const code = exact
269
+ ? await nextCode()
270
+ : await withLookupHelp(item, opts, nextCode);
271
+ values[index] = code.code;
234
272
  } else {
235
273
  values[index] = extractField(fetched, request.field, request.options);
236
274
  }
@@ -240,6 +278,17 @@ export async function readFields(
240
278
  return values;
241
279
  }
242
280
 
281
+ /** Exact references suppress raw diagnostics; named lookups retain suggestions. */
282
+ function withFieldLookup<T>(
283
+ item: string,
284
+ opts: GlobalOptions,
285
+ exactItemId: boolean | undefined,
286
+ read: () => Promise<T>,
287
+ ): Promise<T> {
288
+ if (exactItemId) return withSafeVaultErrors("Could not read field reference", read);
289
+ return withLookupHelp(item, opts, read);
290
+ }
291
+
243
292
  /**
244
293
  * Turns bw's bare "Not found." and "More than one result" into errors that name
245
294
  * the candidates, so a caller can correct itself without a round of guessing.
@@ -272,9 +321,17 @@ async function withLookupHelp<T>(
272
321
  }
273
322
  }
274
323
 
275
- async function fetchItem(item: string, opts: GlobalOptions): Promise<BwItem> {
324
+ async function fetchItem(
325
+ item: string,
326
+ opts: GlobalOptions,
327
+ exactItemId = false,
328
+ ): Promise<BwItem> {
276
329
  const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
277
- return BwItemSchema.parse(JSON.parse(json));
330
+ const fetched = BwItemSchema.parse(JSON.parse(json));
331
+ if (exactItemId && fetched.id.toLowerCase() !== item.toLowerCase()) {
332
+ throw new CliError("Vault returned a different item ID", ExitCode.BwError);
333
+ }
334
+ return fetched;
278
335
  }
279
336
 
280
337
  /** Mirrors what `bw get <field>` would have returned for an already-fetched item. */
@@ -283,6 +340,9 @@ function extractField(
283
340
  field: string,
284
341
  options?: ReadFieldOptions,
285
342
  ): string {
343
+ if (options?.customIndex !== undefined) {
344
+ return pickReferencedCustomField(item, options.customIndex, field);
345
+ }
286
346
  if (options?.customOnly) return pickCustomField(item, field);
287
347
 
288
348
  // A card field only means the card object on a card item. On anything else
@@ -290,6 +350,9 @@ function extractField(
290
350
  // break lookups that worked before cards were understood at all — so the
291
351
  // name is resolved as one, and the miss says what the item actually is.
292
352
  if (isCardField(field)) {
353
+ if (options?.exactItemId && item.type !== BwItemType.Card) {
354
+ throw new FieldMissingError("No built-in card field on this item", ExitCode.NotFound);
355
+ }
293
356
  return item.type === BwItemType.Card
294
357
  ? extractCardField(item, field)
295
358
  : pickCustomField(item, field, cardFieldHint(item, field));
@@ -398,6 +461,15 @@ function pickCustomField(item: BwItem, fieldName: string, hint?: string): string
398
461
  return match.value ?? "";
399
462
  }
400
463
 
464
+ /** Position plus name guards a custom reference against moved or removed fields. */
465
+ function pickReferencedCustomField(item: BwItem, index: number, name: string): string {
466
+ const field = item.fields?.[index];
467
+ if (!field || field.name !== name || customFieldTypeLabel(field.type) === undefined) {
468
+ throw new CliError("Custom field reference changed; discover fields again", ExitCode.BadArgs);
469
+ }
470
+ return field.value ?? "";
471
+ }
472
+
401
473
  async function findMatches(query: string): Promise<ItemSummary[]> {
402
474
  try {
403
475
  const json = await runBwOrThrow(["list", "items", "--search", query]);
package/src/bw/items.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { itemTypeLabel } from "./types.ts";
2
2
 
3
3
  /**
4
- * The only shape of a vault item bwx prints when the caller did not ask for a
5
- * specific secret. Enough to identify an item and follow up with a narrow `get`,
4
+ * Default listing shape. Enough to identify an item and follow up with a narrow `get`,
6
5
  * with no room for passwords, notes, custom fields, or URIs to leak into logs.
7
6
  */
8
7
  export interface ItemSummary {
@@ -0,0 +1,58 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+ import { CARD_FIELDS } from "./cards.ts";
3
+ import type { FieldRequest } from "./fields.ts";
4
+
5
+ export const REFERENCE_PREFIX = "bwx://";
6
+ export const ITEM_ID_RE =
7
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const REFERENCE_BUILTINS: readonly string[] = [
9
+ "username",
10
+ "password",
11
+ "totp",
12
+ "notes",
13
+ "uri",
14
+ ...CARD_FIELDS,
15
+ ];
16
+
17
+ export function builtinReference(id: string, field: string): string {
18
+ return `${REFERENCE_PREFIX}${id}/builtin/${field}`;
19
+ }
20
+
21
+ /** Index distinguishes duplicate and empty custom names; name guards against moves. */
22
+ export function customReference(id: string, index: number, name: string): string {
23
+ return `${REFERENCE_PREFIX}${id}/custom/${index}/${encodeURIComponent(name)}`;
24
+ }
25
+
26
+ /** Parse directly: URL normalization would change custom names such as '.' or '..'. */
27
+ export function parseFieldReference(ref: string): FieldRequest {
28
+ if (!ref.startsWith(REFERENCE_PREFIX)) throw invalidReference();
29
+ const [id, kind, ...parts] = ref.slice(REFERENCE_PREFIX.length).split("/");
30
+ if (!id || !ITEM_ID_RE.test(id)) throw invalidReference();
31
+ if (kind === "builtin" && parts.length === 1 && REFERENCE_BUILTINS.includes(parts[0]!)) {
32
+ return { item: id, field: parts[0]!, options: { exactItemId: true } };
33
+ }
34
+ if (kind !== "custom" || parts.length !== 2 || !/^(0|[1-9]\d*)$/.test(parts[0]!)) {
35
+ throw invalidReference();
36
+ }
37
+ const index = Number(parts[0]);
38
+ if (!Number.isSafeInteger(index)) throw invalidReference();
39
+ let name: string;
40
+ try {
41
+ name = decodeURIComponent(parts[1]!);
42
+ } catch {
43
+ throw invalidReference();
44
+ }
45
+ if (encodeURIComponent(name) !== parts[1]) throw invalidReference();
46
+ return {
47
+ item: id,
48
+ field: name,
49
+ options: { exactItemId: true, customOnly: true, customIndex: index },
50
+ };
51
+ }
52
+
53
+ function invalidReference(): CliError {
54
+ return new CliError(
55
+ "Invalid field reference. Copy a ref from 'bwx search <query> --fields'.",
56
+ ExitCode.BadArgs,
57
+ );
58
+ }
@@ -0,0 +1,24 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+
3
+ const REASONS: Partial<Record<ExitCode, string>> = {
4
+ [ExitCode.AuthFailed]: "authentication failed",
5
+ [ExitCode.NotFound]: "item or field not found",
6
+ [ExitCode.BadArgs]: "invalid arguments or stale reference",
7
+ [ExitCode.Network]: "network error",
8
+ [ExitCode.Timeout]: "timeout",
9
+ [ExitCode.Config]: "configuration error",
10
+ };
11
+
12
+ /** Vault diagnostics and parse errors can contain values. Never forward them. */
13
+ export async function withSafeVaultErrors<T>(
14
+ context: string,
15
+ fn: () => Promise<T>,
16
+ ): Promise<T> {
17
+ try {
18
+ return await fn();
19
+ } catch (err) {
20
+ const code = err instanceof CliError ? err.exitCode : ExitCode.BwError;
21
+ const reason = REASONS[code] ?? "vault command or response failed";
22
+ throw new CliError(`${context}: ${reason}.`, code);
23
+ }
24
+ }
package/src/bw/types.ts CHANGED
@@ -56,6 +56,22 @@ export const BwFieldType = {
56
56
 
57
57
  export type BwFieldType = (typeof BwFieldType)[keyof typeof BwFieldType];
58
58
 
59
+ /** Only these custom field types have a supported value reader. */
60
+ export function customFieldTypeLabel(
61
+ type: number,
62
+ ): "text" | "hidden" | "boolean" | undefined {
63
+ switch (type) {
64
+ case BwFieldType.Text:
65
+ return "text";
66
+ case BwFieldType.Hidden:
67
+ return "hidden";
68
+ case BwFieldType.Boolean:
69
+ return "boolean";
70
+ default:
71
+ return undefined;
72
+ }
73
+ }
74
+
59
75
  export interface BwField {
60
76
  name: string;
61
77
  value: string;
@@ -0,0 +1,169 @@
1
+ import type { Command } from "commander";
2
+ import type { BwCard } from "../bw/types.ts";
3
+ import {
4
+ inferBrand,
5
+ normalizeBrand,
6
+ parseExpiry,
7
+ parseExpMonth,
8
+ parseExpYear,
9
+ } from "../bw/card-input.ts";
10
+ import { CliError, ExitCode } from "./errors.ts";
11
+ import {
12
+ CARD_NUMBER_FAMILY,
13
+ CVV_FAMILY,
14
+ listUsedSourceFlags,
15
+ typeOnlyTextInputFlags,
16
+ type ItemTextInputs,
17
+ type ResolvedItemTextInputs,
18
+ } from "./input.ts";
19
+
20
+ /**
21
+ * The card values that are not secrets. The number and CVV are secrets, so they
22
+ * live in the shared text-input table with every other `--x-stdin|file|env`
23
+ * family; these are plain flags.
24
+ */
25
+ export interface CardOptionInputs {
26
+ cardholder?: string;
27
+ brand?: string;
28
+ expiry?: string;
29
+ expMonth?: string;
30
+ expYear?: string;
31
+ }
32
+
33
+ /** `edit` only: clearing a value is different from leaving it alone. */
34
+ export interface CardRemovalInputs {
35
+ rmNumber?: boolean;
36
+ rmCvv?: boolean;
37
+ rmCardholder?: boolean;
38
+ rmBrand?: boolean;
39
+ rmExpiry?: boolean;
40
+ }
41
+
42
+ type CardInputs = CardOptionInputs & CardRemovalInputs & ItemTextInputs;
43
+
44
+ const CARD_OPTION_FLAGS: Array<[keyof CardOptionInputs, string]> = [
45
+ ["cardholder", "--cardholder"],
46
+ ["brand", "--brand"],
47
+ ["expiry", "--expiry"],
48
+ ["expMonth", "--exp-month"],
49
+ ["expYear", "--exp-year"],
50
+ ];
51
+
52
+ const CARD_REMOVAL_FLAGS: Array<[keyof CardRemovalInputs, string]> = [
53
+ ["rmNumber", "--rm-number"],
54
+ ["rmCvv", "--rm-cvv"],
55
+ ["rmCardholder", "--rm-cardholder"],
56
+ ["rmBrand", "--rm-brand"],
57
+ ["rmExpiry", "--rm-expiry"],
58
+ ];
59
+
60
+ export function registerCardOptions(command: Command): Command {
61
+ return command
62
+ .option("--cardholder <name>", "Cardholder name (card items only)")
63
+ .option("--brand <brand>", "Card brand, e.g. Visa, Mastercard, Amex (inferred from the number if omitted)")
64
+ .option("--expiry <MM/YY>", "Expiry as MM/YY or MM/YYYY")
65
+ .option("--exp-month <1-12>", "Expiry month")
66
+ .option("--exp-year <YYYY>", "Expiry year");
67
+ }
68
+
69
+ export function registerCardRemovalOptions(command: Command): Command {
70
+ return command
71
+ .option("--rm-number", "Remove the stored card number")
72
+ .option("--rm-cvv", "Remove the stored CVV")
73
+ .option("--rm-cardholder", "Remove the stored cardholder name")
74
+ .option("--rm-brand", "Remove the stored brand")
75
+ .option("--rm-expiry", "Remove the stored expiry month and year");
76
+ }
77
+
78
+ /**
79
+ * Every card-only flag the caller used — the set a command must reject on an
80
+ * item that has no card object to store them in.
81
+ */
82
+ export function usedCardOnlyFlags(inputs: CardInputs): string[] {
83
+ const pairs: Array<[keyof CardInputs, string]> = [
84
+ ...typeOnlyTextInputFlags("card"),
85
+ ...CARD_OPTION_FLAGS,
86
+ ...CARD_REMOVAL_FLAGS,
87
+ ];
88
+ return pairs.filter(([key]) => Boolean(inputs[key])).map(([, flag]) => flag);
89
+ }
90
+
91
+ /**
92
+ * Turns the parsed flags into the card keys to write: a value sets a key, `null`
93
+ * clears it, and a key left out is left alone. `current` is the card being
94
+ * edited, so a brand is only inferred onto a card that has none.
95
+ */
96
+ export function resolveCardPatch(
97
+ inputs: CardInputs,
98
+ secrets: ResolvedItemTextInputs,
99
+ current?: BwCard | null,
100
+ ): BwCard {
101
+ const card: BwCard = {};
102
+
103
+ if (secrets.number !== undefined) card.number = secrets.number;
104
+ if (inputs.rmNumber) card.number = null;
105
+
106
+ if (secrets.cvv !== undefined) card.code = secrets.cvv;
107
+ if (inputs.rmCvv) card.code = null;
108
+
109
+ if (inputs.cardholder !== undefined) card.cardholderName = inputs.cardholder;
110
+ if (inputs.rmCardholder) card.cardholderName = null;
111
+
112
+ if (inputs.brand !== undefined) {
113
+ card.brand = normalizeBrand(inputs.brand);
114
+ } else if (inputs.rmBrand) {
115
+ card.brand = null;
116
+ } else if (card.number && !current?.brand) {
117
+ const inferred = inferBrand(card.number);
118
+ if (inferred) card.brand = inferred;
119
+ }
120
+
121
+ if (inputs.expiry !== undefined) {
122
+ const expiry = parseExpiry(inputs.expiry);
123
+ card.expMonth = expiry.month;
124
+ card.expYear = expiry.year;
125
+ }
126
+ if (inputs.expMonth !== undefined) card.expMonth = parseExpMonth(inputs.expMonth);
127
+ if (inputs.expYear !== undefined) card.expYear = parseExpYear(inputs.expYear);
128
+ if (inputs.rmExpiry) {
129
+ card.expMonth = null;
130
+ card.expYear = null;
131
+ }
132
+
133
+ return card;
134
+ }
135
+
136
+ /**
137
+ * Setting and clearing the same value in one call has no sensible winner, and
138
+ * `--expiry` already names both halves `--exp-month` and `--exp-year` would set.
139
+ * Checked before any value is read, so a rejected call never consumes stdin.
140
+ */
141
+ export function assertCardIntent(inputs: CardInputs): void {
142
+ const conflicts: Array<[boolean | undefined, string, string[]]> = [
143
+ [inputs.expiry !== undefined, "--expiry", usedOptionFlags(inputs, ["expMonth", "expYear"])],
144
+ [inputs.rmNumber, "--rm-number", listUsedSourceFlags(CARD_NUMBER_FAMILY, inputs)],
145
+ [inputs.rmCvv, "--rm-cvv", listUsedSourceFlags(CVV_FAMILY, inputs)],
146
+ [inputs.rmCardholder, "--rm-cardholder", usedOptionFlags(inputs, ["cardholder"])],
147
+ [inputs.rmBrand, "--rm-brand", usedOptionFlags(inputs, ["brand"])],
148
+ [inputs.rmExpiry, "--rm-expiry", usedOptionFlags(inputs, ["expiry", "expMonth", "expYear"])],
149
+ ];
150
+
151
+ for (const [active, flag, setters] of conflicts) {
152
+ if (active && setters.length > 0) {
153
+ throw new CliError(
154
+ `${flag} cannot be combined with ${setters.join(", ")}.`,
155
+ ExitCode.BadArgs,
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ /** The flags among `keys` the caller passed, in help-text order. */
162
+ function usedOptionFlags(
163
+ inputs: CardOptionInputs,
164
+ keys: Array<keyof CardOptionInputs>,
165
+ ): string[] {
166
+ return CARD_OPTION_FLAGS.filter(
167
+ ([key]) => keys.includes(key) && inputs[key] !== undefined,
168
+ ).map(([, flag]) => flag);
169
+ }