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/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 isBuiltinField(field) && !options?.customOnly
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
- formatAmbiguousError(item, matches, opts),
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 CliError(
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
- const json = await withSession(opts, () =>
83
- runBwOrThrow(["get", "item", item]),
84
- );
211
+ return pickCustomField(await fetchItem(item, opts), fieldName);
212
+ }
85
213
 
86
- const parsed = BwItemSchema.parse(JSON.parse(json));
87
- const fields = parsed.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
- const msg = available
93
- ? `Field "${fieldName}" not found. Available: ${available}`
94
- : `Field "${fieldName}" not found (item has no custom fields)`;
95
- throw new CliError(msg, ExitCode.NotFound);
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
- function formatAmbiguousError(
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
- const lines = [`Multiple items match "${query}":\n`];
125
- for (const m of matches) {
126
- const user = m.username ? ` (${m.username})` : "";
127
- lines.push(
128
- ` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`,
129
- );
130
- }
131
- lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
132
- return lines.join("\n");
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
- // Quick check — try a lightweight command
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
- }