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.
- package/README.md +96 -7
- package/package.json +3 -5
- 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/commands/edit.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
*
|
|
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
|
|
187
|
+
function assertTypeFlags(
|
|
169
188
|
localOpts: EditOptions,
|
|
170
189
|
current: Record<string, unknown>,
|
|
171
190
|
): void {
|
|
172
|
-
|
|
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)
|
|
194
|
+
if (localOpts.uri.length > 0) login.push("--uri");
|
|
178
195
|
|
|
179
|
-
|
|
196
|
+
const required: Array<[number, string[]]> = [
|
|
197
|
+
[BwItemType.Login, login],
|
|
198
|
+
[BwItemType.Card, usedCardOnlyFlags(localOpts)],
|
|
199
|
+
];
|
|
180
200
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
}
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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("
|
|
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),
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -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
|
|
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
|
|
134
|
+
} catch {
|
|
135
|
+
// Spawn diagnostics may quote the environment that already holds secrets.
|
|
135
136
|
throw new CliError(
|
|
136
|
-
|
|
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:
|
|
34
|
+
localOpts: SearchOptions,
|
|
28
35
|
) {
|
|
29
36
|
const opts = getGlobalOpts(this);
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|