bwx 0.4.1 → 0.6.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 +136 -10
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +107 -14
- package/src/bw/encoding.ts +16 -4
- package/src/bw/fields.ts +326 -27
- package/src/bw/folders.ts +96 -0
- package/src/bw/generate.ts +91 -0
- package/src/bw/items.ts +8 -0
- package/src/bw/session.ts +36 -10
- package/src/bw/totp.ts +246 -0
- package/src/cli/commands/config.ts +1 -10
- package/src/cli/commands/create.ts +60 -5
- package/src/cli/commands/delete.ts +48 -21
- package/src/cli/commands/edit.ts +88 -5
- package/src/cli/commands/folders.ts +34 -0
- package/src/cli/commands/generate.ts +57 -0
- package/src/cli/commands/get.ts +130 -7
- package/src/cli/commands/list.ts +25 -8
- package/src/cli/commands/run.ts +14 -9
- package/src/cli/commands/search.ts +16 -12
- package/src/cli/commands/status.ts +32 -5
- package/src/cli/commands/trash.ts +92 -0
- package/src/cli/input.ts +55 -2
- package/src/cli/io.ts +12 -2
- package/src/cli/items.ts +56 -10
- package/src/cli/program.ts +6 -0
package/src/bw/fields.ts
CHANGED
|
@@ -4,7 +4,15 @@ import type { GlobalOptions } from "../cli/types.ts";
|
|
|
4
4
|
import { summarizeItem, type ItemSummary } from "./items.ts";
|
|
5
5
|
import { runBwOrThrow } from "./runner.ts";
|
|
6
6
|
import { withSession } from "./session.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_PERIOD,
|
|
9
|
+
generateTotp,
|
|
10
|
+
parseTotpSecret,
|
|
11
|
+
secondsRemaining,
|
|
12
|
+
TotpUnsupportedError,
|
|
13
|
+
type TotpCode,
|
|
14
|
+
} from "./totp.ts";
|
|
15
|
+
import { BwItemSchema, type BwItem } from "./types.ts";
|
|
8
16
|
|
|
9
17
|
/** Fields `bw get` resolves natively; anything else is a custom field name. */
|
|
10
18
|
const BUILTIN_FIELDS = [
|
|
@@ -20,6 +28,11 @@ function isBuiltinField(field: string): boolean {
|
|
|
20
28
|
return (BUILTIN_FIELDS as readonly string[]).includes(field);
|
|
21
29
|
}
|
|
22
30
|
|
|
31
|
+
/** True when `field` should be read as the item's TOTP rather than a custom field. */
|
|
32
|
+
function isTotpField(field: string, options?: ReadFieldOptions): boolean {
|
|
33
|
+
return field === "totp" && !options?.customOnly;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export interface ReadFieldOptions {
|
|
24
37
|
/**
|
|
25
38
|
* Always resolve `field` as a custom field name, so an item whose custom
|
|
@@ -28,10 +41,23 @@ export interface ReadFieldOptions {
|
|
|
28
41
|
customOnly?: boolean;
|
|
29
42
|
}
|
|
30
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Raised when the item was found but the requested field was not. Distinct from
|
|
46
|
+
* a missing *item* so the "did you mean this item?" suggestions below never fire
|
|
47
|
+
* on an error that already names the fields that do exist.
|
|
48
|
+
*/
|
|
49
|
+
class FieldMissingError extends CliError {}
|
|
50
|
+
|
|
51
|
+
/** One `(field, item)` pair to resolve — see {@link readFields}. */
|
|
52
|
+
export interface FieldRequest {
|
|
53
|
+
field: string;
|
|
54
|
+
item: string;
|
|
55
|
+
options?: ReadFieldOptions;
|
|
56
|
+
}
|
|
57
|
+
|
|
31
58
|
/**
|
|
32
59
|
* Reads one field from a vault item and returns it verbatim (`item` yields the
|
|
33
60
|
* item JSON), leaving the caller to decide whether the value is printed at all.
|
|
34
|
-
* Ambiguous item lookups fail with the candidate list instead of a raw bw error.
|
|
35
61
|
*/
|
|
36
62
|
export async function readItemField(
|
|
37
63
|
field: string,
|
|
@@ -39,22 +65,205 @@ export async function readItemField(
|
|
|
39
65
|
opts: GlobalOptions,
|
|
40
66
|
options?: ReadFieldOptions,
|
|
41
67
|
): Promise<string> {
|
|
42
|
-
|
|
68
|
+
return withLookupHelp(item, opts, async () => {
|
|
69
|
+
if (isTotpField(field, options)) {
|
|
70
|
+
return (await resolveTotp(await fetchItem(item, opts), item, opts)).code;
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
return isBuiltinField(field) && !options?.customOnly
|
|
44
|
-
?
|
|
45
|
-
:
|
|
74
|
+
? readBuiltinField(field, item, opts)
|
|
75
|
+
: readCustomField(field, item, opts);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 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 need the remainder to
|
|
82
|
+
* decide whether to wait for the next window.
|
|
83
|
+
*/
|
|
84
|
+
export async function readTotp(
|
|
85
|
+
item: string,
|
|
86
|
+
opts: GlobalOptions,
|
|
87
|
+
): Promise<TotpCode> {
|
|
88
|
+
return withLookupHelp(item, opts, async () =>
|
|
89
|
+
resolveTotp(await fetchItem(item, opts), item, opts),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The stored secret itself, for moving a seed back out to another authenticator. */
|
|
94
|
+
export async function readTotpSecret(
|
|
95
|
+
item: string,
|
|
96
|
+
opts: GlobalOptions,
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
return withLookupHelp(item, opts, async () => {
|
|
99
|
+
const fetched = await fetchItem(item, opts);
|
|
100
|
+
return requireStoredTotp(fetched);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function requireStoredTotp(item: BwItem): string {
|
|
105
|
+
const stored = item.login?.totp;
|
|
106
|
+
if (!stored) {
|
|
107
|
+
throw new FieldMissingError(
|
|
108
|
+
`No totp found for item: ${item.name}`,
|
|
109
|
+
ExitCode.NotFound,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return stored;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Computes the code from the secret already in hand, falling back to `bw get
|
|
117
|
+
* totp` for the shapes this codebase will not compute itself (see `totp.ts`).
|
|
118
|
+
* The fallback costs a second spawn, which is the correct trade against
|
|
119
|
+
* emitting a confidently wrong six digits.
|
|
120
|
+
*/
|
|
121
|
+
async function resolveTotp(
|
|
122
|
+
fetched: BwItem,
|
|
123
|
+
item: string,
|
|
124
|
+
opts: GlobalOptions,
|
|
125
|
+
): Promise<TotpCode> {
|
|
126
|
+
const stored = requireStoredTotp(fetched);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
return generateTotp(parseTotpSecret(stored));
|
|
130
|
+
} catch (err) {
|
|
131
|
+
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
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolves many fields at once, paying a single `bw get item` per distinct item
|
|
143
|
+
* rather than one `bw` spawn per field. Each spawn costs ~2.5s, so a caller
|
|
144
|
+
* pulling a username and password from one item halves its own runtime. `totp`
|
|
145
|
+
* joins that batch too, since the code is computed from the secret the fetch
|
|
146
|
+
* already returned.
|
|
147
|
+
*
|
|
148
|
+
* Returns values positionally aligned with `requests`.
|
|
149
|
+
*/
|
|
150
|
+
export async function readFields(
|
|
151
|
+
requests: FieldRequest[],
|
|
152
|
+
opts: GlobalOptions,
|
|
153
|
+
): Promise<string[]> {
|
|
154
|
+
const values = new Array<string>(requests.length);
|
|
155
|
+
const byItem = new Map<string, number[]>();
|
|
156
|
+
|
|
157
|
+
for (const [index, request] of requests.entries()) {
|
|
158
|
+
const bucket = byItem.get(request.item);
|
|
159
|
+
if (bucket) bucket.push(index);
|
|
160
|
+
else byItem.set(request.item, [index]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [item, indexes] of byItem) {
|
|
164
|
+
// A single `bw` process at a time: each read may trigger an unlock, and
|
|
165
|
+
// concurrent unlocks would race over the cached session.
|
|
166
|
+
const fetched = await withLookupHelp(item, opts, () => fetchItem(item, opts));
|
|
167
|
+
|
|
168
|
+
for (const index of indexes) {
|
|
169
|
+
const request = requests[index]!;
|
|
170
|
+
|
|
171
|
+
if (isTotpField(request.field, request.options)) {
|
|
172
|
+
const totp = await withLookupHelp(item, opts, () =>
|
|
173
|
+
resolveTotp(fetched, item, opts),
|
|
174
|
+
);
|
|
175
|
+
values[index] = totp.code;
|
|
176
|
+
} else {
|
|
177
|
+
values[index] = extractField(fetched, request.field, request.options);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return values;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Turns bw's bare "Not found." and "More than one result" into errors that name
|
|
187
|
+
* the candidates, so a caller can correct itself without a round of guessing.
|
|
188
|
+
*/
|
|
189
|
+
async function withLookupHelp<T>(
|
|
190
|
+
item: string,
|
|
191
|
+
opts: GlobalOptions,
|
|
192
|
+
fn: () => Promise<T>,
|
|
193
|
+
): Promise<T> {
|
|
194
|
+
try {
|
|
195
|
+
return await fn();
|
|
46
196
|
} catch (err) {
|
|
197
|
+
if (err instanceof FieldMissingError) throw err;
|
|
198
|
+
|
|
47
199
|
if (err instanceof CliError && /more than one result/i.test(err.message)) {
|
|
48
|
-
const matches = await findMatches(item);
|
|
49
200
|
throw new CliError(
|
|
50
|
-
|
|
201
|
+
formatAmbiguous(item, await findMatches(item), opts),
|
|
51
202
|
ExitCode.BadArgs,
|
|
52
203
|
);
|
|
53
204
|
}
|
|
205
|
+
|
|
206
|
+
if (err instanceof CliError && err.exitCode === ExitCode.NotFound) {
|
|
207
|
+
throw new CliError(
|
|
208
|
+
formatNotFound(item, await findSimilar(item), opts),
|
|
209
|
+
ExitCode.NotFound,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
54
213
|
throw err;
|
|
55
214
|
}
|
|
56
215
|
}
|
|
57
216
|
|
|
217
|
+
async function fetchItem(item: string, opts: GlobalOptions): Promise<BwItem> {
|
|
218
|
+
const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
|
|
219
|
+
return BwItemSchema.parse(JSON.parse(json));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Mirrors what `bw get <field>` would have returned for an already-fetched item. */
|
|
223
|
+
function extractField(
|
|
224
|
+
item: BwItem,
|
|
225
|
+
field: string,
|
|
226
|
+
options?: ReadFieldOptions,
|
|
227
|
+
): string {
|
|
228
|
+
if (!isBuiltinField(field) || options?.customOnly) {
|
|
229
|
+
return pickCustomField(item, field);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (field === "item") return JSON.stringify(item);
|
|
233
|
+
|
|
234
|
+
// Named exhaustively rather than defaulted: a chain ending in `password`
|
|
235
|
+
// would hand back the password for any field it failed to recognize.
|
|
236
|
+
let value: string | null;
|
|
237
|
+
switch (field) {
|
|
238
|
+
case "notes":
|
|
239
|
+
value = item.notes;
|
|
240
|
+
break;
|
|
241
|
+
case "uri":
|
|
242
|
+
value = item.login?.uris?.[0]?.uri ?? null;
|
|
243
|
+
break;
|
|
244
|
+
case "username":
|
|
245
|
+
value = item.login?.username ?? null;
|
|
246
|
+
break;
|
|
247
|
+
case "password":
|
|
248
|
+
value = item.login?.password ?? null;
|
|
249
|
+
break;
|
|
250
|
+
default:
|
|
251
|
+
throw new FieldMissingError(
|
|
252
|
+
`Field "${field}" cannot be read from a fetched item`,
|
|
253
|
+
ExitCode.NotFound,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!value) {
|
|
258
|
+
throw new FieldMissingError(
|
|
259
|
+
`No ${field} found for item: ${item.name}`,
|
|
260
|
+
ExitCode.NotFound,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
|
|
58
267
|
async function readBuiltinField(
|
|
59
268
|
field: string,
|
|
60
269
|
item: string,
|
|
@@ -65,7 +274,7 @@ async function readBuiltinField(
|
|
|
65
274
|
);
|
|
66
275
|
|
|
67
276
|
if (!result) {
|
|
68
|
-
throw new
|
|
277
|
+
throw new FieldMissingError(
|
|
69
278
|
`No ${field} found for item: ${item}`,
|
|
70
279
|
ExitCode.NotFound,
|
|
71
280
|
);
|
|
@@ -79,20 +288,21 @@ async function readCustomField(
|
|
|
79
288
|
item: string,
|
|
80
289
|
opts: GlobalOptions,
|
|
81
290
|
): Promise<string> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
291
|
+
return pickCustomField(await fetchItem(item, opts), fieldName);
|
|
292
|
+
}
|
|
85
293
|
|
|
86
|
-
|
|
87
|
-
const fields =
|
|
294
|
+
function pickCustomField(item: BwItem, fieldName: string): string {
|
|
295
|
+
const fields = item.fields ?? [];
|
|
88
296
|
const match = fields.find((f) => f.name === fieldName);
|
|
89
297
|
|
|
90
298
|
if (!match) {
|
|
91
299
|
const available = fields.map((f) => f.name).join(", ");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
300
|
+
throw new FieldMissingError(
|
|
301
|
+
available
|
|
302
|
+
? `Field "${fieldName}" not found. Available: ${available}`
|
|
303
|
+
: `Field "${fieldName}" not found (item has no custom fields)`,
|
|
304
|
+
ExitCode.NotFound,
|
|
305
|
+
);
|
|
96
306
|
}
|
|
97
307
|
|
|
98
308
|
return match.value ?? "";
|
|
@@ -108,7 +318,95 @@ async function findMatches(query: string): Promise<ItemSummary[]> {
|
|
|
108
318
|
}
|
|
109
319
|
}
|
|
110
320
|
|
|
111
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Looks for what the caller probably meant. bw's own search already came back
|
|
323
|
+
* empty, so this pulls the item list once and scores it locally: one `bw` spawn
|
|
324
|
+
* on the error path, and matching that survives typos and extra words the way
|
|
325
|
+
* repeated `--search` calls do not.
|
|
326
|
+
*/
|
|
327
|
+
async function findSimilar(query: string): Promise<ItemSummary[]> {
|
|
328
|
+
let summaries: ItemSummary[];
|
|
329
|
+
try {
|
|
330
|
+
const json = await runBwOrThrow(["list", "items"]);
|
|
331
|
+
const items: Array<Record<string, unknown>> = JSON.parse(json);
|
|
332
|
+
summaries = items.map(summarizeItem);
|
|
333
|
+
} catch {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return rankSimilar(query, summaries);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Pure so the suggestion quality is testable without a vault. */
|
|
341
|
+
export function rankSimilar(
|
|
342
|
+
query: string,
|
|
343
|
+
items: ItemSummary[],
|
|
344
|
+
max = MAX_SUGGESTIONS,
|
|
345
|
+
): ItemSummary[] {
|
|
346
|
+
const tokens = tokenize(query);
|
|
347
|
+
if (tokens.length === 0) return [];
|
|
348
|
+
|
|
349
|
+
const scored = items
|
|
350
|
+
.map((item) => ({ item, score: scoreMatch(tokens, item) }))
|
|
351
|
+
.filter((entry) => entry.score > 0)
|
|
352
|
+
.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name));
|
|
353
|
+
|
|
354
|
+
return scored.slice(0, max).map((entry) => entry.item);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Length gates on the fuzzier rules keep short words from matching by accident —
|
|
359
|
+
* without them "not" pulls in every item containing "evernote", and a suggestion
|
|
360
|
+
* list full of noise is worse than none.
|
|
361
|
+
*/
|
|
362
|
+
function scoreMatch(tokens: string[], item: ItemSummary): number {
|
|
363
|
+
const haystack = `${item.name} ${item.username ?? ""}`.toLowerCase();
|
|
364
|
+
const itemTokens = tokenize(haystack);
|
|
365
|
+
|
|
366
|
+
let score = 0;
|
|
367
|
+
for (const token of tokens) {
|
|
368
|
+
if (itemTokens.includes(token)) score += 3;
|
|
369
|
+
else if (token.length >= 4 && haystack.includes(token)) score += 2;
|
|
370
|
+
else if (
|
|
371
|
+
token.length >= 3 &&
|
|
372
|
+
itemTokens.some((other) => other.startsWith(token))
|
|
373
|
+
) {
|
|
374
|
+
score += 1;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return score;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function tokenize(value: string): string[] {
|
|
382
|
+
return value
|
|
383
|
+
.toLowerCase()
|
|
384
|
+
.split(/[^a-z0-9]+/)
|
|
385
|
+
.filter((token) => token.length >= 2);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const MAX_SUGGESTIONS = 5;
|
|
389
|
+
|
|
390
|
+
function formatNotFound(
|
|
391
|
+
query: string,
|
|
392
|
+
matches: ItemSummary[],
|
|
393
|
+
opts: GlobalOptions,
|
|
394
|
+
): string {
|
|
395
|
+
if (matches.length === 0) {
|
|
396
|
+
return `No item matching "${query}". Run 'bwx search <query>' to find it.`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (opts.json) {
|
|
400
|
+
return `No item matching "${query}". Did you mean: ${JSON.stringify(matches)}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return [
|
|
404
|
+
`No item matching "${query}". Did you mean:\n`,
|
|
405
|
+
...matches.map(formatCandidate),
|
|
406
|
+
].join("\n");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function formatAmbiguous(
|
|
112
410
|
query: string,
|
|
113
411
|
matches: ItemSummary[],
|
|
114
412
|
opts: GlobalOptions,
|
|
@@ -121,13 +419,14 @@ function formatAmbiguousError(
|
|
|
121
419
|
return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
|
|
122
420
|
}
|
|
123
421
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
422
|
+
return [
|
|
423
|
+
`Multiple items match "${query}":\n`,
|
|
424
|
+
...matches.map(formatCandidate),
|
|
425
|
+
`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`,
|
|
426
|
+
].join("\n");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function formatCandidate(match: ItemSummary): string {
|
|
430
|
+
const user = match.username ? ` (${match.username})` : "";
|
|
431
|
+
return ` ${pc.dim(match.id)} ${pc.cyan(match.type)} ${match.name}${pc.dim(user)}`;
|
|
133
432
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
3
|
+
import type { GlobalOptions } from "../cli/types.ts";
|
|
4
|
+
import { runBwOrThrow } from "./runner.ts";
|
|
5
|
+
import { withSession } from "./session.ts";
|
|
6
|
+
|
|
7
|
+
export interface BwFolder {
|
|
8
|
+
/** `null` is bw's "No Folder" bucket, which every unfiled item belongs to. */
|
|
9
|
+
id: string | null;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const UUID_RE =
|
|
14
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
|
|
16
|
+
/** The spelling bw expects on `--folderid` for items that are in no folder. */
|
|
17
|
+
export const NO_FOLDER = "null";
|
|
18
|
+
|
|
19
|
+
export async function listFolders(opts: GlobalOptions): Promise<BwFolder[]> {
|
|
20
|
+
const json = await withSession(opts, () => runBwOrThrow(["list", "folders"]));
|
|
21
|
+
const raw = JSON.parse(json) as Array<Record<string, unknown>>;
|
|
22
|
+
return raw.map((folder) => ({
|
|
23
|
+
id: typeof folder.id === "string" ? folder.id : null,
|
|
24
|
+
name: String(folder.name ?? ""),
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Turns whatever the caller wrote in `--folder` into an id. IDs pass straight
|
|
30
|
+
* through, so this costs nothing for callers that already have one; names are
|
|
31
|
+
* resolved against the vault because a folder id is not something a human — or
|
|
32
|
+
* an agent reading `bwx list` output — can otherwise discover.
|
|
33
|
+
*
|
|
34
|
+
* Returns `null` for the "no folder" bucket (`--folder none`).
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveFolderRef(
|
|
37
|
+
ref: string,
|
|
38
|
+
opts: GlobalOptions,
|
|
39
|
+
): Promise<string | null> {
|
|
40
|
+
if (UUID_RE.test(ref)) return ref;
|
|
41
|
+
if (ref === NO_FOLDER || ref.toLowerCase() === "none") return null;
|
|
42
|
+
|
|
43
|
+
const folders = await listFolders(opts);
|
|
44
|
+
const named = folders.filter((folder) => folder.id !== null);
|
|
45
|
+
|
|
46
|
+
const exact = named.filter(
|
|
47
|
+
(folder) => folder.name.toLowerCase() === ref.toLowerCase(),
|
|
48
|
+
);
|
|
49
|
+
const matches =
|
|
50
|
+
exact.length > 0
|
|
51
|
+
? exact
|
|
52
|
+
: named.filter((folder) =>
|
|
53
|
+
folder.name.toLowerCase().includes(ref.toLowerCase()),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (matches.length === 0) {
|
|
57
|
+
throw new CliError(
|
|
58
|
+
`No folder matching "${ref}". Run 'bwx folders' to list them.`,
|
|
59
|
+
ExitCode.NotFound,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (matches.length > 1) {
|
|
64
|
+
throw new CliError(
|
|
65
|
+
formatAmbiguousFolders(ref, matches, opts),
|
|
66
|
+
ExitCode.BadArgs,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return matches[0]!.id;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolves `--folder` for read commands, where "no folder" is a real filter. */
|
|
74
|
+
export async function resolveFolderFilter(
|
|
75
|
+
ref: string,
|
|
76
|
+
opts: GlobalOptions,
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
return (await resolveFolderRef(ref, opts)) ?? NO_FOLDER;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatAmbiguousFolders(
|
|
82
|
+
ref: string,
|
|
83
|
+
matches: BwFolder[],
|
|
84
|
+
opts: GlobalOptions,
|
|
85
|
+
): string {
|
|
86
|
+
if (opts.json) {
|
|
87
|
+
return `Multiple folders match "${ref}": ${JSON.stringify(matches)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lines = [`Multiple folders match "${ref}":\n`];
|
|
91
|
+
for (const folder of matches) {
|
|
92
|
+
lines.push(` ${pc.dim(folder.id ?? "-")} ${folder.name}`);
|
|
93
|
+
}
|
|
94
|
+
lines.push(`\nUse the full name or its ID.`);
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
2
|
+
import { runBwOrThrow } from "./runner.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `bw generate` is local — it needs no session and no unlocked vault, so a
|
|
6
|
+
* generated secret costs one fast spawn and never touches the network.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface GenerateOptions {
|
|
10
|
+
length?: number;
|
|
11
|
+
passphrase?: boolean;
|
|
12
|
+
words?: number;
|
|
13
|
+
separator?: string;
|
|
14
|
+
capitalize?: boolean;
|
|
15
|
+
includeNumber?: boolean;
|
|
16
|
+
uppercase?: boolean;
|
|
17
|
+
lowercase?: boolean;
|
|
18
|
+
numbers?: boolean;
|
|
19
|
+
special?: boolean;
|
|
20
|
+
ambiguous?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** bw's own defaults, restated here so `--no-numbers` can turn one off without
|
|
24
|
+
* silently disabling the rest: bw needs every wanted character set named. */
|
|
25
|
+
const CHARSET_DEFAULTS = {
|
|
26
|
+
uppercase: true,
|
|
27
|
+
lowercase: true,
|
|
28
|
+
numbers: true,
|
|
29
|
+
special: false,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export function buildGenerateArgs(options: GenerateOptions): string[] {
|
|
33
|
+
const args = ["generate"];
|
|
34
|
+
|
|
35
|
+
if (options.passphrase) {
|
|
36
|
+
args.push("--passphrase");
|
|
37
|
+
if (options.words !== undefined) args.push("--words", String(options.words));
|
|
38
|
+
if (options.separator !== undefined) {
|
|
39
|
+
args.push("--separator", options.separator);
|
|
40
|
+
}
|
|
41
|
+
if (options.capitalize) args.push("--capitalize");
|
|
42
|
+
if (options.includeNumber) args.push("--includeNumber");
|
|
43
|
+
return args;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const charsets = {
|
|
47
|
+
uppercase: options.uppercase ?? CHARSET_DEFAULTS.uppercase,
|
|
48
|
+
lowercase: options.lowercase ?? CHARSET_DEFAULTS.lowercase,
|
|
49
|
+
numbers: options.numbers ?? CHARSET_DEFAULTS.numbers,
|
|
50
|
+
special: options.special ?? CHARSET_DEFAULTS.special,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (!Object.values(charsets).some(Boolean)) {
|
|
54
|
+
throw new CliError(
|
|
55
|
+
"At least one character set must stay enabled.",
|
|
56
|
+
ExitCode.BadArgs,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (options.length !== undefined) {
|
|
61
|
+
if (!Number.isInteger(options.length) || options.length < 5) {
|
|
62
|
+
throw new CliError(
|
|
63
|
+
`Invalid --length "${options.length}". Use an integer of at least 5.`,
|
|
64
|
+
ExitCode.BadArgs,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
args.push("--length", String(options.length));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (charsets.uppercase) args.push("--uppercase");
|
|
71
|
+
if (charsets.lowercase) args.push("--lowercase");
|
|
72
|
+
if (charsets.numbers) args.push("--number");
|
|
73
|
+
if (charsets.special) args.push("--special");
|
|
74
|
+
if (options.ambiguous) args.push("--ambiguous");
|
|
75
|
+
|
|
76
|
+
return args;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function generateSecret(
|
|
80
|
+
options: GenerateOptions,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
const value = await runBwOrThrow(buildGenerateArgs(options), {
|
|
83
|
+
session: null,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!value) {
|
|
87
|
+
throw new CliError("bw generate returned nothing", ExitCode.BwError);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return value;
|
|
91
|
+
}
|
package/src/bw/items.ts
CHANGED
|
@@ -10,16 +10,24 @@ export interface ItemSummary {
|
|
|
10
10
|
type: string;
|
|
11
11
|
name: string;
|
|
12
12
|
username: string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the item carries a TOTP secret — the fact, never the secret.
|
|
15
|
+
* Without it, finding which items have 2FA means `--full-items`, which dumps
|
|
16
|
+
* every password and note in the vault to answer a boolean.
|
|
17
|
+
*/
|
|
18
|
+
hasTotp: boolean;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export function summarizeItem(item: Record<string, unknown>): ItemSummary {
|
|
16
22
|
const login = item.login as Record<string, unknown> | null | undefined;
|
|
17
23
|
const username = login?.username;
|
|
24
|
+
const totp = login?.totp;
|
|
18
25
|
|
|
19
26
|
return {
|
|
20
27
|
id: String(item.id ?? ""),
|
|
21
28
|
type: BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`,
|
|
22
29
|
name: String(item.name ?? ""),
|
|
23
30
|
username: typeof username === "string" && username ? username : null,
|
|
31
|
+
hasTotp: typeof totp === "string" && totp.length > 0,
|
|
24
32
|
};
|
|
25
33
|
}
|
package/src/bw/session.ts
CHANGED
|
@@ -60,21 +60,47 @@ async function getMasterPassword(): Promise<string> {
|
|
|
60
60
|
return pw;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Whether reads will work right now without an unlock. `bw status` answers for
|
|
65
|
+
* the vault, not for us: it reports "locked" whenever the vault has no session
|
|
66
|
+
* of its own, even when bwx holds a cached one that works perfectly. Reporting
|
|
67
|
+
* that raw answer sends callers off to unlock a vault they can already read.
|
|
68
|
+
*/
|
|
69
|
+
export type SessionState =
|
|
70
|
+
/** Cached session works — reads succeed with no unlock. */
|
|
71
|
+
| "valid"
|
|
72
|
+
/** A session was cached but the vault no longer accepts it. */
|
|
73
|
+
| "stale"
|
|
74
|
+
/** Nothing cached; the next read unlocks. */
|
|
75
|
+
| "none";
|
|
76
|
+
|
|
77
|
+
/** Confirms a token by asking the vault, which is the only authority on it. */
|
|
78
|
+
async function sessionWorks(token: string): Promise<boolean> {
|
|
79
|
+
const check = await runBw(["status"], { session: token });
|
|
80
|
+
if (check.exitCode !== 0) return false;
|
|
81
|
+
try {
|
|
82
|
+
return (
|
|
83
|
+
BwStatusSchema.parse(JSON.parse(check.stdout)).status === "unlocked"
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function probeSessionState(
|
|
91
|
+
opts: GlobalOptions,
|
|
92
|
+
): Promise<SessionState> {
|
|
93
|
+
const cached = getSession() || loadCachedSession(opts);
|
|
94
|
+
if (!cached) return "none";
|
|
95
|
+
return (await sessionWorks(cached)) ? "valid" : "stale";
|
|
96
|
+
}
|
|
97
|
+
|
|
63
98
|
export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
|
|
64
99
|
// Try cached session first
|
|
65
100
|
const cached = getSession() || loadCachedSession(opts);
|
|
66
101
|
if (cached) {
|
|
67
102
|
setSession(cached);
|
|
68
|
-
|
|
69
|
-
const check = await runBw(["status"]);
|
|
70
|
-
if (check.exitCode === 0) {
|
|
71
|
-
try {
|
|
72
|
-
const status = BwStatusSchema.parse(JSON.parse(check.stdout));
|
|
73
|
-
if (status.status === "unlocked") return;
|
|
74
|
-
} catch {
|
|
75
|
-
// Fall through
|
|
76
|
-
}
|
|
77
|
-
}
|
|
103
|
+
if (await sessionWorks(cached)) return;
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
const status = await getStatus();
|