bwx 0.8.1 → 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.
@@ -7,7 +7,7 @@ import { withSession } from "../../bw/session.ts";
7
7
  import { patchItem, bwEncode } from "../../bw/encoding.ts";
8
8
  import { generateSecret } from "../../bw/generate.ts";
9
9
  import { resolveFolderRef } from "../../bw/folders.ts";
10
- import { BwItemType } from "../../bw/types.ts";
10
+ import { BwItemType, itemTypeLabel, type BwCard } from "../../bw/types.ts";
11
11
  import {
12
12
  assertSingleStdinSource,
13
13
  collect,
@@ -21,9 +21,18 @@ import {
21
21
  TOTP_FAMILY,
22
22
  type ItemTextInputs,
23
23
  } from "../input.ts";
24
+ import {
25
+ assertCardIntent,
26
+ registerCardOptions,
27
+ registerCardRemovalOptions,
28
+ resolveCardPatch,
29
+ usedCardOnlyFlags,
30
+ type CardOptionInputs,
31
+ type CardRemovalInputs,
32
+ } from "../card-options.ts";
24
33
  import { redactGeneratedPassword } from "./generate.ts";
25
34
 
26
- interface EditOptions extends ItemTextInputs {
35
+ interface EditOptions extends ItemTextInputs, CardOptionInputs, CardRemovalInputs {
27
36
  name?: string;
28
37
  username?: string;
29
38
  uri: string[];
@@ -52,8 +61,12 @@ export function registerEdit(program: Command): void {
52
61
  .option("--name <name>", "New name")
53
62
  .option("--username <user>", "New username");
54
63
 
55
- registerItemTextOptions(command)
56
- .option("--rm-totp", "Remove the stored TOTP secret")
64
+ registerItemTextOptions(command).option(
65
+ "--rm-totp",
66
+ "Remove the stored TOTP secret",
67
+ );
68
+ registerCardOptions(command);
69
+ registerCardRemovalOptions(command)
57
70
  .option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
58
71
  .option("--add-field <kv>", "Add/update field k=v", collect, [])
59
72
  .option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
@@ -87,8 +100,9 @@ export function registerEdit(program: Command): void {
87
100
  }
88
101
  encoded = bwEncode(stdinText);
89
102
  } else {
90
- assertLoginFlags(localOpts, current);
103
+ assertTypeFlags(localOpts, current);
91
104
  assertTotpIntent(localOpts);
105
+ assertCardIntent(localOpts);
92
106
 
93
107
  assertSingleStdinSource(
94
108
  listExplicitStdinInputs({
@@ -119,6 +133,10 @@ export function registerEdit(program: Command): void {
119
133
  password: textInputs.password,
120
134
  totp: localOpts.rmTotp ? null : textInputs.totp,
121
135
  uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
136
+ card:
137
+ current.type === BwItemType.Card
138
+ ? resolveCardPatch(localOpts, textInputs, current.card as BwCard | null)
139
+ : undefined,
122
140
  addFields,
123
141
  rmFields: localOpts.rmField,
124
142
  folderId: localOpts.folder
@@ -162,24 +180,29 @@ function assertTotpIntent(localOpts: EditOptions): void {
162
180
  }
163
181
 
164
182
  /**
165
- * Same trap as `create`: only a login item has somewhere to put a password, so
166
- * setting one on a note would be accepted and then quietly lost.
183
+ * Same trap as `create`: only a login has somewhere to put a password and only a
184
+ * card somewhere to put a card number, so setting either on another type would
185
+ * be accepted and then quietly lost.
167
186
  */
168
- function assertLoginFlags(
187
+ function assertTypeFlags(
169
188
  localOpts: EditOptions,
170
189
  current: Record<string, unknown>,
171
190
  ): void {
172
- if (current.type === BwItemType.Login) return;
173
-
174
- const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
191
+ const login = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
175
192
  ([, flag]) => flag,
176
193
  );
177
- if (localOpts.uri.length > 0) used.push("--uri");
194
+ if (localOpts.uri.length > 0) login.push("--uri");
178
195
 
179
- if (used.length === 0) return;
196
+ const required: Array<[number, string[]]> = [
197
+ [BwItemType.Login, login],
198
+ [BwItemType.Card, usedCardOnlyFlags(localOpts)],
199
+ ];
180
200
 
181
- throw new CliError(
182
- `${used.join(", ")} ${used.length > 1 ? "need" : "needs"} a login item, but "${String(current.name)}" is not one.`,
183
- ExitCode.BadArgs,
184
- );
201
+ for (const [needed, used] of required) {
202
+ if (needed === current.type || used.length === 0) continue;
203
+ throw new CliError(
204
+ `${used.join(", ")} ${used.length > 1 ? "need" : "needs"} a ${itemTypeLabel(needed)} item, but "${String(current.name)}" is a ${itemTypeLabel(Number(current.type))}.`,
205
+ ExitCode.BadArgs,
206
+ );
207
+ }
185
208
  }
@@ -1,9 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData, emitLog, emitRaw } from "../io.ts";
4
- import { BUILTIN_FIELDS, readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
4
+ import {
5
+ BUILTIN_FIELDS, readItemField, readTotp, readTotpSecret, type FieldRequest,
6
+ } from "../../bw/fields.ts";
5
7
  import { CARD_FIELDS } from "../../bw/cards.ts";
6
8
  import { CliError, ExitCode } from "../errors.ts";
9
+ import { parseFieldReference, REFERENCE_PREFIX } from "../../bw/references.ts";
7
10
  import type { GlobalOptions } from "../types.ts";
8
11
 
9
12
  /** Seconds left below which `--fresh` waits, when the flag is given bare. */
@@ -22,12 +25,12 @@ interface GetOptions {
22
25
  export function registerGet(program: Command): void {
23
26
  program
24
27
  .command("get")
25
- .description("Get a field from a vault item (built-in or custom)")
28
+ .description("Get a field from a vault item or a discovered bwx:// reference")
26
29
  .argument(
27
30
  "<field>",
28
- `Field: ${BUILTIN_FIELDS.join(" | ")} | <custom>. Card items also take: ${CARD_FIELDS.join(" | ")}`,
31
+ `Field or bwx:// ref: ${BUILTIN_FIELDS.join(" | ")} | <custom>. Card items also take: ${CARD_FIELDS.join(" | ")}`,
29
32
  )
30
- .argument("<item>", "Item name or ID")
33
+ .argument("[item]", "Item name or ID (omit when the field is a bwx:// reference)")
31
34
  .option(
32
35
  "--fresh [seconds]",
33
36
  `Wait for the next TOTP window if fewer than N seconds remain (default ${DEFAULT_FRESH_SECONDS})`,
@@ -37,26 +40,28 @@ export function registerGet(program: Command): void {
37
40
  .action(async function (
38
41
  this: Command,
39
42
  field: string,
40
- item: string,
43
+ item: string | undefined,
41
44
  localOpts: GetOptions,
42
45
  ) {
43
46
  const opts = getGlobalOpts(this);
44
47
  assertRawUsable(localOpts, opts);
48
+ const request = parseGetRequest(field, item);
45
49
 
46
- if (field === "totp") {
47
- await emitTotp(item, localOpts, opts);
50
+ if (request.field === "totp" && !request.options?.customOnly) {
51
+ await emitTotp(request.item, localOpts, opts, request.options?.exactItemId);
48
52
  return;
49
53
  }
50
54
 
51
- assertTotpOnlyFlags(field, localOpts);
55
+ assertTotpOnlyFlags(request.field, localOpts);
52
56
 
53
- const value = await readItemField(field, item, opts, {
57
+ const value = await readItemField(request.field, request.item, opts, {
58
+ ...request.options,
54
59
  verbatim: localOpts.raw,
55
60
  });
56
61
 
57
62
  // `item` is the one field that is itself JSON, so it is emitted as
58
63
  // structure — except under `--raw`, which promised the bytes `bw` gave us.
59
- if (field === "item" && !localOpts.raw) {
64
+ if (request.field === "item" && !request.options?.customOnly && !localOpts.raw) {
60
65
  emitData(JSON.parse(value), opts);
61
66
  return;
62
67
  }
@@ -90,6 +95,20 @@ export function registerGet(program: Command): void {
90
95
  });
91
96
  }
92
97
 
98
+ function parseGetRequest(field: string, item: string | undefined): FieldRequest {
99
+ if (field.startsWith(REFERENCE_PREFIX)) {
100
+ const reference = parseFieldReference(field);
101
+ if (item !== undefined) {
102
+ throw new CliError("A field reference already contains the item ID; omit <item>.", ExitCode.BadArgs);
103
+ }
104
+ return reference;
105
+ }
106
+ if (item === undefined) {
107
+ throw new CliError("Expected get <field> <item> or get <ref>.", ExitCode.BadArgs);
108
+ }
109
+ return { field, item };
110
+ }
111
+
93
112
  /**
94
113
  * Emits one field value. `--raw` writes it alone, so piping a secret into a
95
114
  * consumer that reads stdin literally does not append bwx's newline to it;
@@ -131,6 +150,7 @@ async function emitTotp(
131
150
  item: string,
132
151
  localOpts: GetOptions,
133
152
  opts: GlobalOptions,
153
+ exactItemId = false,
134
154
  ): Promise<void> {
135
155
  if (localOpts.seed) {
136
156
  if (localOpts.fresh !== undefined) {
@@ -139,11 +159,12 @@ async function emitTotp(
139
159
  ExitCode.BadArgs,
140
160
  );
141
161
  }
142
- emitValue(await readTotpSecret(item, opts), localOpts, opts);
162
+ emitValue(await readTotpSecret(item, opts, exactItemId), localOpts, opts);
143
163
  return;
144
164
  }
145
165
 
146
166
  const totp = await readTotp(item, opts, {
167
+ exactItemId,
147
168
  minSecondsRemaining: parseFreshSeconds(localOpts.fresh),
148
169
  onWait: (seconds) =>
149
170
  emitLog(`Waiting ${seconds}s for a fresh code…`, opts),
@@ -1,22 +1,21 @@
1
1
  import type { Command } from "commander";
2
2
  import { constants } from "node:os";
3
- import { readFields } from "../../bw/fields.ts";
3
+ import { readFields, type FieldRequest } from "../../bw/fields.ts";
4
+ import { parseFieldReference, REFERENCE_PREFIX } from "../../bw/references.ts";
4
5
  import { CliError, ExitCode } from "../errors.ts";
5
6
  import { collect } from "../input.ts";
6
7
  import { emitLog } from "../io.ts";
7
8
  import { getGlobalOpts } from "../program.ts";
8
9
  import type { GlobalOptions } from "../types.ts";
9
10
 
10
- /** `NAME=<field>:<item>` — one secret to place in the child's environment. */
11
- export interface EnvSpec {
11
+ /** `NAME=<field>:<item>` or `NAME=<ref>` — one secret for the child's environment. */
12
+ export interface EnvSpec extends FieldRequest {
12
13
  name: string;
13
- field: string;
14
- item: string;
15
14
  }
16
15
 
17
16
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
17
 
19
- const SPEC_HINT = "Expected --env NAME=<field>:<item>, e.g. --env DB_PASS=password:'Prod DB'";
18
+ const SPEC_HINT = "Expected --env NAME=<field>:<item> or NAME=<ref>, e.g. --env DB_PASS=password:'Prod DB'";
20
19
 
21
20
  export function registerRun(program: Command): void {
22
21
  program
@@ -25,7 +24,7 @@ export function registerRun(program: Command): void {
25
24
  .argument("<command...>", "Command to run — use -- before it if it takes its own flags")
26
25
  .option(
27
26
  "--env <spec>",
28
- "Inject a secret as NAME=<field>:<item> (repeatable)",
27
+ "Inject a secret as NAME=<field>:<item> or NAME=<ref> (repeatable)",
29
28
  collect,
30
29
  [],
31
30
  )
@@ -71,7 +70,8 @@ export function parseEnvSpecs(raws: string[]): EnvSpec[] {
71
70
  * Splits `NAME=<field>:<item>` on the first `=` and then the first `:`, so item
72
71
  * names may contain colons (`password:http://my.router`) while field names may not.
73
72
  * Fields resolve exactly as in `bwx get`: built-ins first, then card fields on a card
74
- * item, otherwise custom fields.
73
+ * item, otherwise custom fields. A bwx:// reference supplies an exact ID and an
74
+ * explicit field kind, bypassing the legacy colon-separated grammar.
75
75
  */
76
76
  export function parseEnvSpec(raw: string): EnvSpec {
77
77
  const eq = raw.indexOf("=");
@@ -88,6 +88,9 @@ export function parseEnvSpec(raw: string): EnvSpec {
88
88
  }
89
89
 
90
90
  const ref = raw.slice(eq + 1);
91
+ if (ref.startsWith(REFERENCE_PREFIX)) {
92
+ return { name, ...parseFieldReference(ref) };
93
+ }
91
94
  const colon = ref.indexOf(":");
92
95
  if (colon <= 0 || colon === ref.length - 1) {
93
96
  throw new CliError(`Invalid --env "${raw}". ${SPEC_HINT}`, ExitCode.BadArgs);
@@ -105,10 +108,7 @@ async function resolveSecrets(
105
108
  specs: EnvSpec[],
106
109
  opts: GlobalOptions,
107
110
  ): Promise<Record<string, string>> {
108
- const values = await readFields(
109
- specs.map((spec) => ({ field: spec.field, item: spec.item })),
110
- opts,
111
- );
111
+ const values = await readFields(specs, opts);
112
112
 
113
113
  return Object.fromEntries(
114
114
  specs.map((spec, index) => [spec.name, values[index]!]),
@@ -131,9 +131,10 @@ async function spawnChild(
131
131
  stderr: "inherit",
132
132
  env: { ...process.env, ...secrets },
133
133
  });
134
- } catch (err) {
134
+ } catch {
135
+ // Spawn diagnostics may quote the environment that already holds secrets.
135
136
  throw new CliError(
136
- `Failed to run "${command[0]}": ${err instanceof Error ? err.message : String(err)}`,
137
+ "Failed to run requested command. Check the executable and environment.",
137
138
  ExitCode.BadArgs,
138
139
  );
139
140
  }
@@ -10,6 +10,12 @@ import {
10
10
  import { runBwOrThrow } from "../../bw/runner.ts";
11
11
  import { withSession } from "../../bw/session.ts";
12
12
  import { resolveFolderFilter } from "../../bw/folders.ts";
13
+ import { withSafeVaultErrors } from "../../bw/safe-errors.ts";
14
+ import type { GlobalOptions } from "../types.ts";
15
+
16
+ interface SearchOptions extends ItemListOptions {
17
+ folder?: string;
18
+ }
13
19
 
14
20
  export function registerSearch(program: Command): void {
15
21
  program
@@ -20,26 +26,37 @@ export function registerSearch(program: Command): void {
20
26
  .option("--type <type>", "Filter by type: login|note|card|identity")
21
27
  .option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
22
28
  .option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
29
+ .option("--fields", "Discover available fields and reusable references (no values)")
23
30
  .option("--full-items", "Emit complete item objects (includes secrets)")
24
31
  .action(async function (
25
32
  this: Command,
26
33
  query: string,
27
- localOpts: ItemListOptions & { folder?: string },
34
+ localOpts: SearchOptions,
28
35
  ) {
29
36
  const opts = getGlobalOpts(this);
30
-
31
- const args = ["list", "items", "--search", query];
32
- if (localOpts.folder) {
33
- args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
37
+ if (localOpts.fields && localOpts.fullItems) {
38
+ throw new CliError("--fields and --full-items cannot be combined.", ExitCode.BadArgs);
34
39
  }
40
+ const search = () => searchItems(query, localOpts, opts);
41
+ if (localOpts.fields) await withSafeVaultErrors("Could not discover fields", search);
42
+ else await search();
43
+ });
44
+ }
35
45
 
36
- const json = await withSession(opts, () => runBwOrThrow(args));
37
- const filtered = filterItems(JSON.parse(json), localOpts);
38
-
39
- if (filtered.total === 0) {
40
- throw new CliError(`No items matching "${query}"`, ExitCode.NotFound);
41
- }
46
+ async function searchItems(
47
+ query: string,
48
+ localOpts: SearchOptions,
49
+ opts: GlobalOptions,
50
+ ): Promise<void> {
51
+ const args = ["list", "items", "--search", query];
52
+ if (localOpts.folder) {
53
+ args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
54
+ }
42
55
 
43
- writeItems(filtered, opts, localOpts);
44
- });
56
+ const json = await withSession(opts, () => runBwOrThrow(args));
57
+ const filtered = filterItems(JSON.parse(json), localOpts);
58
+ if (filtered.total === 0) {
59
+ throw new CliError(`No items matching "${query}"`, ExitCode.NotFound);
60
+ }
61
+ writeItems(filtered, opts, localOpts);
45
62
  }
package/src/cli/input.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
3
3
  import { normalizeTotpInput } from "../bw/totp.ts";
4
+ import { normalizeCardNumber, normalizeCvv } from "../bw/card-input.ts";
4
5
  import { CliError, ExitCode } from "./errors.ts";
5
6
 
6
7
  export function collect(val: string, prev: string[]): string[] {
@@ -65,6 +66,16 @@ export interface ItemTextInputs {
65
66
  totpStdin?: boolean;
66
67
  totpFile?: string;
67
68
  totpEnv?: string;
69
+ number?: string;
70
+ numberStdin?: boolean;
71
+ numberFile?: string;
72
+ numberEnv?: string;
73
+ cvv?: string;
74
+ cvvStdin?: boolean;
75
+ cvvFile?: string;
76
+ cvvEnv?: string;
77
+ /** `--no-validate` sets this false, skipping the card number's Luhn check. */
78
+ validate?: boolean;
68
79
  }
69
80
 
70
81
  export interface ResolvedItemTextInputs {
@@ -74,6 +85,9 @@ export interface ResolvedItemTextInputs {
74
85
  passwordGenerated?: boolean;
75
86
  /** Normalized and validated by {@link normalizeTotpInput} before it is stored. */
76
87
  totp?: string;
88
+ /** Digits only, checked by {@link normalizeCardNumber}. */
89
+ number?: string;
90
+ cvv?: string;
77
91
  }
78
92
 
79
93
  export interface ExplicitStdinInputs extends ItemTextInputs {
@@ -81,6 +95,9 @@ export interface ExplicitStdinInputs extends ItemTextInputs {
81
95
  fieldFiles?: string[];
82
96
  }
83
97
 
98
+ /** The item type a value can only be stored on. */
99
+ export type ItemTypeName = "login" | "card";
100
+
84
101
  /** Which slot of a {@link TextSource} a flag fills. */
85
102
  type TextSourceKind = "inline" | "stdin" | "file" | "env" | "generate";
86
103
 
@@ -89,29 +106,28 @@ interface TextInputFlag {
89
106
  spec: string;
90
107
  description: string;
91
108
  /** The property commander derives from `spec`. */
92
- key: keyof ItemTextInputs;
109
+ key: Exclude<keyof ItemTextInputs, "validate">;
93
110
  kind: TextSourceKind;
94
111
  }
95
112
 
96
113
  /**
97
114
  * One value a caller can supply, and every flag that can supply it. Option
98
115
  * registration, the "choose only one source" error, the stdin-conflict check and
99
- * the login-only guard all read from here, so adding a source touches one table
116
+ * the item-type guards all read from here, so adding a source touches one table
100
117
  * rather than both write commands and three hand-kept lists.
101
118
  */
102
119
  export interface TextInputFamily {
103
120
  /** Names the value in error messages. */
104
121
  label: string;
105
122
  flags: TextInputFlag[];
106
- /** True when only a login item has anywhere to store the value. */
107
- loginOnly: boolean;
123
+ /** The only item type with anywhere to store the value; unset means any. */
124
+ itemType?: ItemTypeName;
108
125
  /** Whether empty is a legitimate value — it is, for notes. */
109
126
  allowEmpty?: boolean;
110
127
  }
111
128
 
112
129
  const NOTES_FAMILY: TextInputFamily = {
113
130
  label: "notes",
114
- loginOnly: false,
115
131
  allowEmpty: true,
116
132
  flags: [
117
133
  {
@@ -131,7 +147,7 @@ const NOTES_FAMILY: TextInputFamily = {
131
147
 
132
148
  const PASSWORD_FAMILY: TextInputFamily = {
133
149
  label: "password",
134
- loginOnly: true,
150
+ itemType: "login",
135
151
  flags: [
136
152
  {
137
153
  spec: "--password <pass>",
@@ -168,7 +184,7 @@ const PASSWORD_FAMILY: TextInputFamily = {
168
184
 
169
185
  export const TOTP_FAMILY: TextInputFamily = {
170
186
  label: "TOTP secret",
171
- loginOnly: true,
187
+ itemType: "login",
172
188
  flags: [
173
189
  {
174
190
  spec: "--totp <secret>",
@@ -197,7 +213,75 @@ export const TOTP_FAMILY: TextInputFamily = {
197
213
  ],
198
214
  };
199
215
 
200
- const ITEM_TEXT_FAMILIES = [NOTES_FAMILY, PASSWORD_FAMILY, TOTP_FAMILY];
216
+ export const CARD_NUMBER_FAMILY: TextInputFamily = {
217
+ label: "card number",
218
+ itemType: "card",
219
+ flags: [
220
+ {
221
+ spec: "--number <number>",
222
+ description: "Card number (card items only)",
223
+ key: "number",
224
+ kind: "inline",
225
+ },
226
+ {
227
+ spec: "--number-stdin",
228
+ description: "Read the card number from stdin",
229
+ key: "numberStdin",
230
+ kind: "stdin",
231
+ },
232
+ {
233
+ spec: "--number-file <path>",
234
+ description: "Read the card number from file (use - for stdin)",
235
+ key: "numberFile",
236
+ kind: "file",
237
+ },
238
+ {
239
+ spec: "--number-env <name>",
240
+ description: "Read the card number from environment variable",
241
+ key: "numberEnv",
242
+ kind: "env",
243
+ },
244
+ ],
245
+ };
246
+
247
+ export const CVV_FAMILY: TextInputFamily = {
248
+ label: "CVV",
249
+ itemType: "card",
250
+ flags: [
251
+ {
252
+ spec: "--cvv <cvv>",
253
+ description: "Card security code (card items only)",
254
+ key: "cvv",
255
+ kind: "inline",
256
+ },
257
+ {
258
+ spec: "--cvv-stdin",
259
+ description: "Read the CVV from stdin",
260
+ key: "cvvStdin",
261
+ kind: "stdin",
262
+ },
263
+ {
264
+ spec: "--cvv-file <path>",
265
+ description: "Read the CVV from file (use - for stdin)",
266
+ key: "cvvFile",
267
+ kind: "file",
268
+ },
269
+ {
270
+ spec: "--cvv-env <name>",
271
+ description: "Read the CVV from environment variable",
272
+ key: "cvvEnv",
273
+ kind: "env",
274
+ },
275
+ ],
276
+ };
277
+
278
+ const ITEM_TEXT_FAMILIES = [
279
+ NOTES_FAMILY,
280
+ PASSWORD_FAMILY,
281
+ TOTP_FAMILY,
282
+ CARD_NUMBER_FAMILY,
283
+ CVV_FAMILY,
284
+ ];
201
285
 
202
286
  function flagName(spec: string): string {
203
287
  return spec.split(" ")[0]!;
@@ -208,8 +292,8 @@ function sourceFlagNames(family: TextInputFamily): string[] {
208
292
  }
209
293
 
210
294
  /**
211
- * Registers every flag that can supply notes, a password, or a TOTP secret, plus
212
- * the length modifier `--password-generate` takes. `create` and `edit` accept the
295
+ * Registers every flag that can supply notes, a password, a TOTP secret, a card
296
+ * number, or a CVV, plus the length modifier `--password-generate` takes. `create` and `edit` accept the
213
297
  * identical set and {@link resolveItemTextInputs} is their only reader, so the
214
298
  * set is declared once here instead of per command.
215
299
  */
@@ -220,20 +304,24 @@ export function registerItemTextOptions(command: Command): Command {
220
304
  }
221
305
  }
222
306
 
223
- return command.option(
224
- "--generate-length <n>",
225
- "Length for --password-generate",
226
- (value) => Number.parseInt(value, 10),
227
- );
307
+ return command
308
+ .option(
309
+ "--generate-length <n>",
310
+ "Length for --password-generate",
311
+ (value) => Number.parseInt(value, 10),
312
+ )
313
+ .option("--no-validate", "Skip the card number's Luhn check");
228
314
  }
229
315
 
230
316
  /**
231
- * The `[key, flag]` pairs a command must reject on a non-login item, because
232
- * `buildNewItem` and `patchItem` have nowhere to put them and would accept the
233
- * value only to drop it.
317
+ * The `[key, flag]` pairs a command must reject on an item of any other type,
318
+ * because `buildNewItem` and `patchItem` have nowhere to put them and would
319
+ * accept the value only to drop it.
234
320
  */
235
- export function loginOnlyTextInputFlags(): Array<[keyof ItemTextInputs, string]> {
236
- return ITEM_TEXT_FAMILIES.filter((family) => family.loginOnly).flatMap(
321
+ export function typeOnlyTextInputFlags(
322
+ itemType: ItemTypeName,
323
+ ): Array<[keyof ItemTextInputs, string]> {
324
+ return ITEM_TEXT_FAMILIES.filter((family) => family.itemType === itemType).flatMap(
237
325
  (family) =>
238
326
  family.flags.map(
239
327
  (flag) =>
@@ -242,6 +330,10 @@ export function loginOnlyTextInputFlags(): Array<[keyof ItemTextInputs, string]>
242
330
  );
243
331
  }
244
332
 
333
+ export function loginOnlyTextInputFlags(): Array<[keyof ItemTextInputs, string]> {
334
+ return typeOnlyTextInputFlags("login");
335
+ }
336
+
245
337
  /** The flags a caller actually used to supply this family's value. */
246
338
  export function listUsedSourceFlags(
247
339
  family: TextInputFamily,
@@ -259,43 +351,15 @@ export async function resolveItemTextInputs(
259
351
  ): Promise<ResolvedItemTextInputs> {
260
352
  assertSingleStdinSource(listExplicitStdinInputs(inputs));
261
353
 
262
- const [notes, password, totp] = await Promise.all([
263
- resolveOptionalTextSource(
264
- {
265
- label: NOTES_FAMILY.label,
266
- inline: inputs.notes,
267
- file: inputs.notesFile,
268
- flagNames: sourceFlagNames(NOTES_FAMILY),
269
- allowEmpty: NOTES_FAMILY.allowEmpty,
270
- },
271
- stdin,
272
- ),
273
- resolveOptionalTextSource(
274
- {
275
- label: PASSWORD_FAMILY.label,
276
- inline: inputs.password,
277
- stdin: inputs.passwordStdin,
278
- file: inputs.passwordFile,
279
- env: inputs.passwordEnv,
280
- generate:
281
- inputs.passwordGenerate && generatePassword
282
- ? generatePassword
283
- : undefined,
284
- flagNames: sourceFlagNames(PASSWORD_FAMILY),
285
- },
286
- stdin,
287
- ),
354
+ const [notes, password, totp, number, cvv] = await Promise.all([
355
+ resolveOptionalTextSource(familySource(NOTES_FAMILY, inputs), stdin),
288
356
  resolveOptionalTextSource(
289
- {
290
- label: TOTP_FAMILY.label,
291
- inline: inputs.totp,
292
- stdin: inputs.totpStdin,
293
- file: inputs.totpFile,
294
- env: inputs.totpEnv,
295
- flagNames: sourceFlagNames(TOTP_FAMILY),
296
- },
357
+ familySource(PASSWORD_FAMILY, inputs, generatePassword),
297
358
  stdin,
298
359
  ),
360
+ resolveOptionalTextSource(familySource(TOTP_FAMILY, inputs), stdin),
361
+ resolveOptionalTextSource(familySource(CARD_NUMBER_FAMILY, inputs), stdin),
362
+ resolveOptionalTextSource(familySource(CVV_FAMILY, inputs), stdin),
299
363
  ]);
300
364
 
301
365
  return {
@@ -305,9 +369,50 @@ export async function resolveItemTextInputs(
305
369
  // Validated here rather than at the call sites so every write path gets
306
370
  // the check: a malformed seed is only ever noticed at the login it breaks.
307
371
  totp: totp === undefined ? undefined : normalizeTotpInput(totp),
372
+ number:
373
+ number === undefined
374
+ ? undefined
375
+ : normalizeCardNumber(number, { luhn: inputs.validate !== false }),
376
+ cvv: cvv === undefined ? undefined : normalizeCvv(cvv),
308
377
  };
309
378
  }
310
379
 
380
+ /** The {@link TextSource} a family's flags describe, filled from parsed options. */
381
+ function familySource(
382
+ family: TextInputFamily,
383
+ inputs: ItemTextInputs,
384
+ generate?: () => Promise<string>,
385
+ ): TextSource {
386
+ const source: TextSource = {
387
+ label: family.label,
388
+ flagNames: sourceFlagNames(family),
389
+ allowEmpty: family.allowEmpty,
390
+ };
391
+
392
+ for (const flag of family.flags) {
393
+ const value = inputs[flag.key];
394
+ switch (flag.kind) {
395
+ case "inline":
396
+ source.inline = value as string | undefined;
397
+ break;
398
+ case "stdin":
399
+ source.stdin = value as boolean | undefined;
400
+ break;
401
+ case "file":
402
+ source.file = value as string | undefined;
403
+ break;
404
+ case "env":
405
+ source.env = value as string | undefined;
406
+ break;
407
+ case "generate":
408
+ source.generate = value && generate ? generate : undefined;
409
+ break;
410
+ }
411
+ }
412
+
413
+ return source;
414
+ }
415
+
311
416
  export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
312
417
  const sources: string[] = [];
313
418