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/bw/encoding.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
119
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|