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 +21 -0
- package/README.md +100 -7
- package/package.json +23 -6
- package/skill/bwx/SKILL.md +75 -7
- package/src/bw/card-input.ts +180 -0
- package/src/bw/discovery.ts +59 -0
- package/src/bw/encoding.ts +32 -5
- package/src/bw/fields.ts +83 -11
- package/src/bw/items.ts +1 -2
- package/src/bw/references.ts +58 -0
- package/src/bw/safe-errors.ts +24 -0
- package/src/bw/types.ts +16 -0
- package/src/cli/card-options.ts +169 -0
- package/src/cli/commands/create.ts +40 -18
- package/src/cli/commands/edit.ts +40 -17
- package/src/cli/commands/get.ts +32 -11
- package/src/cli/commands/run.ts +15 -14
- package/src/cli/commands/search.ts +30 -13
- package/src/cli/input.ts +159 -54
- package/src/cli/items.ts +26 -1
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
|
|
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
|
-
/**
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
232
|
-
* `buildNewItem` and `patchItem` have nowhere to put them and would
|
|
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
|
|
236
|
-
|
|
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
|
-
|
|
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}`;
|