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/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
 
package/src/cli/items.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import pc from "picocolors";
2
2
  import { summarizeItem, type ItemSummary } from "../bw/items.ts";
3
+ import { discoverItem, type ItemDiscovery } from "../bw/discovery.ts";
3
4
  import { BW_TYPE_FROM_NAME } from "../bw/types.ts";
4
5
  import { CliError, ExitCode } from "./errors.ts";
5
6
  import { emitData, emitWarn } from "./io.ts";
@@ -18,6 +19,8 @@ export interface ItemListOptions {
18
19
  limit?: number;
19
20
  /** Opt in to raw bw items, which carry passwords, notes, and custom fields. */
20
21
  fullItems?: boolean;
22
+ /** Metadata and consumable references only; never field values. */
23
+ fields?: boolean;
21
24
  }
22
25
 
23
26
  export interface FilteredItems {
@@ -67,6 +70,7 @@ export function filterItems(
67
70
  * the same reduced projection, so asking for machine-readable output is a
68
71
  * serialization choice and never widens data access. `--full-items` is the
69
72
  * explicit opt-in for callers that genuinely need the complete objects.
73
+ * `--fields` uses a separate allowlist for capabilities and consumable refs.
70
74
  *
71
75
  * A truncated listing reports what it dropped through every channel — a stderr
72
76
  * warning that survives `--json`, plus `meta` in the JSON envelope — because a
@@ -79,6 +83,8 @@ export function writeItems(
79
83
  ): void {
80
84
  const { items, total } = filtered;
81
85
  const omitted = total - items.length;
86
+ // Project the whole response before emitting anything, including warnings.
87
+ const discovery = options.fields ? items.map(discoverItem) : undefined;
82
88
 
83
89
  if (omitted > 0) {
84
90
  emitWarn(
@@ -88,13 +94,20 @@ export function writeItems(
88
94
  }
89
95
 
90
96
  if (opts.json) {
97
+ let data: unknown = items;
98
+ if (discovery) data = discovery;
99
+ else if (!options.fullItems) data = items.map(summarizeItem);
91
100
  emitData(
92
- options.fullItems ? items : items.map(summarizeItem),
101
+ data,
93
102
  opts,
94
103
  { total, shown: items.length, truncated: omitted > 0 },
95
104
  );
96
105
  return;
97
106
  }
107
+ if (discovery) {
108
+ writeLines(discovery, opts.plain ? plainDiscovery : humanDiscovery);
109
+ return;
110
+ }
98
111
 
99
112
  if (options.fullItems) {
100
113
  // Plain output stays line-oriented: one compact item per line (NDJSON).
@@ -106,6 +119,18 @@ export function writeItems(
106
119
  writeLines(items.map(summarizeItem), opts.plain ? plainLine : humanLine);
107
120
  }
108
121
 
122
+ function plainDiscovery(item: ItemDiscovery): string {
123
+ return `${item.id}\t${item.type}\t${JSON.stringify(item.name)}\t${JSON.stringify(item.fields)}`;
124
+ }
125
+
126
+ function humanDiscovery(item: ItemDiscovery): string {
127
+ const header = `${pc.dim(item.id)} ${pc.cyan(item.type)} ${JSON.stringify(item.name)}`;
128
+ const fields = item.fields.map((field) =>
129
+ ` ${field.kind} ${JSON.stringify(field.name)} [${field.type}] ${field.ref}`,
130
+ );
131
+ return [header, ...fields].join("\n");
132
+ }
133
+
109
134
  function plainLine(summary: ItemSummary): string {
110
135
  const totp = summary.hasTotp ? "totp" : "-";
111
136
  return `${summary.id}\t${summary.type}\t${summary.name}\t${summary.username ?? "-"}\t${totp}`;