bwx 0.4.0 → 0.5.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 +85 -4
- package/package.json +3 -2
- package/skill/bwx/SKILL.md +196 -0
- package/src/bw/encoding.ts +2 -1
- package/src/bw/fields.ts +246 -27
- package/src/bw/folders.ts +96 -0
- package/src/bw/generate.ts +91 -0
- package/src/bw/session.ts +36 -10
- package/src/cli/commands/config.ts +1 -10
- package/src/cli/commands/create.ts +51 -5
- package/src/cli/commands/delete.ts +48 -21
- package/src/cli/commands/edit.ts +53 -5
- package/src/cli/commands/folders.ts +34 -0
- package/src/cli/commands/generate.ts +57 -0
- package/src/cli/commands/get.ts +28 -4
- 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 +30 -1
- package/src/cli/io.ts +12 -2
- package/src/cli/items.ts +52 -8
- package/src/cli/program.ts +6 -0
package/src/bw/fields.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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 { BwItemSchema } from "./types.ts";
|
|
7
|
+
import { BwItemSchema, type BwItem } from "./types.ts";
|
|
8
8
|
|
|
9
9
|
/** Fields `bw get` resolves natively; anything else is a custom field name. */
|
|
10
10
|
const BUILTIN_FIELDS = [
|
|
@@ -16,6 +16,12 @@ const BUILTIN_FIELDS = [
|
|
|
16
16
|
"item",
|
|
17
17
|
] as const;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* `totp` is a computed code, not stored data — only `bw get totp` can derive it,
|
|
21
|
+
* so it is the one field that cannot be served from a fetched item.
|
|
22
|
+
*/
|
|
23
|
+
const DERIVED_FIELDS = new Set(["totp"]);
|
|
24
|
+
|
|
19
25
|
function isBuiltinField(field: string): boolean {
|
|
20
26
|
return (BUILTIN_FIELDS as readonly string[]).includes(field);
|
|
21
27
|
}
|
|
@@ -28,10 +34,23 @@ export interface ReadFieldOptions {
|
|
|
28
34
|
customOnly?: boolean;
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Raised when the item was found but the requested field was not. Distinct from
|
|
39
|
+
* a missing *item* so the "did you mean this item?" suggestions below never fire
|
|
40
|
+
* on an error that already names the fields that do exist.
|
|
41
|
+
*/
|
|
42
|
+
class FieldMissingError extends CliError {}
|
|
43
|
+
|
|
44
|
+
/** One `(field, item)` pair to resolve — see {@link readFields}. */
|
|
45
|
+
export interface FieldRequest {
|
|
46
|
+
field: string;
|
|
47
|
+
item: string;
|
|
48
|
+
options?: ReadFieldOptions;
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Reads one field from a vault item and returns it verbatim (`item` yields the
|
|
33
53
|
* 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
54
|
*/
|
|
36
55
|
export async function readItemField(
|
|
37
56
|
field: string,
|
|
@@ -39,22 +58,132 @@ export async function readItemField(
|
|
|
39
58
|
opts: GlobalOptions,
|
|
40
59
|
options?: ReadFieldOptions,
|
|
41
60
|
): Promise<string> {
|
|
61
|
+
return withLookupHelp(item, opts, () =>
|
|
62
|
+
isBuiltinField(field) && !options?.customOnly
|
|
63
|
+
? readBuiltinField(field, item, opts)
|
|
64
|
+
: readCustomField(field, item, opts),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolves many fields at once, paying a single `bw get item` per distinct item
|
|
70
|
+
* rather than one `bw` spawn per field. Each spawn costs ~2.5s, so a caller
|
|
71
|
+
* pulling a username and password from one item halves its own runtime.
|
|
72
|
+
*
|
|
73
|
+
* Returns values positionally aligned with `requests`.
|
|
74
|
+
*/
|
|
75
|
+
export async function readFields(
|
|
76
|
+
requests: FieldRequest[],
|
|
77
|
+
opts: GlobalOptions,
|
|
78
|
+
): Promise<string[]> {
|
|
79
|
+
const values = new Array<string>(requests.length);
|
|
80
|
+
const byItem = new Map<string, number[]>();
|
|
81
|
+
|
|
82
|
+
for (const [index, request] of requests.entries()) {
|
|
83
|
+
const bucket = byItem.get(request.item);
|
|
84
|
+
if (bucket) bucket.push(index);
|
|
85
|
+
else byItem.set(request.item, [index]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const [item, indexes] of byItem) {
|
|
89
|
+
// A single `bw` process at a time: each read may trigger an unlock, and
|
|
90
|
+
// concurrent unlocks would race over the cached session.
|
|
91
|
+
const derived = indexes.filter((i) => isDerived(requests[i]!));
|
|
92
|
+
const stored = indexes.filter((i) => !isDerived(requests[i]!));
|
|
93
|
+
|
|
94
|
+
if (stored.length > 0) {
|
|
95
|
+
const fetched = await withLookupHelp(item, opts, () =>
|
|
96
|
+
fetchItem(item, opts),
|
|
97
|
+
);
|
|
98
|
+
for (const index of stored) {
|
|
99
|
+
const request = requests[index]!;
|
|
100
|
+
values[index] = extractField(fetched, request.field, request.options);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const index of derived) {
|
|
105
|
+
const request = requests[index]!;
|
|
106
|
+
values[index] = await withLookupHelp(item, opts, () =>
|
|
107
|
+
readBuiltinField(request.field, item, opts),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return values;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isDerived(request: FieldRequest): boolean {
|
|
116
|
+
return !request.options?.customOnly && DERIVED_FIELDS.has(request.field);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Turns bw's bare "Not found." and "More than one result" into errors that name
|
|
121
|
+
* the candidates, so a caller can correct itself without a round of guessing.
|
|
122
|
+
*/
|
|
123
|
+
async function withLookupHelp<T>(
|
|
124
|
+
item: string,
|
|
125
|
+
opts: GlobalOptions,
|
|
126
|
+
fn: () => Promise<T>,
|
|
127
|
+
): Promise<T> {
|
|
42
128
|
try {
|
|
43
|
-
return
|
|
44
|
-
? await readBuiltinField(field, item, opts)
|
|
45
|
-
: await readCustomField(field, item, opts);
|
|
129
|
+
return await fn();
|
|
46
130
|
} catch (err) {
|
|
131
|
+
if (err instanceof FieldMissingError) throw err;
|
|
132
|
+
|
|
47
133
|
if (err instanceof CliError && /more than one result/i.test(err.message)) {
|
|
48
|
-
const matches = await findMatches(item);
|
|
49
134
|
throw new CliError(
|
|
50
|
-
|
|
135
|
+
formatAmbiguous(item, await findMatches(item), opts),
|
|
51
136
|
ExitCode.BadArgs,
|
|
52
137
|
);
|
|
53
138
|
}
|
|
139
|
+
|
|
140
|
+
if (err instanceof CliError && err.exitCode === ExitCode.NotFound) {
|
|
141
|
+
throw new CliError(
|
|
142
|
+
formatNotFound(item, await findSimilar(item), opts),
|
|
143
|
+
ExitCode.NotFound,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
54
147
|
throw err;
|
|
55
148
|
}
|
|
56
149
|
}
|
|
57
150
|
|
|
151
|
+
async function fetchItem(item: string, opts: GlobalOptions): Promise<BwItem> {
|
|
152
|
+
const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
|
|
153
|
+
return BwItemSchema.parse(JSON.parse(json));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Mirrors what `bw get <field>` would have returned for an already-fetched item. */
|
|
157
|
+
function extractField(
|
|
158
|
+
item: BwItem,
|
|
159
|
+
field: string,
|
|
160
|
+
options?: ReadFieldOptions,
|
|
161
|
+
): string {
|
|
162
|
+
if (!isBuiltinField(field) || options?.customOnly) {
|
|
163
|
+
return pickCustomField(item, field);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (field === "item") return JSON.stringify(item);
|
|
167
|
+
|
|
168
|
+
const value =
|
|
169
|
+
field === "notes"
|
|
170
|
+
? item.notes
|
|
171
|
+
: field === "uri"
|
|
172
|
+
? (item.login?.uris?.[0]?.uri ?? null)
|
|
173
|
+
: field === "username"
|
|
174
|
+
? (item.login?.username ?? null)
|
|
175
|
+
: (item.login?.password ?? null);
|
|
176
|
+
|
|
177
|
+
if (!value) {
|
|
178
|
+
throw new FieldMissingError(
|
|
179
|
+
`No ${field} found for item: ${item.name}`,
|
|
180
|
+
ExitCode.NotFound,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
58
187
|
async function readBuiltinField(
|
|
59
188
|
field: string,
|
|
60
189
|
item: string,
|
|
@@ -65,7 +194,7 @@ async function readBuiltinField(
|
|
|
65
194
|
);
|
|
66
195
|
|
|
67
196
|
if (!result) {
|
|
68
|
-
throw new
|
|
197
|
+
throw new FieldMissingError(
|
|
69
198
|
`No ${field} found for item: ${item}`,
|
|
70
199
|
ExitCode.NotFound,
|
|
71
200
|
);
|
|
@@ -79,20 +208,21 @@ async function readCustomField(
|
|
|
79
208
|
item: string,
|
|
80
209
|
opts: GlobalOptions,
|
|
81
210
|
): Promise<string> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
211
|
+
return pickCustomField(await fetchItem(item, opts), fieldName);
|
|
212
|
+
}
|
|
85
213
|
|
|
86
|
-
|
|
87
|
-
const fields =
|
|
214
|
+
function pickCustomField(item: BwItem, fieldName: string): string {
|
|
215
|
+
const fields = item.fields ?? [];
|
|
88
216
|
const match = fields.find((f) => f.name === fieldName);
|
|
89
217
|
|
|
90
218
|
if (!match) {
|
|
91
219
|
const available = fields.map((f) => f.name).join(", ");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
220
|
+
throw new FieldMissingError(
|
|
221
|
+
available
|
|
222
|
+
? `Field "${fieldName}" not found. Available: ${available}`
|
|
223
|
+
: `Field "${fieldName}" not found (item has no custom fields)`,
|
|
224
|
+
ExitCode.NotFound,
|
|
225
|
+
);
|
|
96
226
|
}
|
|
97
227
|
|
|
98
228
|
return match.value ?? "";
|
|
@@ -108,7 +238,95 @@ async function findMatches(query: string): Promise<ItemSummary[]> {
|
|
|
108
238
|
}
|
|
109
239
|
}
|
|
110
240
|
|
|
111
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Looks for what the caller probably meant. bw's own search already came back
|
|
243
|
+
* empty, so this pulls the item list once and scores it locally: one `bw` spawn
|
|
244
|
+
* on the error path, and matching that survives typos and extra words the way
|
|
245
|
+
* repeated `--search` calls do not.
|
|
246
|
+
*/
|
|
247
|
+
async function findSimilar(query: string): Promise<ItemSummary[]> {
|
|
248
|
+
let summaries: ItemSummary[];
|
|
249
|
+
try {
|
|
250
|
+
const json = await runBwOrThrow(["list", "items"]);
|
|
251
|
+
const items: Array<Record<string, unknown>> = JSON.parse(json);
|
|
252
|
+
summaries = items.map(summarizeItem);
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return rankSimilar(query, summaries);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Pure so the suggestion quality is testable without a vault. */
|
|
261
|
+
export function rankSimilar(
|
|
262
|
+
query: string,
|
|
263
|
+
items: ItemSummary[],
|
|
264
|
+
max = MAX_SUGGESTIONS,
|
|
265
|
+
): ItemSummary[] {
|
|
266
|
+
const tokens = tokenize(query);
|
|
267
|
+
if (tokens.length === 0) return [];
|
|
268
|
+
|
|
269
|
+
const scored = items
|
|
270
|
+
.map((item) => ({ item, score: scoreMatch(tokens, item) }))
|
|
271
|
+
.filter((entry) => entry.score > 0)
|
|
272
|
+
.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name));
|
|
273
|
+
|
|
274
|
+
return scored.slice(0, max).map((entry) => entry.item);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Length gates on the fuzzier rules keep short words from matching by accident —
|
|
279
|
+
* without them "not" pulls in every item containing "evernote", and a suggestion
|
|
280
|
+
* list full of noise is worse than none.
|
|
281
|
+
*/
|
|
282
|
+
function scoreMatch(tokens: string[], item: ItemSummary): number {
|
|
283
|
+
const haystack = `${item.name} ${item.username ?? ""}`.toLowerCase();
|
|
284
|
+
const itemTokens = tokenize(haystack);
|
|
285
|
+
|
|
286
|
+
let score = 0;
|
|
287
|
+
for (const token of tokens) {
|
|
288
|
+
if (itemTokens.includes(token)) score += 3;
|
|
289
|
+
else if (token.length >= 4 && haystack.includes(token)) score += 2;
|
|
290
|
+
else if (
|
|
291
|
+
token.length >= 3 &&
|
|
292
|
+
itemTokens.some((other) => other.startsWith(token))
|
|
293
|
+
) {
|
|
294
|
+
score += 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return score;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function tokenize(value: string): string[] {
|
|
302
|
+
return value
|
|
303
|
+
.toLowerCase()
|
|
304
|
+
.split(/[^a-z0-9]+/)
|
|
305
|
+
.filter((token) => token.length >= 2);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const MAX_SUGGESTIONS = 5;
|
|
309
|
+
|
|
310
|
+
function formatNotFound(
|
|
311
|
+
query: string,
|
|
312
|
+
matches: ItemSummary[],
|
|
313
|
+
opts: GlobalOptions,
|
|
314
|
+
): string {
|
|
315
|
+
if (matches.length === 0) {
|
|
316
|
+
return `No item matching "${query}". Run 'bwx search <query>' to find it.`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (opts.json) {
|
|
320
|
+
return `No item matching "${query}". Did you mean: ${JSON.stringify(matches)}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return [
|
|
324
|
+
`No item matching "${query}". Did you mean:\n`,
|
|
325
|
+
...matches.map(formatCandidate),
|
|
326
|
+
].join("\n");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function formatAmbiguous(
|
|
112
330
|
query: string,
|
|
113
331
|
matches: ItemSummary[],
|
|
114
332
|
opts: GlobalOptions,
|
|
@@ -121,13 +339,14 @@ function formatAmbiguousError(
|
|
|
121
339
|
return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
|
|
122
340
|
}
|
|
123
341
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
342
|
+
return [
|
|
343
|
+
`Multiple items match "${query}":\n`,
|
|
344
|
+
...matches.map(formatCandidate),
|
|
345
|
+
`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`,
|
|
346
|
+
].join("\n");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function formatCandidate(match: ItemSummary): string {
|
|
350
|
+
const user = match.username ? ` (${match.username})` : "";
|
|
351
|
+
return ` ${pc.dim(match.id)} ${pc.cyan(match.type)} ${match.name}${pc.dim(user)}`;
|
|
133
352
|
}
|
|
@@ -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/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();
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
loadConfig,
|
|
8
8
|
saveConfig,
|
|
9
9
|
} from "../../config/store.ts";
|
|
10
|
+
import { readLine } from "../input.ts";
|
|
10
11
|
|
|
11
12
|
export function registerConfig(program: Command): void {
|
|
12
13
|
const configCmd = program
|
|
@@ -161,13 +162,3 @@ function redactSensitive(value: unknown): unknown {
|
|
|
161
162
|
function isSensitiveKey(key: string): boolean {
|
|
162
163
|
return SENSITIVE_KEY_RE.some((pattern) => pattern.test(key));
|
|
163
164
|
}
|
|
164
|
-
|
|
165
|
-
async function readLine(): Promise<string> {
|
|
166
|
-
const chunks: Uint8Array[] = [];
|
|
167
|
-
for await (const chunk of Bun.stdin.stream()) {
|
|
168
|
-
chunks.push(chunk);
|
|
169
|
-
const text = Buffer.concat(chunks).toString();
|
|
170
|
-
if (text.includes("\n")) return text.split("\n")[0]!.trim();
|
|
171
|
-
}
|
|
172
|
-
return Buffer.concat(chunks).toString().trim();
|
|
173
|
-
}
|