bwx 0.6.3 → 0.8.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 +65 -2
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +42 -2
- package/src/bw/cards.ts +116 -0
- package/src/bw/fields.ts +61 -12
- package/src/bw/items.ts +2 -2
- package/src/bw/runner.ts +17 -1
- package/src/bw/types.ts +56 -9
- package/src/cli/commands/get.ts +79 -19
- package/src/cli/commands/run.ts +2 -1
- package/src/cli/io.ts +10 -0
package/README.md
CHANGED
|
@@ -69,9 +69,10 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
|
|
|
69
69
|
|
|
70
70
|
| Command | Description |
|
|
71
71
|
|---------|-------------|
|
|
72
|
-
| `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
|
|
72
|
+
| `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`), `--raw` |
|
|
73
73
|
| `bwx get totp <item>` | TOTP code (`--fresh [n]`, `--seed`) |
|
|
74
|
-
| `bwx
|
|
74
|
+
| `bwx get <card field> <item>` | Card field (`number`, `cvv`, `cardholder`, `brand`, `expiry`, …) |
|
|
75
|
+
| `bwx field <name> <item>` | Get custom field by name (same argument order as `get`), `--raw` |
|
|
75
76
|
| `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
76
77
|
| `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
77
78
|
| `bwx folders` | List folder names and IDs |
|
|
@@ -103,6 +104,29 @@ error: No item matching "GitHub PAT vforsh old". Did you mean:
|
|
|
103
104
|
3683fb9a-… note GitHub PAT (yaforsh) [codex-monitor-pr]
|
|
104
105
|
```
|
|
105
106
|
|
|
107
|
+
### Piping a secret
|
|
108
|
+
|
|
109
|
+
`get` and `field` end their output with a newline, which reads fine in a terminal and
|
|
110
|
+
lands *inside the secret* for a consumer that treats stdin literally. `--raw` suppresses
|
|
111
|
+
that one newline — and only that one: newlines stored in the value are its own and are
|
|
112
|
+
written through.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
bwx get password "GitHub" --raw | argus fill app --selector 'input[type=password]' --value-stdin
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`--raw` values are byte-for-byte, so whitespace around a password survives too (the
|
|
119
|
+
default output trims it). `--raw` and `--json` are mutually exclusive — the JSON envelope
|
|
120
|
+
quotes and wraps the value, so there is no raw byte stream to hand over — and combining
|
|
121
|
+
them is a usage error rather than a silent winner.
|
|
122
|
+
|
|
123
|
+
For handing a secret to a whole command rather than to one stdin, `bwx run` keeps it out
|
|
124
|
+
of argv entirely:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
bwx run --env GITHUB_TOKEN=password:"GitHub PAT" -- gh api /user
|
|
128
|
+
```
|
|
129
|
+
|
|
106
130
|
### TOTP
|
|
107
131
|
|
|
108
132
|
Codes are computed locally from the stored secret. A TOTP therefore costs no extra `bw`
|
|
@@ -139,6 +163,45 @@ Spaces, hyphens, and `=` padding are stripped and case is normalized; an `otpaut
|
|
|
139
163
|
is stored verbatim so its parameters survive. Malformed secrets are rejected before the
|
|
140
164
|
write, because a bad seed is otherwise discovered at the login it was supposed to unlock.
|
|
141
165
|
|
|
166
|
+
### Cards
|
|
167
|
+
|
|
168
|
+
Card items keep their values in a `card` object that `bw get <field>` cannot reach, so
|
|
169
|
+
bwx reads them from the item itself — one vault read, no extra `bw` spawn:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
bwx get number "Visa" # 4111111111111111
|
|
173
|
+
bwx get cvv "Visa" # 123
|
|
174
|
+
bwx get cardholder "Visa" # Vladislav Forsh
|
|
175
|
+
bwx get brand "Visa" # Visa
|
|
176
|
+
bwx get expiry "Visa" # 03/2030
|
|
177
|
+
bwx get expMonth "Visa" # 03
|
|
178
|
+
bwx get expYear "Visa" # 2030
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Names match leniently on case, dashes, and underscores, and bw's own JSON keys work too,
|
|
182
|
+
so a name copied out of `bwx get item` resolves (`code` → `cvv`, `cardholderName` →
|
|
183
|
+
`cardholder`, `exp-month` → `expMonth`).
|
|
184
|
+
|
|
185
|
+
Two values are normalized rather than passed through, because the stored form is not the
|
|
186
|
+
usable one:
|
|
187
|
+
|
|
188
|
+
- `expMonth` is zero-padded — bw stores the month as typed (`3`), and every `MM` field
|
|
189
|
+
wants `03`.
|
|
190
|
+
- `expiry` is composed from month and year, and is withheld entirely if either is missing
|
|
191
|
+
rather than handing back half a date.
|
|
192
|
+
|
|
193
|
+
A card field only means the card object **on a card item**. `number`, `code`, and `brand`
|
|
194
|
+
are plausible custom field names, so on any other item type they resolve as custom fields
|
|
195
|
+
exactly as before — and if no such custom field exists, the error says which kind of item
|
|
196
|
+
it actually is. `bwx field` is unchanged: custom fields only, never a card field.
|
|
197
|
+
|
|
198
|
+
Grouping several card fields into one read is worth it — see [Secrets into a child
|
|
199
|
+
process](#secrets-into-a-child-process):
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
bwx run --env NUM=number:Visa --env CVV=cvv:Visa --env EXP=expiry:Visa -- ./pay.sh
|
|
203
|
+
```
|
|
204
|
+
|
|
142
205
|
### Listings are capped
|
|
143
206
|
|
|
144
207
|
Listings stop at 50 items by default — an uncapped `bwx list` returns the whole vault,
|
package/package.json
CHANGED
package/skill/bwx/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bwx
|
|
3
|
-
description: Bitwarden Extended CLI — retrieve, create, edit, delete vault items with auto-unlock. Use when the agent needs passwords, API keys, TOTP codes, custom fields, or vault management. Triggers on mentions of Bitwarden, bw, bwx, vault, password, secrets, TOTP, save key/token.
|
|
3
|
+
description: Bitwarden Extended CLI — retrieve, create, edit, delete vault items with auto-unlock. Use when the agent needs passwords, API keys, TOTP codes, card details, custom fields, or vault management. Triggers on mentions of Bitwarden, bw, bwx, vault, password, secrets, TOTP, card number, CVV, save key/token.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# bwx
|
|
@@ -17,8 +17,10 @@ bwx get username "GitHub" # username
|
|
|
17
17
|
bwx get notes "Deploy key" # secure note content
|
|
18
18
|
bwx get uri "GitHub" # first URI
|
|
19
19
|
bwx get item "GitHub" # full item JSON
|
|
20
|
+
bwx get number "Visa" # card number (also: cvv, expiry, cardholder, brand)
|
|
20
21
|
bwx get "API Key" "Acme" # custom field by name
|
|
21
22
|
bwx field "API Key" "Acme" # custom-field-only lookup, same arg order
|
|
23
|
+
bwx get password "GitHub" --raw # no trailing newline, for piping into stdin
|
|
22
24
|
|
|
23
25
|
bwx search github # fuzzy search, human table
|
|
24
26
|
bwx search github --json # id/type/name/username only — no secrets
|
|
@@ -45,6 +47,7 @@ echo "secret" | bwx create --name "Piped Note"
|
|
|
45
47
|
### `bwx get <field> <item>`
|
|
46
48
|
|
|
47
49
|
Built-in fields: `password | username | totp | notes | uri | item`.
|
|
50
|
+
Card items additionally take `number | cvv | cardholder | brand | expiry | expMonth | expYear`.
|
|
48
51
|
Anything else is looked up as a custom field. Lists available fields on miss.
|
|
49
52
|
|
|
50
53
|
A miss suggests near matches; an ambiguous name lists candidates with IDs. Read the
|
|
@@ -76,6 +79,43 @@ exactly are delegated to `bw get totp` instead of guessed at.
|
|
|
76
79
|
Custom fields only, so an item whose custom field is named `password` is still reachable.
|
|
77
80
|
Takes its arguments in the same order as `get` (this changed in 0.5.0).
|
|
78
81
|
|
|
82
|
+
### `--raw` (on `get` and `field`)
|
|
83
|
+
|
|
84
|
+
Output normally ends in a newline, which a consumer that reads stdin literally takes as
|
|
85
|
+
the last byte of the secret. `--raw` suppresses that one newline and nothing else —
|
|
86
|
+
newlines inside the value are written through, and the value is byte-for-byte, so
|
|
87
|
+
surrounding whitespace survives (the default output trims it).
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
bwx get password "GitHub" --raw | argus fill app --selector 'input[type=password]' --value-stdin
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Prefer this over `$(bwx get …)` when piping a secret: command substitution eats trailing
|
|
94
|
+
newlines *and* puts the secret in the child's argv, where any same-user process can read
|
|
95
|
+
it. `--raw` and `--json` cannot be combined (the envelope quotes and wraps the value); use
|
|
96
|
+
`bwx run --env` when a whole command needs the secret in its environment instead.
|
|
97
|
+
|
|
98
|
+
### Card fields
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bwx get number "Visa" # 4111111111111111
|
|
102
|
+
bwx get cvv "Visa" # 123 (bw's own key `code` works too)
|
|
103
|
+
bwx get cardholder "Visa" # Vladislav Forsh
|
|
104
|
+
bwx get brand "Visa" # Visa
|
|
105
|
+
bwx get expiry "Visa" # 03/2030 (composed; withheld if either half is missing)
|
|
106
|
+
bwx get expMonth "Visa" # 03 (zero-padded; bw stores "3")
|
|
107
|
+
bwx get expYear "Visa" # 2030
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Read from the fetched item — `bw` has no card fields — so they cost one vault read and
|
|
111
|
+
batch with each other under `bwx run`. Names match leniently on case, dashes, and
|
|
112
|
+
underscores, and bw's JSON keys resolve (`code`, `cardholderName`, `exp-month`).
|
|
113
|
+
|
|
114
|
+
A card field means the card object **only on a card item**. On any other type it resolves
|
|
115
|
+
as a custom field of that name, since `number` and `code` are ordinary field names; the
|
|
116
|
+
error names the item's actual type when no such field exists. `bwx field` never returns a
|
|
117
|
+
card field.
|
|
118
|
+
|
|
79
119
|
### `bwx search <query>` / `bwx list [type]`
|
|
80
120
|
|
|
81
121
|
Flags: `--type login|note|card|identity`, `--folder <name|id>`, `--limit <n>`, `--full-items`
|
|
@@ -108,7 +148,7 @@ bwx run --env DB_PASS=password:'Prod DB' -- ./migrate.sh
|
|
|
108
148
|
bwx run --env TOKEN='API Key:Acme' --env USER=username:Acme -- ./deploy.sh
|
|
109
149
|
```
|
|
110
150
|
|
|
111
|
-
- `--env` is repeatable; the field resolves as in `bwx get` (built-in, else custom field).
|
|
151
|
+
- `--env` is repeatable; the field resolves as in `bwx get` (built-in, else card, else custom field).
|
|
112
152
|
- Only the first `:` splits field from item, so `password:http://my.router` works.
|
|
113
153
|
- Put `--` before the command when it takes its own flags.
|
|
114
154
|
- Child stdio is inherited; bwx exits with the child's status (`128 + signal` if killed).
|
package/src/bw/cards.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { BwCard } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Card items keep their values in a `card` object rather than in `login` or in
|
|
5
|
+
* custom fields, and `bw get <field>` has no equivalent for any of them — so
|
|
6
|
+
* these names are resolved here, from an item bwx has already fetched.
|
|
7
|
+
*
|
|
8
|
+
* A reader returns the value or `null`; naming the item and raising is the
|
|
9
|
+
* caller's job, since it holds the item name and the error type.
|
|
10
|
+
*/
|
|
11
|
+
type CardReader = (card: BwCard) => string | null;
|
|
12
|
+
|
|
13
|
+
/** Canonical names, in the order help text and errors list them. */
|
|
14
|
+
export const CARD_FIELDS = [
|
|
15
|
+
"number",
|
|
16
|
+
"cvv",
|
|
17
|
+
"cardholder",
|
|
18
|
+
"brand",
|
|
19
|
+
"expiry",
|
|
20
|
+
"expMonth",
|
|
21
|
+
"expYear",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export type CardField = (typeof CARD_FIELDS)[number];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How each canonical field reads off the card object. Keyed by `CardField`, so
|
|
28
|
+
* adding a name to {@link CARD_FIELDS} without a reader fails to compile rather
|
|
29
|
+
* than at the one call that asks for it.
|
|
30
|
+
*/
|
|
31
|
+
const CARD_READERS: Record<CardField, CardReader> = {
|
|
32
|
+
number: (card) => text(card.number),
|
|
33
|
+
cvv: (card) => text(card.code),
|
|
34
|
+
cardholder: (card) => text(card.cardholderName),
|
|
35
|
+
brand: (card) => text(card.brand),
|
|
36
|
+
expiry: readExpiry,
|
|
37
|
+
expMonth: readExpMonth,
|
|
38
|
+
expYear: (card) => text(card.expYear),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Other spellings of the same fields: bw's own JSON keys, so a name copied out
|
|
43
|
+
* of `bwx get item` resolves, plus the words people reach for first.
|
|
44
|
+
*/
|
|
45
|
+
const CARD_ALIASES: Record<string, CardField> = {
|
|
46
|
+
code: "cvv",
|
|
47
|
+
cvc: "cvv",
|
|
48
|
+
securityCode: "cvv",
|
|
49
|
+
cardholderName: "cardholder",
|
|
50
|
+
holder: "cardholder",
|
|
51
|
+
exp: "expiry",
|
|
52
|
+
expiration: "expiry",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Card names are matched leniently — case, dashes, and underscores all collapse
|
|
57
|
+
* — because `expMonth`, `exp-month`, and `expmonth` are the same request, and
|
|
58
|
+
* failing one of them would only teach the caller to try the other two.
|
|
59
|
+
*/
|
|
60
|
+
function normalize(field: string): string {
|
|
61
|
+
return field.toLowerCase().replaceAll(/[-_\s]/g, "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every accepted spelling to its canonical field, derived so that the two
|
|
66
|
+
* tables above stay the only place a card field name is written down.
|
|
67
|
+
*/
|
|
68
|
+
const CANONICAL_BY_SPELLING: Record<string, CardField> = {
|
|
69
|
+
...Object.fromEntries(CARD_FIELDS.map((field) => [normalize(field), field])),
|
|
70
|
+
...Object.fromEntries(
|
|
71
|
+
Object.entries(CARD_ALIASES).map(([alias, field]) => [normalize(alias), field]),
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** True when `field` names a card field rather than a login field or a custom one. */
|
|
76
|
+
export function isCardField(field: string): boolean {
|
|
77
|
+
return normalize(field) in CANONICAL_BY_SPELLING;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Reads one card field, or `null` when the card carries no value for it. */
|
|
81
|
+
export function readCardValue(card: BwCard, field: string): string | null {
|
|
82
|
+
const canonical = CANONICAL_BY_SPELLING[normalize(field)];
|
|
83
|
+
return canonical ? CARD_READERS[canonical](card) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* bw stores the month as the digit it was entered as ("3"), which is not what a
|
|
88
|
+
* `MM` field or an expiry string wants. Padding here means every consumer gets
|
|
89
|
+
* the two-digit form rather than each one remembering to pad.
|
|
90
|
+
*/
|
|
91
|
+
function readExpMonth(card: BwCard): string | null {
|
|
92
|
+
return text(card.expMonth)?.padStart(2, "0") ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Composed rather than stored: Bitwarden keeps month and year apart, and a
|
|
97
|
+
* caller filling one expiry box should not have to join them itself. The year
|
|
98
|
+
* is passed through as stored, so a vault holding `30` yields `03/30`.
|
|
99
|
+
*/
|
|
100
|
+
function readExpiry(card: BwCard): string | null {
|
|
101
|
+
const month = readExpMonth(card);
|
|
102
|
+
const year = text(card.expYear);
|
|
103
|
+
// Half an expiry is worse than none — it would read as a complete date.
|
|
104
|
+
return month && year ? `${month}/${year}` : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Normalizes a stored value to a non-empty string. Months and years arrive as
|
|
109
|
+
* either strings or numbers depending on how the item was created, and an empty
|
|
110
|
+
* string means "not set" just as much as `null` does.
|
|
111
|
+
*/
|
|
112
|
+
function text(value: string | number | null | undefined): string | null {
|
|
113
|
+
if (value === null || value === undefined) return null;
|
|
114
|
+
const trimmed = String(value).trim();
|
|
115
|
+
return trimmed === "" ? null : trimmed;
|
|
116
|
+
}
|
package/src/bw/fields.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
2
|
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
3
3
|
import type { GlobalOptions } from "../cli/types.ts";
|
|
4
|
+
import { isCardField, readCardValue, CARD_FIELDS } from "./cards.ts";
|
|
4
5
|
import { summarizeItem, type ItemSummary } from "./items.ts";
|
|
5
6
|
import { runBwOrThrow } from "./runner.ts";
|
|
6
7
|
import { withSession } from "./session.ts";
|
|
@@ -12,10 +13,10 @@ import {
|
|
|
12
13
|
TotpUnsupportedError,
|
|
13
14
|
type TotpCode,
|
|
14
15
|
} from "./totp.ts";
|
|
15
|
-
import { BwItemSchema, type BwItem } from "./types.ts";
|
|
16
|
+
import { BwItemSchema, BwItemType, itemTypeLabel, type BwItem } from "./types.ts";
|
|
16
17
|
|
|
17
|
-
/** Fields `bw get` resolves natively; anything else is a custom field
|
|
18
|
-
const BUILTIN_FIELDS = [
|
|
18
|
+
/** Fields `bw get` resolves natively; anything else is a card or custom field. */
|
|
19
|
+
export const BUILTIN_FIELDS = [
|
|
19
20
|
"password",
|
|
20
21
|
"username",
|
|
21
22
|
"totp",
|
|
@@ -39,6 +40,14 @@ export interface ReadFieldOptions {
|
|
|
39
40
|
* field is called e.g. `password` is still reachable.
|
|
40
41
|
*/
|
|
41
42
|
customOnly?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Return the value exactly as stored rather than trimmed. Only `bwx get --raw`
|
|
45
|
+
* asks for this; every other caller compares or parses the value, where
|
|
46
|
+
* surrounding whitespace is framing rather than content. Custom and card
|
|
47
|
+
* fields are read out of the item JSON and are verbatim either way — this
|
|
48
|
+
* reaches the fields `bw get <field>` prints on stdout.
|
|
49
|
+
*/
|
|
50
|
+
verbatim?: boolean;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
/**
|
|
@@ -71,8 +80,16 @@ export async function readItemField(
|
|
|
71
80
|
return (await nextCode()).code;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
return
|
|
75
|
-
|
|
83
|
+
if (options?.customOnly) return readCustomField(field, item, opts);
|
|
84
|
+
|
|
85
|
+
// `bw get` has no card fields, so they are read from the item itself —
|
|
86
|
+
// which also settles whether this item is a card at all.
|
|
87
|
+
if (isCardField(field)) {
|
|
88
|
+
return extractField(await fetchItem(item, opts), field, options);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return isBuiltinField(field)
|
|
92
|
+
? readBuiltinField(field, item, opts, options?.verbatim)
|
|
76
93
|
: readCustomField(field, item, opts);
|
|
77
94
|
});
|
|
78
95
|
}
|
|
@@ -266,7 +283,19 @@ function extractField(
|
|
|
266
283
|
field: string,
|
|
267
284
|
options?: ReadFieldOptions,
|
|
268
285
|
): string {
|
|
269
|
-
if (
|
|
286
|
+
if (options?.customOnly) return pickCustomField(item, field);
|
|
287
|
+
|
|
288
|
+
// A card field only means the card object on a card item. On anything else
|
|
289
|
+
// `number` or `code` is just a custom field name, and stealing it here would
|
|
290
|
+
// break lookups that worked before cards were understood at all — so the
|
|
291
|
+
// name is resolved as one, and the miss says what the item actually is.
|
|
292
|
+
if (isCardField(field)) {
|
|
293
|
+
return item.type === BwItemType.Card
|
|
294
|
+
? extractCardField(item, field)
|
|
295
|
+
: pickCustomField(item, field, cardFieldHint(item, field));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!isBuiltinField(field)) {
|
|
270
299
|
return pickCustomField(item, field);
|
|
271
300
|
}
|
|
272
301
|
|
|
@@ -274,7 +303,7 @@ function extractField(
|
|
|
274
303
|
|
|
275
304
|
// Named exhaustively rather than defaulted: a chain ending in `password`
|
|
276
305
|
// would hand back the password for any field it failed to recognize.
|
|
277
|
-
let value: string | null;
|
|
306
|
+
let value: string | null | undefined;
|
|
278
307
|
switch (field) {
|
|
279
308
|
case "notes":
|
|
280
309
|
value = item.notes;
|
|
@@ -309,9 +338,10 @@ async function readBuiltinField(
|
|
|
309
338
|
field: string,
|
|
310
339
|
item: string,
|
|
311
340
|
opts: GlobalOptions,
|
|
341
|
+
verbatim = false,
|
|
312
342
|
): Promise<string> {
|
|
313
343
|
const result = await withSession(opts, () =>
|
|
314
|
-
runBwOrThrow(["get", field, item]),
|
|
344
|
+
runBwOrThrow(["get", field, item], { verbatim }),
|
|
315
345
|
);
|
|
316
346
|
|
|
317
347
|
if (!result) {
|
|
@@ -332,16 +362,35 @@ async function readCustomField(
|
|
|
332
362
|
return pickCustomField(await fetchItem(item, opts), fieldName);
|
|
333
363
|
}
|
|
334
364
|
|
|
335
|
-
function
|
|
365
|
+
function extractCardField(item: BwItem, field: string): string {
|
|
366
|
+
const value = item.card ? readCardValue(item.card, field) : null;
|
|
367
|
+
if (!value) {
|
|
368
|
+
throw new FieldMissingError(
|
|
369
|
+
`No ${field} found for item: ${item.name}`,
|
|
370
|
+
ExitCode.NotFound,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Why a card field found nothing on an item that is not a card. */
|
|
378
|
+
function cardFieldHint(item: BwItem, field: string): string {
|
|
379
|
+
return `Note: "${field}" reads a card field on card items, but "${item.name}" is a ${itemTypeLabel(item.type)} item. Card fields: ${CARD_FIELDS.join(", ")}.`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** `hint` is appended to a miss, for a name that could have meant something else. */
|
|
383
|
+
function pickCustomField(item: BwItem, fieldName: string, hint?: string): string {
|
|
336
384
|
const fields = item.fields ?? [];
|
|
337
385
|
const match = fields.find((f) => f.name === fieldName);
|
|
338
386
|
|
|
339
387
|
if (!match) {
|
|
340
388
|
const available = fields.map((f) => f.name).join(", ");
|
|
389
|
+
const miss = available
|
|
390
|
+
? `Field "${fieldName}" not found. Available: ${available}`
|
|
391
|
+
: `Field "${fieldName}" not found (item has no custom fields)`;
|
|
341
392
|
throw new FieldMissingError(
|
|
342
|
-
|
|
343
|
-
? `Field "${fieldName}" not found. Available: ${available}`
|
|
344
|
-
: `Field "${fieldName}" not found (item has no custom fields)`,
|
|
393
|
+
hint ? `${miss}\n\n${hint}` : miss,
|
|
345
394
|
ExitCode.NotFound,
|
|
346
395
|
);
|
|
347
396
|
}
|
package/src/bw/items.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { itemTypeLabel } from "./types.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The only shape of a vault item bwx prints when the caller did not ask for a
|
|
@@ -25,7 +25,7 @@ export function summarizeItem(item: Record<string, unknown>): ItemSummary {
|
|
|
25
25
|
|
|
26
26
|
return {
|
|
27
27
|
id: String(item.id ?? ""),
|
|
28
|
-
type:
|
|
28
|
+
type: itemTypeLabel(item.type as number),
|
|
29
29
|
name: String(item.name ?? ""),
|
|
30
30
|
username: typeof username === "string" && username ? username : null,
|
|
31
31
|
hasTotp: typeof totp === "string" && totp.length > 0,
|
package/src/bw/runner.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface RunBwOptions {
|
|
|
11
11
|
/** Explicit session, or `null` to run without one (login/unlock/status). */
|
|
12
12
|
session?: string | null;
|
|
13
13
|
env?: Record<string, string>;
|
|
14
|
+
/**
|
|
15
|
+
* Return stdout as `bw` wrote it, less the single newline `bw` adds to frame
|
|
16
|
+
* its own output. The default trim is right for values bwx parses or compares
|
|
17
|
+
* and wrong for a secret handed back byte-for-byte: whitespace around a
|
|
18
|
+
* password belongs to the password.
|
|
19
|
+
*/
|
|
20
|
+
verbatim?: boolean;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
/** Deadline for a single `bw` call; overridable with `--timeout`. */
|
|
@@ -66,7 +73,11 @@ export async function runBw(
|
|
|
66
73
|
);
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
stdout: options?.verbatim ? unframe(stdout) : stdout.trim(),
|
|
78
|
+
stderr: stderr.trim(),
|
|
79
|
+
exitCode,
|
|
80
|
+
};
|
|
70
81
|
} finally {
|
|
71
82
|
guard?.disarm();
|
|
72
83
|
}
|
|
@@ -87,6 +98,11 @@ export async function runBwOrThrow(
|
|
|
87
98
|
return result.stdout;
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
/** Drops the one newline `bw` prints after a value, and nothing the value owns. */
|
|
102
|
+
function unframe(stdout: string): string {
|
|
103
|
+
return stdout.replace(/\r?\n$/, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
/**
|
|
91
107
|
* Child environment for `bw`. `BW_SESSION` is set from our session, or removed
|
|
92
108
|
* outright so an ambient one from the caller's shell cannot silently substitute
|
package/src/bw/types.ts
CHANGED
|
@@ -30,6 +30,11 @@ export const BW_TYPE_LABELS: Record<number, string> = {
|
|
|
30
30
|
4: "identity",
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/** The label for an item type, naming the raw number for one bwx predates. */
|
|
34
|
+
export function itemTypeLabel(type: number): string {
|
|
35
|
+
return BW_TYPE_LABELS[type] ?? `type:${type}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
export const BW_TYPE_FROM_NAME: Record<string, BwItemType> = {
|
|
34
39
|
login: BwItemType.Login,
|
|
35
40
|
logins: BwItemType.Login,
|
|
@@ -73,17 +78,43 @@ export interface BwLogin {
|
|
|
73
78
|
uris: BwUri[] | null;
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
// --- Card ---
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Values arrive as strings from `bw`, but `expMonth`/`expYear` are numbers in
|
|
85
|
+
* some hand-written and imported items, so both are accepted rather than
|
|
86
|
+
* failing the whole item parse over a field the caller may not even want. Every
|
|
87
|
+
* key is optional for the same reason — see the schema below.
|
|
88
|
+
*/
|
|
89
|
+
export interface BwCard {
|
|
90
|
+
cardholderName?: string | null;
|
|
91
|
+
brand?: string | null;
|
|
92
|
+
number?: string | null;
|
|
93
|
+
expMonth?: string | number | null;
|
|
94
|
+
expYear?: string | number | null;
|
|
95
|
+
code?: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
// --- Item (loose schema — passthrough for edit round-trip) ---
|
|
77
99
|
|
|
100
|
+
/**
|
|
101
|
+
* `bw get item` omits keys it has no value for rather than nulling them — an
|
|
102
|
+
* unfiled card has no `folderId`, a login without 2FA no `totp` — so every key
|
|
103
|
+
* but an item's identity is optional as well as nullable. Requiring them made
|
|
104
|
+
* an absent key fail the parse and took down reads of the fields that were
|
|
105
|
+
* present.
|
|
106
|
+
*/
|
|
107
|
+
const OmittableTextSchema = z.string().nullable().optional();
|
|
108
|
+
|
|
78
109
|
export const BwItemSchema = z
|
|
79
110
|
.object({
|
|
80
111
|
id: z.string(),
|
|
81
|
-
organizationId:
|
|
82
|
-
folderId:
|
|
112
|
+
organizationId: OmittableTextSchema,
|
|
113
|
+
folderId: OmittableTextSchema,
|
|
83
114
|
type: z.number(),
|
|
84
115
|
name: z.string(),
|
|
85
|
-
notes:
|
|
86
|
-
favorite: z.boolean(),
|
|
116
|
+
notes: OmittableTextSchema,
|
|
117
|
+
favorite: z.boolean().optional(),
|
|
87
118
|
fields: z
|
|
88
119
|
.array(
|
|
89
120
|
z.object({
|
|
@@ -92,12 +123,13 @@ export const BwItemSchema = z
|
|
|
92
123
|
type: z.number(),
|
|
93
124
|
}),
|
|
94
125
|
)
|
|
95
|
-
.nullable()
|
|
126
|
+
.nullable()
|
|
127
|
+
.optional(),
|
|
96
128
|
login: z
|
|
97
129
|
.object({
|
|
98
|
-
username:
|
|
99
|
-
password:
|
|
100
|
-
totp:
|
|
130
|
+
username: OmittableTextSchema,
|
|
131
|
+
password: OmittableTextSchema,
|
|
132
|
+
totp: OmittableTextSchema,
|
|
101
133
|
uris: z
|
|
102
134
|
.array(
|
|
103
135
|
z.object({
|
|
@@ -105,8 +137,23 @@ export const BwItemSchema = z
|
|
|
105
137
|
uri: z.string(),
|
|
106
138
|
}),
|
|
107
139
|
)
|
|
108
|
-
.nullable()
|
|
140
|
+
.nullable()
|
|
141
|
+
.optional(),
|
|
142
|
+
})
|
|
143
|
+
.passthrough()
|
|
144
|
+
.nullable()
|
|
145
|
+
.optional(),
|
|
146
|
+
card: z
|
|
147
|
+
.object({
|
|
148
|
+
cardholderName: OmittableTextSchema,
|
|
149
|
+
brand: OmittableTextSchema,
|
|
150
|
+
number: OmittableTextSchema,
|
|
151
|
+
// Numeric in items written by hand or by an importer.
|
|
152
|
+
expMonth: z.union([z.string(), z.number()]).nullable().optional(),
|
|
153
|
+
expYear: z.union([z.string(), z.number()]).nullable().optional(),
|
|
154
|
+
code: OmittableTextSchema,
|
|
109
155
|
})
|
|
156
|
+
.passthrough()
|
|
110
157
|
.nullable()
|
|
111
158
|
.optional(),
|
|
112
159
|
reprompt: z.number().optional(),
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -1,31 +1,47 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
|
-
import { emitData, emitLog } from "../io.ts";
|
|
4
|
-
import { readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
|
|
3
|
+
import { emitData, emitLog, emitRaw } from "../io.ts";
|
|
4
|
+
import { BUILTIN_FIELDS, readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
|
|
5
|
+
import { CARD_FIELDS } from "../../bw/cards.ts";
|
|
5
6
|
import { CliError, ExitCode } from "../errors.ts";
|
|
6
7
|
import type { GlobalOptions } from "../types.ts";
|
|
7
8
|
|
|
8
9
|
/** Seconds left below which `--fresh` waits, when the flag is given bare. */
|
|
9
10
|
const DEFAULT_FRESH_SECONDS = 5;
|
|
10
11
|
|
|
12
|
+
/** Written once so `get` and `field` describe `--raw` in the same words. */
|
|
13
|
+
const RAW_DESCRIPTION =
|
|
14
|
+
"Suppress only the trailing newline bwx adds; newlines stored in the value are kept";
|
|
15
|
+
|
|
16
|
+
interface GetOptions {
|
|
17
|
+
fresh?: string | boolean;
|
|
18
|
+
seed?: boolean;
|
|
19
|
+
raw?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
export function registerGet(program: Command): void {
|
|
12
23
|
program
|
|
13
24
|
.command("get")
|
|
14
25
|
.description("Get a field from a vault item (built-in or custom)")
|
|
15
|
-
.argument(
|
|
26
|
+
.argument(
|
|
27
|
+
"<field>",
|
|
28
|
+
`Field: ${BUILTIN_FIELDS.join(" | ")} | <custom>. Card items also take: ${CARD_FIELDS.join(" | ")}`,
|
|
29
|
+
)
|
|
16
30
|
.argument("<item>", "Item name or ID")
|
|
17
31
|
.option(
|
|
18
32
|
"--fresh [seconds]",
|
|
19
33
|
`Wait for the next TOTP window if fewer than N seconds remain (default ${DEFAULT_FRESH_SECONDS})`,
|
|
20
34
|
)
|
|
21
35
|
.option("--seed", "Emit the stored TOTP secret instead of a code")
|
|
36
|
+
.option("--raw", RAW_DESCRIPTION)
|
|
22
37
|
.action(async function (
|
|
23
38
|
this: Command,
|
|
24
39
|
field: string,
|
|
25
40
|
item: string,
|
|
26
|
-
localOpts:
|
|
41
|
+
localOpts: GetOptions,
|
|
27
42
|
) {
|
|
28
43
|
const opts = getGlobalOpts(this);
|
|
44
|
+
assertRawUsable(localOpts, opts);
|
|
29
45
|
|
|
30
46
|
if (field === "totp") {
|
|
31
47
|
await emitTotp(item, localOpts, opts);
|
|
@@ -34,9 +50,18 @@ export function registerGet(program: Command): void {
|
|
|
34
50
|
|
|
35
51
|
assertTotpOnlyFlags(field, localOpts);
|
|
36
52
|
|
|
37
|
-
const value = await readItemField(field, item, opts
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
const value = await readItemField(field, item, opts, {
|
|
54
|
+
verbatim: localOpts.raw,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// `item` is the one field that is itself JSON, so it is emitted as
|
|
58
|
+
// structure — except under `--raw`, which promised the bytes `bw` gave us.
|
|
59
|
+
if (field === "item" && !localOpts.raw) {
|
|
60
|
+
emitData(JSON.parse(value), opts);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
emitValue(value, localOpts, opts);
|
|
40
65
|
});
|
|
41
66
|
|
|
42
67
|
program
|
|
@@ -46,19 +71,57 @@ export function registerGet(program: Command): void {
|
|
|
46
71
|
// alongside it — the two used to take their arguments back to front.
|
|
47
72
|
.argument("<name>", "Custom field name")
|
|
48
73
|
.argument("<item>", "Item name or ID")
|
|
49
|
-
.
|
|
74
|
+
.option("--raw", RAW_DESCRIPTION)
|
|
75
|
+
.action(async function (
|
|
76
|
+
this: Command,
|
|
77
|
+
name: string,
|
|
78
|
+
item: string,
|
|
79
|
+
localOpts: { raw?: boolean },
|
|
80
|
+
) {
|
|
50
81
|
const opts = getGlobalOpts(this);
|
|
82
|
+
assertRawUsable(localOpts, opts);
|
|
83
|
+
|
|
51
84
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
opts,
|
|
55
|
-
);
|
|
85
|
+
const value = await readItemField(name, item, opts, { customOnly: true });
|
|
86
|
+
emitValue(value, localOpts, opts);
|
|
56
87
|
} catch (err) {
|
|
57
88
|
throw withOrderHint(err);
|
|
58
89
|
}
|
|
59
90
|
});
|
|
60
91
|
}
|
|
61
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Emits one field value. `--raw` writes it alone, so piping a secret into a
|
|
95
|
+
* consumer that reads stdin literally does not append bwx's newline to it;
|
|
96
|
+
* `meta` describes the JSON envelope and so never applies on that path.
|
|
97
|
+
*/
|
|
98
|
+
function emitValue(
|
|
99
|
+
value: string,
|
|
100
|
+
localOpts: { raw?: boolean },
|
|
101
|
+
opts: GlobalOptions,
|
|
102
|
+
meta?: Record<string, unknown>,
|
|
103
|
+
): void {
|
|
104
|
+
if (localOpts.raw) {
|
|
105
|
+
emitRaw(value);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
emitData(value, opts, meta);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `--json` is an envelope — the value arrives quoted, escaped, and wrapped in
|
|
113
|
+
* `{ "data": … }` — so its trailing newline is the least of what `--raw` would
|
|
114
|
+
* have to undo. Saying so beats silently letting one win.
|
|
115
|
+
*/
|
|
116
|
+
function assertRawUsable(localOpts: { raw?: boolean }, opts: GlobalOptions): void {
|
|
117
|
+
if (localOpts.raw && opts.json) {
|
|
118
|
+
throw new CliError(
|
|
119
|
+
"--raw and --json cannot be combined: --json wraps the value in an envelope. Use one or the other.",
|
|
120
|
+
ExitCode.BadArgs,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
62
125
|
/**
|
|
63
126
|
* Emits a TOTP alongside how long it stays valid. The remainder rides in the
|
|
64
127
|
* `--json` envelope's `meta` rather than replacing `data`, so `data` is still
|
|
@@ -66,7 +129,7 @@ export function registerGet(program: Command): void {
|
|
|
66
129
|
*/
|
|
67
130
|
async function emitTotp(
|
|
68
131
|
item: string,
|
|
69
|
-
localOpts:
|
|
132
|
+
localOpts: GetOptions,
|
|
70
133
|
opts: GlobalOptions,
|
|
71
134
|
): Promise<void> {
|
|
72
135
|
if (localOpts.seed) {
|
|
@@ -76,7 +139,7 @@ async function emitTotp(
|
|
|
76
139
|
ExitCode.BadArgs,
|
|
77
140
|
);
|
|
78
141
|
}
|
|
79
|
-
|
|
142
|
+
emitValue(await readTotpSecret(item, opts), localOpts, opts);
|
|
80
143
|
return;
|
|
81
144
|
}
|
|
82
145
|
|
|
@@ -87,7 +150,7 @@ async function emitTotp(
|
|
|
87
150
|
});
|
|
88
151
|
|
|
89
152
|
emitLog(`Valid for ${totp.secondsRemaining}s`, opts);
|
|
90
|
-
|
|
153
|
+
emitValue(totp.code, localOpts, opts, {
|
|
91
154
|
secondsRemaining: totp.secondsRemaining,
|
|
92
155
|
period: totp.period,
|
|
93
156
|
});
|
|
@@ -115,10 +178,7 @@ function parseFreshSeconds(raw: string | boolean | undefined): number | undefine
|
|
|
115
178
|
* Accepting `--fresh` on a password would read as "give me a fresh password",
|
|
116
179
|
* which is not what it does. Rejecting it beats quietly ignoring it.
|
|
117
180
|
*/
|
|
118
|
-
function assertTotpOnlyFlags(
|
|
119
|
-
field: string,
|
|
120
|
-
localOpts: { fresh?: string | boolean; seed?: boolean },
|
|
121
|
-
): void {
|
|
181
|
+
function assertTotpOnlyFlags(field: string, localOpts: GetOptions): void {
|
|
122
182
|
const used = [
|
|
123
183
|
localOpts.fresh !== undefined ? "--fresh" : null,
|
|
124
184
|
localOpts.seed ? "--seed" : null,
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -70,7 +70,8 @@ export function parseEnvSpecs(raws: string[]): EnvSpec[] {
|
|
|
70
70
|
/**
|
|
71
71
|
* Splits `NAME=<field>:<item>` on the first `=` and then the first `:`, so item
|
|
72
72
|
* names may contain colons (`password:http://my.router`) while field names may not.
|
|
73
|
-
* Fields resolve exactly as in `bwx get`: built-ins first,
|
|
73
|
+
* Fields resolve exactly as in `bwx get`: built-ins first, then card fields on a card
|
|
74
|
+
* item, otherwise custom fields.
|
|
74
75
|
*/
|
|
75
76
|
export function parseEnvSpec(raw: string): EnvSpec {
|
|
76
77
|
const eq = raw.indexOf("=");
|
package/src/cli/io.ts
CHANGED
|
@@ -34,6 +34,16 @@ export function emitData(
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Writes a value and nothing else — no newline, no formatting. {@link emitData}'s
|
|
39
|
+
* newline is a courtesy to terminals, but a consumer that reads stdin literally
|
|
40
|
+
* takes it as the last byte of the secret. Newlines *inside* the value are the
|
|
41
|
+
* value's own and are left alone.
|
|
42
|
+
*/
|
|
43
|
+
export function emitRaw(value: string): void {
|
|
44
|
+
process.stdout.write(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
export function emitLog(message: string, opts: GlobalOptions): void {
|
|
38
48
|
if (opts.quiet || opts.json) return;
|
|
39
49
|
process.stderr.write(message + "\n");
|