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/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 { BwItemSchema } from "./types.ts";
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
- try {
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
- ? await readBuiltinField(field, item, opts)
45
- : await readCustomField(field, item, opts);
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
- formatAmbiguousError(item, matches, opts),
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 CliError(
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
- const json = await withSession(opts, () =>
83
- runBwOrThrow(["get", "item", item]),
84
- );
291
+ return pickCustomField(await fetchItem(item, opts), fieldName);
292
+ }
85
293
 
86
- const parsed = BwItemSchema.parse(JSON.parse(json));
87
- const fields = parsed.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
- 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);
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
- function formatAmbiguousError(
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
- 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");
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
- // 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();