bwx 0.6.1 → 0.7.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 +40 -0
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +25 -2
- package/src/bw/cards.ts +116 -0
- package/src/bw/fields.ts +114 -33
- package/src/bw/items.ts +2 -2
- package/src/bw/session.ts +4 -2
- package/src/bw/totp.ts +6 -0
- package/src/bw/types.ts +56 -9
- package/src/cli/commands/create.ts +7 -23
- package/src/cli/commands/edit.ts +10 -29
- package/src/cli/commands/get.ts +13 -13
- package/src/cli/commands/run.ts +2 -1
- package/src/cli/input.ts +198 -26
- package/src/config/process-lock.ts +42 -10
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
|
|
|
71
71
|
|---------|-------------|
|
|
72
72
|
| `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
|
|
73
73
|
| `bwx get totp <item>` | TOTP code (`--fresh [n]`, `--seed`) |
|
|
74
|
+
| `bwx get <card field> <item>` | Card field (`number`, `cvv`, `cardholder`, `brand`, `expiry`, …) |
|
|
74
75
|
| `bwx field <name> <item>` | Get custom field by name (same argument order as `get`) |
|
|
75
76
|
| `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
76
77
|
| `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
@@ -139,6 +140,45 @@ Spaces, hyphens, and `=` padding are stripped and case is normalized; an `otpaut
|
|
|
139
140
|
is stored verbatim so its parameters survive. Malformed secrets are rejected before the
|
|
140
141
|
write, because a bad seed is otherwise discovered at the login it was supposed to unlock.
|
|
141
142
|
|
|
143
|
+
### Cards
|
|
144
|
+
|
|
145
|
+
Card items keep their values in a `card` object that `bw get <field>` cannot reach, so
|
|
146
|
+
bwx reads them from the item itself — one vault read, no extra `bw` spawn:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
bwx get number "Visa" # 4111111111111111
|
|
150
|
+
bwx get cvv "Visa" # 123
|
|
151
|
+
bwx get cardholder "Visa" # Vladislav Forsh
|
|
152
|
+
bwx get brand "Visa" # Visa
|
|
153
|
+
bwx get expiry "Visa" # 03/2030
|
|
154
|
+
bwx get expMonth "Visa" # 03
|
|
155
|
+
bwx get expYear "Visa" # 2030
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Names match leniently on case, dashes, and underscores, and bw's own JSON keys work too,
|
|
159
|
+
so a name copied out of `bwx get item` resolves (`code` → `cvv`, `cardholderName` →
|
|
160
|
+
`cardholder`, `exp-month` → `expMonth`).
|
|
161
|
+
|
|
162
|
+
Two values are normalized rather than passed through, because the stored form is not the
|
|
163
|
+
usable one:
|
|
164
|
+
|
|
165
|
+
- `expMonth` is zero-padded — bw stores the month as typed (`3`), and every `MM` field
|
|
166
|
+
wants `03`.
|
|
167
|
+
- `expiry` is composed from month and year, and is withheld entirely if either is missing
|
|
168
|
+
rather than handing back half a date.
|
|
169
|
+
|
|
170
|
+
A card field only means the card object **on a card item**. `number`, `code`, and `brand`
|
|
171
|
+
are plausible custom field names, so on any other item type they resolve as custom fields
|
|
172
|
+
exactly as before — and if no such custom field exists, the error says which kind of item
|
|
173
|
+
it actually is. `bwx field` is unchanged: custom fields only, never a card field.
|
|
174
|
+
|
|
175
|
+
Grouping several card fields into one read is worth it — see [Secrets into a child
|
|
176
|
+
process](#secrets-into-a-child-process):
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
bwx run --env NUM=number:Visa --env CVV=cvv:Visa --env EXP=expiry:Visa -- ./pay.sh
|
|
180
|
+
```
|
|
181
|
+
|
|
142
182
|
### Listings are capped
|
|
143
183
|
|
|
144
184
|
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,6 +17,7 @@ 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
|
|
22
23
|
|
|
@@ -45,6 +46,7 @@ echo "secret" | bwx create --name "Piped Note"
|
|
|
45
46
|
### `bwx get <field> <item>`
|
|
46
47
|
|
|
47
48
|
Built-in fields: `password | username | totp | notes | uri | item`.
|
|
49
|
+
Card items additionally take `number | cvv | cardholder | brand | expiry | expMonth | expYear`.
|
|
48
50
|
Anything else is looked up as a custom field. Lists available fields on miss.
|
|
49
51
|
|
|
50
52
|
A miss suggests near matches; an ambiguous name lists candidates with IDs. Read the
|
|
@@ -76,6 +78,27 @@ exactly are delegated to `bw get totp` instead of guessed at.
|
|
|
76
78
|
Custom fields only, so an item whose custom field is named `password` is still reachable.
|
|
77
79
|
Takes its arguments in the same order as `get` (this changed in 0.5.0).
|
|
78
80
|
|
|
81
|
+
### Card fields
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bwx get number "Visa" # 4111111111111111
|
|
85
|
+
bwx get cvv "Visa" # 123 (bw's own key `code` works too)
|
|
86
|
+
bwx get cardholder "Visa" # Vladislav Forsh
|
|
87
|
+
bwx get brand "Visa" # Visa
|
|
88
|
+
bwx get expiry "Visa" # 03/2030 (composed; withheld if either half is missing)
|
|
89
|
+
bwx get expMonth "Visa" # 03 (zero-padded; bw stores "3")
|
|
90
|
+
bwx get expYear "Visa" # 2030
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Read from the fetched item — `bw` has no card fields — so they cost one vault read and
|
|
94
|
+
batch with each other under `bwx run`. Names match leniently on case, dashes, and
|
|
95
|
+
underscores, and bw's JSON keys resolve (`code`, `cardholderName`, `exp-month`).
|
|
96
|
+
|
|
97
|
+
A card field means the card object **only on a card item**. On any other type it resolves
|
|
98
|
+
as a custom field of that name, since `number` and `code` are ordinary field names; the
|
|
99
|
+
error names the item's actual type when no such field exists. `bwx field` never returns a
|
|
100
|
+
card field.
|
|
101
|
+
|
|
79
102
|
### `bwx search <query>` / `bwx list [type]`
|
|
80
103
|
|
|
81
104
|
Flags: `--type login|note|card|identity`, `--folder <name|id>`, `--limit <n>`, `--full-items`
|
|
@@ -108,7 +131,7 @@ bwx run --env DB_PASS=password:'Prod DB' -- ./migrate.sh
|
|
|
108
131
|
bwx run --env TOKEN='API Key:Acme' --env USER=username:Acme -- ./deploy.sh
|
|
109
132
|
```
|
|
110
133
|
|
|
111
|
-
- `--env` is repeatable; the field resolves as in `bwx get` (built-in, else custom field).
|
|
134
|
+
- `--env` is repeatable; the field resolves as in `bwx get` (built-in, else card, else custom field).
|
|
112
135
|
- Only the first `:` splits field from item, so `password:http://my.router` works.
|
|
113
136
|
- Put `--` before the command when it takes its own flags.
|
|
114
137
|
- 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",
|
|
@@ -67,27 +68,49 @@ export async function readItemField(
|
|
|
67
68
|
): Promise<string> {
|
|
68
69
|
return withLookupHelp(item, opts, async () => {
|
|
69
70
|
if (isTotpField(field, options)) {
|
|
70
|
-
|
|
71
|
+
const nextCode = resolveTotpSource(await fetchItem(item, opts), item, opts);
|
|
72
|
+
return (await nextCode()).code;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
return
|
|
75
|
+
if (options?.customOnly) return readCustomField(field, item, opts);
|
|
76
|
+
|
|
77
|
+
// `bw get` has no card fields, so they are read from the item itself —
|
|
78
|
+
// which also settles whether this item is a card at all.
|
|
79
|
+
if (isCardField(field)) {
|
|
80
|
+
return extractField(await fetchItem(item, opts), field, options);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return isBuiltinField(field)
|
|
74
84
|
? readBuiltinField(field, item, opts)
|
|
75
85
|
: readCustomField(field, item, opts);
|
|
76
86
|
});
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
export interface ReadTotpOptions {
|
|
90
|
+
/**
|
|
91
|
+
* Wait for the next window when the code has fewer than this many seconds
|
|
92
|
+
* left, rather than handing back one that expires on the way to the prompt.
|
|
93
|
+
*/
|
|
94
|
+
minSecondsRemaining?: number;
|
|
95
|
+
/** Notified with the seconds about to be spent waiting, for progress output. */
|
|
96
|
+
onWait?: (seconds: number) => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
/**
|
|
80
100
|
* Reads a TOTP with the time it has left. A code handed over with three seconds
|
|
81
|
-
* on it is usually dead before it is pasted, so callers
|
|
82
|
-
* decide
|
|
101
|
+
* on it is usually dead before it is pasted, so callers get the remainder to
|
|
102
|
+
* decide for themselves — or set `minSecondsRemaining` and have the wait handled
|
|
103
|
+
* here, where the secret is still in hand and a fresh code is free.
|
|
83
104
|
*/
|
|
84
105
|
export async function readTotp(
|
|
85
106
|
item: string,
|
|
86
107
|
opts: GlobalOptions,
|
|
108
|
+
options?: ReadTotpOptions,
|
|
87
109
|
): Promise<TotpCode> {
|
|
88
|
-
return withLookupHelp(item, opts, async () =>
|
|
89
|
-
|
|
90
|
-
|
|
110
|
+
return withLookupHelp(item, opts, async () => {
|
|
111
|
+
const nextCode = resolveTotpSource(await fetchItem(item, opts), item, opts);
|
|
112
|
+
return awaitUsableWindow(nextCode, options);
|
|
113
|
+
});
|
|
91
114
|
}
|
|
92
115
|
|
|
93
116
|
/** The stored secret itself, for moving a seed back out to another authenticator. */
|
|
@@ -113,29 +136,58 @@ function requireStoredTotp(item: BwItem): string {
|
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* emitting a confidently wrong six digits.
|
|
139
|
+
* Produces a code for the current window. Callable rather than a single resolved
|
|
140
|
+
* value so waiting out a nearly-dead window is cheap: the local path just runs
|
|
141
|
+
* the HMAC again over the secret it captured.
|
|
120
142
|
*/
|
|
121
|
-
|
|
143
|
+
type TotpSource = () => Promise<TotpCode>;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Decides how this item's codes are computed: locally from the secret already in
|
|
147
|
+
* hand, or by `bw get totp` for the shapes this codebase will not compute itself
|
|
148
|
+
* (see `totp.ts`). The fallback costs a spawn per code, which is the correct
|
|
149
|
+
* trade against emitting a confidently wrong six digits.
|
|
150
|
+
*/
|
|
151
|
+
function resolveTotpSource(
|
|
122
152
|
fetched: BwItem,
|
|
123
153
|
item: string,
|
|
124
154
|
opts: GlobalOptions,
|
|
125
|
-
):
|
|
155
|
+
): TotpSource {
|
|
126
156
|
const stored = requireStoredTotp(fetched);
|
|
127
157
|
|
|
128
158
|
try {
|
|
129
|
-
|
|
159
|
+
const config = parseTotpSecret(stored);
|
|
160
|
+
return async () => generateTotp(config);
|
|
130
161
|
} catch (err) {
|
|
131
162
|
if (!(err instanceof TotpUnsupportedError)) throw err;
|
|
132
|
-
|
|
133
|
-
return {
|
|
134
|
-
code: await readBuiltinField("totp", item, opts),
|
|
135
|
-
period: DEFAULT_PERIOD,
|
|
136
|
-
secondsRemaining: secondsRemaining(DEFAULT_PERIOD),
|
|
137
|
-
};
|
|
138
163
|
}
|
|
164
|
+
|
|
165
|
+
return async () => ({
|
|
166
|
+
code: await readBuiltinField("totp", item, opts),
|
|
167
|
+
period: DEFAULT_PERIOD,
|
|
168
|
+
secondsRemaining: secondsRemaining(DEFAULT_PERIOD),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Sleeps out a window with too little left, then asks the source for the next
|
|
174
|
+
* code. Only the `bw` fallback pays a second spawn for that; the local path
|
|
175
|
+
* recomputes from the secret it holds, so the returned code has a full window
|
|
176
|
+
* rather than one already spent on a second vault round-trip.
|
|
177
|
+
*/
|
|
178
|
+
async function awaitUsableWindow(
|
|
179
|
+
nextCode: TotpSource,
|
|
180
|
+
options?: ReadTotpOptions,
|
|
181
|
+
): Promise<TotpCode> {
|
|
182
|
+
const code = await nextCode();
|
|
183
|
+
|
|
184
|
+
const minimum = options?.minSecondsRemaining;
|
|
185
|
+
if (minimum === undefined || code.secondsRemaining >= minimum) return code;
|
|
186
|
+
|
|
187
|
+
options?.onWait?.(code.secondsRemaining);
|
|
188
|
+
// The extra beat lands just past the boundary rather than exactly on it.
|
|
189
|
+
await Bun.sleep(code.secondsRemaining * 1000 + 250);
|
|
190
|
+
return nextCode();
|
|
139
191
|
}
|
|
140
192
|
|
|
141
193
|
/**
|
|
@@ -169,10 +221,8 @@ export async function readFields(
|
|
|
169
221
|
const request = requests[index]!;
|
|
170
222
|
|
|
171
223
|
if (isTotpField(request.field, request.options)) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
);
|
|
175
|
-
values[index] = totp.code;
|
|
224
|
+
const nextCode = resolveTotpSource(fetched, item, opts);
|
|
225
|
+
values[index] = (await withLookupHelp(item, opts, nextCode)).code;
|
|
176
226
|
} else {
|
|
177
227
|
values[index] = extractField(fetched, request.field, request.options);
|
|
178
228
|
}
|
|
@@ -225,7 +275,19 @@ function extractField(
|
|
|
225
275
|
field: string,
|
|
226
276
|
options?: ReadFieldOptions,
|
|
227
277
|
): string {
|
|
228
|
-
if (
|
|
278
|
+
if (options?.customOnly) return pickCustomField(item, field);
|
|
279
|
+
|
|
280
|
+
// A card field only means the card object on a card item. On anything else
|
|
281
|
+
// `number` or `code` is just a custom field name, and stealing it here would
|
|
282
|
+
// break lookups that worked before cards were understood at all — so the
|
|
283
|
+
// name is resolved as one, and the miss says what the item actually is.
|
|
284
|
+
if (isCardField(field)) {
|
|
285
|
+
return item.type === BwItemType.Card
|
|
286
|
+
? extractCardField(item, field)
|
|
287
|
+
: pickCustomField(item, field, cardFieldHint(item, field));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!isBuiltinField(field)) {
|
|
229
291
|
return pickCustomField(item, field);
|
|
230
292
|
}
|
|
231
293
|
|
|
@@ -233,7 +295,7 @@ function extractField(
|
|
|
233
295
|
|
|
234
296
|
// Named exhaustively rather than defaulted: a chain ending in `password`
|
|
235
297
|
// would hand back the password for any field it failed to recognize.
|
|
236
|
-
let value: string | null;
|
|
298
|
+
let value: string | null | undefined;
|
|
237
299
|
switch (field) {
|
|
238
300
|
case "notes":
|
|
239
301
|
value = item.notes;
|
|
@@ -291,16 +353,35 @@ async function readCustomField(
|
|
|
291
353
|
return pickCustomField(await fetchItem(item, opts), fieldName);
|
|
292
354
|
}
|
|
293
355
|
|
|
294
|
-
function
|
|
356
|
+
function extractCardField(item: BwItem, field: string): string {
|
|
357
|
+
const value = item.card ? readCardValue(item.card, field) : null;
|
|
358
|
+
if (!value) {
|
|
359
|
+
throw new FieldMissingError(
|
|
360
|
+
`No ${field} found for item: ${item.name}`,
|
|
361
|
+
ExitCode.NotFound,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Why a card field found nothing on an item that is not a card. */
|
|
369
|
+
function cardFieldHint(item: BwItem, field: string): string {
|
|
370
|
+
return `Note: "${field}" reads a card field on card items, but "${item.name}" is a ${itemTypeLabel(item.type)} item. Card fields: ${CARD_FIELDS.join(", ")}.`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** `hint` is appended to a miss, for a name that could have meant something else. */
|
|
374
|
+
function pickCustomField(item: BwItem, fieldName: string, hint?: string): string {
|
|
295
375
|
const fields = item.fields ?? [];
|
|
296
376
|
const match = fields.find((f) => f.name === fieldName);
|
|
297
377
|
|
|
298
378
|
if (!match) {
|
|
299
379
|
const available = fields.map((f) => f.name).join(", ");
|
|
380
|
+
const miss = available
|
|
381
|
+
? `Field "${fieldName}" not found. Available: ${available}`
|
|
382
|
+
: `Field "${fieldName}" not found (item has no custom fields)`;
|
|
300
383
|
throw new FieldMissingError(
|
|
301
|
-
|
|
302
|
-
? `Field "${fieldName}" not found. Available: ${available}`
|
|
303
|
-
: `Field "${fieldName}" not found (item has no custom fields)`,
|
|
384
|
+
hint ? `${miss}\n\n${hint}` : miss,
|
|
304
385
|
ExitCode.NotFound,
|
|
305
386
|
);
|
|
306
387
|
}
|
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/session.ts
CHANGED
|
@@ -119,9 +119,11 @@ export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
|
|
|
119
119
|
},
|
|
120
120
|
async () => {
|
|
121
121
|
// A process that waited must use the winner's disk cache, not the stale
|
|
122
|
-
// token it may still have held in memory before entering the lock.
|
|
122
|
+
// token it may still have held in memory before entering the lock. An
|
|
123
|
+
// unchanged cache needs no second `bw status`: we just watched that exact
|
|
124
|
+
// token be refused, and nothing has replaced it.
|
|
123
125
|
const winner = loadCachedSession(opts);
|
|
124
|
-
if (winner) {
|
|
126
|
+
if (winner && winner !== cached) {
|
|
125
127
|
setSession(winner);
|
|
126
128
|
if (await sessionWorks(winner)) return;
|
|
127
129
|
setSession(null);
|
package/src/bw/totp.ts
CHANGED
|
@@ -81,6 +81,12 @@ export function parseTotpSecret(raw: string): TotpConfig {
|
|
|
81
81
|
throw new TotpUnsupportedError("secret is not base32");
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// A returned config must be computable, so that generateTotp cannot fail on
|
|
85
|
+
// one and strand a caller that has already chosen the local path over the bw
|
|
86
|
+
// fallback. Base32 alone does not promise that: a single character carries
|
|
87
|
+
// five bits and decodes to no whole byte.
|
|
88
|
+
base32Decode(config.secret);
|
|
89
|
+
|
|
84
90
|
return config;
|
|
85
91
|
}
|
|
86
92
|
|
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(),
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
assertSingleStdinSource,
|
|
12
12
|
collect,
|
|
13
13
|
listExplicitStdinInputs,
|
|
14
|
+
loginOnlyTextInputFlags,
|
|
14
15
|
readOptionalStdin,
|
|
16
|
+
registerItemTextOptions,
|
|
15
17
|
resolveFieldSources,
|
|
16
18
|
resolveItemTextInputs,
|
|
17
19
|
StdinReader,
|
|
@@ -36,36 +38,18 @@ interface CreateOptions extends ItemTextInputs {
|
|
|
36
38
|
/** Flags a secure note has nowhere to store — see {@link assertLoginFlags}. */
|
|
37
39
|
const LOGIN_ONLY_FLAGS: Array<[keyof CreateOptions, string]> = [
|
|
38
40
|
["username", "--username"],
|
|
39
|
-
|
|
40
|
-
["passwordStdin", "--password-stdin"],
|
|
41
|
-
["passwordFile", "--password-file"],
|
|
42
|
-
["passwordEnv", "--password-env"],
|
|
43
|
-
["passwordGenerate", "--password-generate"],
|
|
44
|
-
["totp", "--totp"],
|
|
45
|
-
["totpStdin", "--totp-stdin"],
|
|
46
|
-
["totpFile", "--totp-file"],
|
|
47
|
-
["totpEnv", "--totp-env"],
|
|
41
|
+
...loginOnlyTextInputFlags(),
|
|
48
42
|
];
|
|
49
43
|
|
|
50
44
|
export function registerCreate(program: Command): void {
|
|
51
|
-
program
|
|
45
|
+
const command = program
|
|
52
46
|
.command("create").alias("add")
|
|
53
47
|
.description("Create a new vault item")
|
|
54
48
|
.option("--type <type>", "Item type: login|note", "note")
|
|
55
49
|
.option("--name <name>", "Item name")
|
|
56
|
-
.option("--
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
.option("--password <pass>", "Password (login type)")
|
|
60
|
-
.option("--password-stdin", "Read password from stdin")
|
|
61
|
-
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
62
|
-
.option("--password-env <name>", "Read password from environment variable")
|
|
63
|
-
.option("--password-generate", "Generate the password; it is never printed")
|
|
64
|
-
.option("--generate-length <n>", "Length for --password-generate", parseInt)
|
|
65
|
-
.option("--totp <secret>", "TOTP secret: base32 or otpauth:// URI (login type)")
|
|
66
|
-
.option("--totp-stdin", "Read the TOTP secret from stdin")
|
|
67
|
-
.option("--totp-file <path>", "Read the TOTP secret from file (use - for stdin)")
|
|
68
|
-
.option("--totp-env <name>", "Read the TOTP secret from environment variable")
|
|
50
|
+
.option("--username <user>", "Username (login items only)");
|
|
51
|
+
|
|
52
|
+
registerItemTextOptions(command)
|
|
69
53
|
.option("--uri <url>", "URI (repeatable)", collect, [])
|
|
70
54
|
.option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
|
|
71
55
|
.option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
|
package/src/cli/commands/edit.ts
CHANGED
|
@@ -12,9 +12,13 @@ import {
|
|
|
12
12
|
assertSingleStdinSource,
|
|
13
13
|
collect,
|
|
14
14
|
listExplicitStdinInputs,
|
|
15
|
+
listUsedSourceFlags,
|
|
16
|
+
loginOnlyTextInputFlags,
|
|
17
|
+
registerItemTextOptions,
|
|
15
18
|
resolveFieldSources,
|
|
16
19
|
resolveItemTextInputs,
|
|
17
20
|
StdinReader,
|
|
21
|
+
TOTP_FAMILY,
|
|
18
22
|
type ItemTextInputs,
|
|
19
23
|
} from "../input.ts";
|
|
20
24
|
import { redactGeneratedPassword } from "./generate.ts";
|
|
@@ -36,37 +40,19 @@ interface EditOptions extends ItemTextInputs {
|
|
|
36
40
|
|
|
37
41
|
const LOGIN_ONLY_FLAGS: Array<[keyof EditOptions, string]> = [
|
|
38
42
|
["username", "--username"],
|
|
39
|
-
|
|
40
|
-
["passwordStdin", "--password-stdin"],
|
|
41
|
-
["passwordFile", "--password-file"],
|
|
42
|
-
["passwordEnv", "--password-env"],
|
|
43
|
-
["passwordGenerate", "--password-generate"],
|
|
44
|
-
["totp", "--totp"],
|
|
45
|
-
["totpStdin", "--totp-stdin"],
|
|
46
|
-
["totpFile", "--totp-file"],
|
|
47
|
-
["totpEnv", "--totp-env"],
|
|
43
|
+
...loginOnlyTextInputFlags(),
|
|
48
44
|
["rmTotp", "--rm-totp"],
|
|
49
45
|
];
|
|
50
46
|
|
|
51
47
|
export function registerEdit(program: Command): void {
|
|
52
|
-
program
|
|
48
|
+
const command = program
|
|
53
49
|
.command("edit")
|
|
54
50
|
.description("Edit an existing vault item")
|
|
55
51
|
.argument("<item>", "Item name or ID")
|
|
56
52
|
.option("--name <name>", "New name")
|
|
57
|
-
.option("--
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.option("--password <pass>", "New password")
|
|
61
|
-
.option("--password-stdin", "Read password from stdin")
|
|
62
|
-
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
63
|
-
.option("--password-env <name>", "Read password from environment variable")
|
|
64
|
-
.option("--password-generate", "Generate the password; it is never printed")
|
|
65
|
-
.option("--generate-length <n>", "Length for --password-generate", parseInt)
|
|
66
|
-
.option("--totp <secret>", "Set TOTP secret: base32 or otpauth:// URI")
|
|
67
|
-
.option("--totp-stdin", "Read the TOTP secret from stdin")
|
|
68
|
-
.option("--totp-file <path>", "Read the TOTP secret from file (use - for stdin)")
|
|
69
|
-
.option("--totp-env <name>", "Read the TOTP secret from environment variable")
|
|
53
|
+
.option("--username <user>", "New username");
|
|
54
|
+
|
|
55
|
+
registerItemTextOptions(command)
|
|
70
56
|
.option("--rm-totp", "Remove the stored TOTP secret")
|
|
71
57
|
.option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
|
|
72
58
|
.option("--add-field <kv>", "Add/update field k=v", collect, [])
|
|
@@ -165,12 +151,7 @@ export function registerEdit(program: Command): void {
|
|
|
165
151
|
function assertTotpIntent(localOpts: EditOptions): void {
|
|
166
152
|
if (!localOpts.rmTotp) return;
|
|
167
153
|
|
|
168
|
-
const setters =
|
|
169
|
-
localOpts.totp !== undefined ? "--totp" : null,
|
|
170
|
-
localOpts.totpStdin ? "--totp-stdin" : null,
|
|
171
|
-
localOpts.totpFile !== undefined ? "--totp-file" : null,
|
|
172
|
-
localOpts.totpEnv !== undefined ? "--totp-env" : null,
|
|
173
|
-
].filter((flag): flag is string => flag !== null);
|
|
154
|
+
const setters = listUsedSourceFlags(TOTP_FAMILY, localOpts);
|
|
174
155
|
|
|
175
156
|
if (setters.length > 0) {
|
|
176
157
|
throw new CliError(
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { emitData, emitLog } from "../io.ts";
|
|
4
|
-
import { readItemField, readTotp, readTotpSecret } from "../../bw/fields.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
|
|
|
@@ -12,7 +13,10 @@ export function registerGet(program: Command): void {
|
|
|
12
13
|
program
|
|
13
14
|
.command("get")
|
|
14
15
|
.description("Get a field from a vault item (built-in or custom)")
|
|
15
|
-
.argument(
|
|
16
|
+
.argument(
|
|
17
|
+
"<field>",
|
|
18
|
+
`Field: ${BUILTIN_FIELDS.join(" | ")} | <custom>. Card items also take: ${CARD_FIELDS.join(" | ")}`,
|
|
19
|
+
)
|
|
16
20
|
.argument("<item>", "Item name or ID")
|
|
17
21
|
.option(
|
|
18
22
|
"--fresh [seconds]",
|
|
@@ -80,15 +84,11 @@ async function emitTotp(
|
|
|
80
84
|
return;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
emitLog(`Waiting ${wait}s for a fresh code…`, opts);
|
|
89
|
-
await Bun.sleep(wait * 1000 + 250);
|
|
90
|
-
totp = await readTotp(item, opts);
|
|
91
|
-
}
|
|
87
|
+
const totp = await readTotp(item, opts, {
|
|
88
|
+
minSecondsRemaining: parseFreshSeconds(localOpts.fresh),
|
|
89
|
+
onWait: (seconds) =>
|
|
90
|
+
emitLog(`Waiting ${seconds}s for a fresh code…`, opts),
|
|
91
|
+
});
|
|
92
92
|
|
|
93
93
|
emitLog(`Valid for ${totp.secondsRemaining}s`, opts);
|
|
94
94
|
emitData(totp.code, opts, {
|
|
@@ -101,8 +101,8 @@ async function emitTotp(
|
|
|
101
101
|
* `--fresh` is a bare flag by default and takes an explicit threshold when the
|
|
102
102
|
* caller needs longer than {@link DEFAULT_FRESH_SECONDS} to use the code.
|
|
103
103
|
*/
|
|
104
|
-
function parseFreshSeconds(raw: string | boolean | undefined): number |
|
|
105
|
-
if (raw === undefined || raw === false) return
|
|
104
|
+
function parseFreshSeconds(raw: string | boolean | undefined): number | undefined {
|
|
105
|
+
if (raw === undefined || raw === false) return undefined;
|
|
106
106
|
if (raw === true) return DEFAULT_FRESH_SECONDS;
|
|
107
107
|
|
|
108
108
|
const value = Number(raw.trim());
|
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/input.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
1
2
|
import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
|
|
2
3
|
import { normalizeTotpInput } from "../bw/totp.ts";
|
|
3
4
|
import { CliError, ExitCode } from "./errors.ts";
|
|
@@ -80,15 +81,176 @@ export interface ExplicitStdinInputs extends ItemTextInputs {
|
|
|
80
81
|
fieldFiles?: string[];
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
/** Which slot of a {@link TextSource} a flag fills. */
|
|
85
|
+
type TextSourceKind = "inline" | "stdin" | "file" | "env" | "generate";
|
|
86
|
+
|
|
87
|
+
interface TextInputFlag {
|
|
88
|
+
/** Commander option spec, e.g. `--password-file <path>`. */
|
|
89
|
+
spec: string;
|
|
90
|
+
description: string;
|
|
91
|
+
/** The property commander derives from `spec`. */
|
|
92
|
+
key: keyof ItemTextInputs;
|
|
93
|
+
kind: TextSourceKind;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One value a caller can supply, and every flag that can supply it. Option
|
|
98
|
+
* registration, the "choose only one source" error, the stdin-conflict check and
|
|
99
|
+
* the login-only guard all read from here, so adding a source touches one table
|
|
100
|
+
* rather than both write commands and three hand-kept lists.
|
|
101
|
+
*/
|
|
102
|
+
export interface TextInputFamily {
|
|
103
|
+
/** Names the value in error messages. */
|
|
104
|
+
label: string;
|
|
105
|
+
flags: TextInputFlag[];
|
|
106
|
+
/** True when only a login item has anywhere to store the value. */
|
|
107
|
+
loginOnly: boolean;
|
|
108
|
+
/** Whether empty is a legitimate value — it is, for notes. */
|
|
109
|
+
allowEmpty?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const NOTES_FAMILY: TextInputFamily = {
|
|
113
|
+
label: "notes",
|
|
114
|
+
loginOnly: false,
|
|
115
|
+
allowEmpty: true,
|
|
116
|
+
flags: [
|
|
117
|
+
{
|
|
118
|
+
spec: "--notes <text>",
|
|
119
|
+
description: "Notes content",
|
|
120
|
+
key: "notes",
|
|
121
|
+
kind: "inline",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
spec: "--notes-file <path>",
|
|
125
|
+
description: "Read notes from file (use - for stdin)",
|
|
126
|
+
key: "notesFile",
|
|
127
|
+
kind: "file",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const PASSWORD_FAMILY: TextInputFamily = {
|
|
133
|
+
label: "password",
|
|
134
|
+
loginOnly: true,
|
|
135
|
+
flags: [
|
|
136
|
+
{
|
|
137
|
+
spec: "--password <pass>",
|
|
138
|
+
description: "Password (login items only)",
|
|
139
|
+
key: "password",
|
|
140
|
+
kind: "inline",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
spec: "--password-stdin",
|
|
144
|
+
description: "Read password from stdin",
|
|
145
|
+
key: "passwordStdin",
|
|
146
|
+
kind: "stdin",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
spec: "--password-file <path>",
|
|
150
|
+
description: "Read password from file (use - for stdin)",
|
|
151
|
+
key: "passwordFile",
|
|
152
|
+
kind: "file",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
spec: "--password-env <name>",
|
|
156
|
+
description: "Read password from environment variable",
|
|
157
|
+
key: "passwordEnv",
|
|
158
|
+
kind: "env",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
spec: "--password-generate",
|
|
162
|
+
description: "Generate the password; it is never printed",
|
|
163
|
+
key: "passwordGenerate",
|
|
164
|
+
kind: "generate",
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const TOTP_FAMILY: TextInputFamily = {
|
|
170
|
+
label: "TOTP secret",
|
|
171
|
+
loginOnly: true,
|
|
172
|
+
flags: [
|
|
173
|
+
{
|
|
174
|
+
spec: "--totp <secret>",
|
|
175
|
+
description: "TOTP secret: base32 or otpauth:// URI (login items only)",
|
|
176
|
+
key: "totp",
|
|
177
|
+
kind: "inline",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
spec: "--totp-stdin",
|
|
181
|
+
description: "Read the TOTP secret from stdin",
|
|
182
|
+
key: "totpStdin",
|
|
183
|
+
kind: "stdin",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
spec: "--totp-file <path>",
|
|
187
|
+
description: "Read the TOTP secret from file (use - for stdin)",
|
|
188
|
+
key: "totpFile",
|
|
189
|
+
kind: "file",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
spec: "--totp-env <name>",
|
|
193
|
+
description: "Read the TOTP secret from environment variable",
|
|
194
|
+
key: "totpEnv",
|
|
195
|
+
kind: "env",
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const ITEM_TEXT_FAMILIES = [NOTES_FAMILY, PASSWORD_FAMILY, TOTP_FAMILY];
|
|
201
|
+
|
|
202
|
+
function flagName(spec: string): string {
|
|
203
|
+
return spec.split(" ")[0]!;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sourceFlagNames(family: TextInputFamily): string[] {
|
|
207
|
+
return family.flags.map((flag) => flagName(flag.spec));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Registers every flag that can supply notes, a password, or a TOTP secret, plus
|
|
212
|
+
* the length modifier `--password-generate` takes. `create` and `edit` accept the
|
|
213
|
+
* identical set and {@link resolveItemTextInputs} is their only reader, so the
|
|
214
|
+
* set is declared once here instead of per command.
|
|
215
|
+
*/
|
|
216
|
+
export function registerItemTextOptions(command: Command): Command {
|
|
217
|
+
for (const family of ITEM_TEXT_FAMILIES) {
|
|
218
|
+
for (const flag of family.flags) {
|
|
219
|
+
command.option(flag.spec, flag.description);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return command.option(
|
|
224
|
+
"--generate-length <n>",
|
|
225
|
+
"Length for --password-generate",
|
|
226
|
+
(value) => Number.parseInt(value, 10),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The `[key, flag]` pairs a command must reject on a non-login item, because
|
|
232
|
+
* `buildNewItem` and `patchItem` have nowhere to put them and would accept the
|
|
233
|
+
* value only to drop it.
|
|
234
|
+
*/
|
|
235
|
+
export function loginOnlyTextInputFlags(): Array<[keyof ItemTextInputs, string]> {
|
|
236
|
+
return ITEM_TEXT_FAMILIES.filter((family) => family.loginOnly).flatMap(
|
|
237
|
+
(family) =>
|
|
238
|
+
family.flags.map(
|
|
239
|
+
(flag) =>
|
|
240
|
+
[flag.key, flagName(flag.spec)] as [keyof ItemTextInputs, string],
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The flags a caller actually used to supply this family's value. */
|
|
246
|
+
export function listUsedSourceFlags(
|
|
247
|
+
family: TextInputFamily,
|
|
248
|
+
inputs: ItemTextInputs,
|
|
249
|
+
): string[] {
|
|
250
|
+
return family.flags
|
|
251
|
+
.filter((flag) => inputs[flag.key] !== undefined)
|
|
252
|
+
.map((flag) => flagName(flag.spec));
|
|
253
|
+
}
|
|
92
254
|
|
|
93
255
|
export async function resolveItemTextInputs(
|
|
94
256
|
inputs: ItemTextInputs,
|
|
@@ -100,17 +262,17 @@ export async function resolveItemTextInputs(
|
|
|
100
262
|
const [notes, password, totp] = await Promise.all([
|
|
101
263
|
resolveOptionalTextSource(
|
|
102
264
|
{
|
|
103
|
-
label:
|
|
265
|
+
label: NOTES_FAMILY.label,
|
|
104
266
|
inline: inputs.notes,
|
|
105
267
|
file: inputs.notesFile,
|
|
106
|
-
flagNames:
|
|
107
|
-
allowEmpty:
|
|
268
|
+
flagNames: sourceFlagNames(NOTES_FAMILY),
|
|
269
|
+
allowEmpty: NOTES_FAMILY.allowEmpty,
|
|
108
270
|
},
|
|
109
271
|
stdin,
|
|
110
272
|
),
|
|
111
273
|
resolveOptionalTextSource(
|
|
112
274
|
{
|
|
113
|
-
label:
|
|
275
|
+
label: PASSWORD_FAMILY.label,
|
|
114
276
|
inline: inputs.password,
|
|
115
277
|
stdin: inputs.passwordStdin,
|
|
116
278
|
file: inputs.passwordFile,
|
|
@@ -119,18 +281,18 @@ export async function resolveItemTextInputs(
|
|
|
119
281
|
inputs.passwordGenerate && generatePassword
|
|
120
282
|
? generatePassword
|
|
121
283
|
: undefined,
|
|
122
|
-
flagNames:
|
|
284
|
+
flagNames: sourceFlagNames(PASSWORD_FAMILY),
|
|
123
285
|
},
|
|
124
286
|
stdin,
|
|
125
287
|
),
|
|
126
288
|
resolveOptionalTextSource(
|
|
127
289
|
{
|
|
128
|
-
label:
|
|
290
|
+
label: TOTP_FAMILY.label,
|
|
129
291
|
inline: inputs.totp,
|
|
130
292
|
stdin: inputs.totpStdin,
|
|
131
293
|
file: inputs.totpFile,
|
|
132
294
|
env: inputs.totpEnv,
|
|
133
|
-
flagNames:
|
|
295
|
+
flagNames: sourceFlagNames(TOTP_FAMILY),
|
|
134
296
|
},
|
|
135
297
|
stdin,
|
|
136
298
|
),
|
|
@@ -147,16 +309,26 @@ export async function resolveItemTextInputs(
|
|
|
147
309
|
}
|
|
148
310
|
|
|
149
311
|
export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
312
|
+
const sources: string[] = [];
|
|
313
|
+
|
|
314
|
+
for (const family of ITEM_TEXT_FAMILIES) {
|
|
315
|
+
for (const flag of family.flags) {
|
|
316
|
+
const name = flagName(flag.spec);
|
|
317
|
+
if (flag.kind === "stdin" && inputs[flag.key]) {
|
|
318
|
+
sources.push(name);
|
|
319
|
+
} else if (flag.kind === "file" && inputs[flag.key] === "-") {
|
|
320
|
+
sources.push(`${name} -`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (inputs.fieldFileFlag && inputs.fieldFiles) {
|
|
326
|
+
sources.push(
|
|
327
|
+
...stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return sources;
|
|
160
332
|
}
|
|
161
333
|
|
|
162
334
|
export async function resolveOptionalTextSource(
|
|
@@ -64,32 +64,42 @@ export async function withProcessLock<T>(
|
|
|
64
64
|
|
|
65
65
|
async function acquire(options: ProcessLockOptions): Promise<Ownership> {
|
|
66
66
|
const startedAt = performance.now();
|
|
67
|
-
let holder: LockOwner | null = null;
|
|
68
67
|
|
|
69
68
|
while (true) {
|
|
70
69
|
const ownership = tryCreate(options.path);
|
|
71
70
|
if (ownership) return ownership;
|
|
72
71
|
|
|
73
|
-
holder = readOwner(options.path, options.label);
|
|
74
|
-
if (!holder) continue;
|
|
72
|
+
const holder = readOwner(options.path, options.label);
|
|
75
73
|
|
|
76
|
-
if (!isProcessAlive(holder.pid)) {
|
|
74
|
+
if (holder && !isProcessAlive(holder.pid)) {
|
|
77
75
|
if (detachAbandonedLock(options.path, holder, options.label)) continue;
|
|
78
76
|
}
|
|
79
77
|
|
|
78
|
+
// The deadline is checked even when the lock turned out to be free, so a
|
|
79
|
+
// peer that keeps taking and releasing it cannot spin us indefinitely.
|
|
80
80
|
const elapsed = performance.now() - startedAt;
|
|
81
|
-
if (elapsed >= options.waitMs)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
81
|
+
if (elapsed >= options.waitMs) throw timedOut(options, holder);
|
|
82
|
+
|
|
83
|
+
// A lock that vanished mid-inspection is free: contend for it again at once
|
|
84
|
+
// rather than sleeping on a holder that is already gone.
|
|
85
|
+
if (!holder) continue;
|
|
87
86
|
|
|
88
87
|
const remaining = options.waitMs - elapsed;
|
|
89
88
|
await Bun.sleep(Math.min(options.pollMs ?? DEFAULT_POLL_MS, remaining));
|
|
90
89
|
}
|
|
91
90
|
}
|
|
92
91
|
|
|
92
|
+
function timedOut(
|
|
93
|
+
options: ProcessLockOptions,
|
|
94
|
+
holder: LockOwner | null,
|
|
95
|
+
): CliError {
|
|
96
|
+
const held = holder ? ` held by pid ${holder.pid}` : "";
|
|
97
|
+
return new CliError(
|
|
98
|
+
`Timed out after ${formatDuration(options.waitMs)} waiting for ${options.label}${held}`,
|
|
99
|
+
ExitCode.Timeout,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
function tryCreate(path: string): Ownership | null {
|
|
94
104
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
95
105
|
|
|
@@ -143,6 +153,14 @@ function readOwner(path: string, label: string): LockOwner | null {
|
|
|
143
153
|
|
|
144
154
|
const read = readPrivateFile(`${path}/${OWNER_FILE}`);
|
|
145
155
|
if (read.kind !== "ok") {
|
|
156
|
+
// A holder publishes `owner.json` before the directory is renamed into
|
|
157
|
+
// place, and lets go by renaming the whole directory away. So an owner file
|
|
158
|
+
// that would not read, under a directory that is no longer the one just
|
|
159
|
+
// inspected, means the holder released mid-read — the caller should contend
|
|
160
|
+
// again rather than report a corrupt lock. Only an unreadable owner file in
|
|
161
|
+
// the *same* directory is genuinely wrong.
|
|
162
|
+
if (inodeAt(path) !== stat.ino) return null;
|
|
163
|
+
|
|
146
164
|
const reason =
|
|
147
165
|
read.kind === "missing"
|
|
148
166
|
? `${path}/${OWNER_FILE} is missing`
|
|
@@ -246,6 +264,20 @@ function pathExists(path: string): boolean {
|
|
|
246
264
|
}
|
|
247
265
|
}
|
|
248
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Identifies what currently sits at `path`, or `null` when nothing does. Compared
|
|
269
|
+
* against an earlier `stat` it answers "is this still the same directory?", which
|
|
270
|
+
* distinguishes a released lock from a corrupt one.
|
|
271
|
+
*/
|
|
272
|
+
function inodeAt(path: string): number | null {
|
|
273
|
+
try {
|
|
274
|
+
return lstatSync(path).ino;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
if (errorCode(err) === "ENOENT") return null;
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
249
281
|
function errorCode(err: unknown): string | undefined {
|
|
250
282
|
return (err as NodeJS.ErrnoException)?.code;
|
|
251
283
|
}
|