bwx 0.6.0 → 0.6.3

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 CHANGED
@@ -17,7 +17,7 @@ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, sessio
17
17
  The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
18
18
 
19
19
  - **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
20
- - **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`, passed to `bw` through `BW_SESSION` so it never appears in a process command line
20
+ - **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`; concurrent callers share one bounded login/unlock attempt, and the token reaches `bw` through `BW_SESSION` rather than a process command line
21
21
  - **Structured output** — `--json` and `--plain` flags for machine-parseable output
22
22
  - **Leak-resistant** — listings never print secrets, `bwx run` hands them to a child process instead of stdout
23
23
  - **No interaction** — credentials from config + Keychain, never prompts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.6.0",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
package/src/bw/fields.ts CHANGED
@@ -67,7 +67,8 @@ export async function readItemField(
67
67
  ): Promise<string> {
68
68
  return withLookupHelp(item, opts, async () => {
69
69
  if (isTotpField(field, options)) {
70
- return (await resolveTotp(await fetchItem(item, opts), item, opts)).code;
70
+ const nextCode = resolveTotpSource(await fetchItem(item, opts), item, opts);
71
+ return (await nextCode()).code;
71
72
  }
72
73
 
73
74
  return isBuiltinField(field) && !options?.customOnly
@@ -76,18 +77,31 @@ export async function readItemField(
76
77
  });
77
78
  }
78
79
 
80
+ export interface ReadTotpOptions {
81
+ /**
82
+ * Wait for the next window when the code has fewer than this many seconds
83
+ * left, rather than handing back one that expires on the way to the prompt.
84
+ */
85
+ minSecondsRemaining?: number;
86
+ /** Notified with the seconds about to be spent waiting, for progress output. */
87
+ onWait?: (seconds: number) => void;
88
+ }
89
+
79
90
  /**
80
91
  * 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.
92
+ * on it is usually dead before it is pasted, so callers get the remainder to
93
+ * decide for themselves — or set `minSecondsRemaining` and have the wait handled
94
+ * here, where the secret is still in hand and a fresh code is free.
83
95
  */
84
96
  export async function readTotp(
85
97
  item: string,
86
98
  opts: GlobalOptions,
99
+ options?: ReadTotpOptions,
87
100
  ): Promise<TotpCode> {
88
- return withLookupHelp(item, opts, async () =>
89
- resolveTotp(await fetchItem(item, opts), item, opts),
90
- );
101
+ return withLookupHelp(item, opts, async () => {
102
+ const nextCode = resolveTotpSource(await fetchItem(item, opts), item, opts);
103
+ return awaitUsableWindow(nextCode, options);
104
+ });
91
105
  }
92
106
 
93
107
  /** The stored secret itself, for moving a seed back out to another authenticator. */
@@ -113,29 +127,58 @@ function requireStoredTotp(item: BwItem): string {
113
127
  }
114
128
 
115
129
  /**
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.
130
+ * Produces a code for the current window. Callable rather than a single resolved
131
+ * value so waiting out a nearly-dead window is cheap: the local path just runs
132
+ * the HMAC again over the secret it captured.
120
133
  */
121
- async function resolveTotp(
134
+ type TotpSource = () => Promise<TotpCode>;
135
+
136
+ /**
137
+ * Decides how this item's codes are computed: locally from the secret already in
138
+ * hand, or by `bw get totp` for the shapes this codebase will not compute itself
139
+ * (see `totp.ts`). The fallback costs a spawn per code, which is the correct
140
+ * trade against emitting a confidently wrong six digits.
141
+ */
142
+ function resolveTotpSource(
122
143
  fetched: BwItem,
123
144
  item: string,
124
145
  opts: GlobalOptions,
125
- ): Promise<TotpCode> {
146
+ ): TotpSource {
126
147
  const stored = requireStoredTotp(fetched);
127
148
 
128
149
  try {
129
- return generateTotp(parseTotpSecret(stored));
150
+ const config = parseTotpSecret(stored);
151
+ return async () => generateTotp(config);
130
152
  } catch (err) {
131
153
  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
154
  }
155
+
156
+ return async () => ({
157
+ code: await readBuiltinField("totp", item, opts),
158
+ period: DEFAULT_PERIOD,
159
+ secondsRemaining: secondsRemaining(DEFAULT_PERIOD),
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Sleeps out a window with too little left, then asks the source for the next
165
+ * code. Only the `bw` fallback pays a second spawn for that; the local path
166
+ * recomputes from the secret it holds, so the returned code has a full window
167
+ * rather than one already spent on a second vault round-trip.
168
+ */
169
+ async function awaitUsableWindow(
170
+ nextCode: TotpSource,
171
+ options?: ReadTotpOptions,
172
+ ): Promise<TotpCode> {
173
+ const code = await nextCode();
174
+
175
+ const minimum = options?.minSecondsRemaining;
176
+ if (minimum === undefined || code.secondsRemaining >= minimum) return code;
177
+
178
+ options?.onWait?.(code.secondsRemaining);
179
+ // The extra beat lands just past the boundary rather than exactly on it.
180
+ await Bun.sleep(code.secondsRemaining * 1000 + 250);
181
+ return nextCode();
139
182
  }
140
183
 
141
184
  /**
@@ -169,10 +212,8 @@ export async function readFields(
169
212
  const request = requests[index]!;
170
213
 
171
214
  if (isTotpField(request.field, request.options)) {
172
- const totp = await withLookupHelp(item, opts, () =>
173
- resolveTotp(fetched, item, opts),
174
- );
175
- values[index] = totp.code;
215
+ const nextCode = resolveTotpSource(fetched, item, opts);
216
+ values[index] = (await withLookupHelp(item, opts, nextCode)).code;
176
217
  } else {
177
218
  values[index] = extractField(fetched, request.field, request.options);
178
219
  }
package/src/bw/session.ts CHANGED
@@ -2,9 +2,16 @@ import { unlinkSync } from "node:fs";
2
2
  import { CliError, ExitCode } from "../cli/errors.ts";
3
3
  import type { GlobalOptions } from "../cli/types.ts";
4
4
  import { emitLog, emitWarn } from "../cli/io.ts";
5
- import { SESSION_FILE } from "../config/paths.ts";
5
+ import { SESSION_FILE, SESSION_LOCK_DIR } from "../config/paths.ts";
6
+ import { withProcessLock } from "../config/process-lock.ts";
6
7
  import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
7
- import { runBw, runBwOrThrow, setSession, getSession } from "./runner.ts";
8
+ import {
9
+ DEFAULT_TIMEOUT_MS,
10
+ runBw,
11
+ runBwOrThrow,
12
+ setSession,
13
+ getSession,
14
+ } from "./runner.ts";
8
15
  import { checkVaultFreshness } from "./freshness.ts";
9
16
  import { getStatus } from "./status.ts";
10
17
  import { BwStatusSchema } from "./types.ts";
@@ -96,13 +103,38 @@ export async function probeSessionState(
96
103
  }
97
104
 
98
105
  export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
99
- // Try cached session first
106
+ // Keep the ordinary valid-session path entirely outside the process lock.
100
107
  const cached = getSession() || loadCachedSession(opts);
101
108
  if (cached) {
102
109
  setSession(cached);
103
110
  if (await sessionWorks(cached)) return;
111
+ setSession(null);
104
112
  }
105
113
 
114
+ await withProcessLock(
115
+ {
116
+ path: SESSION_LOCK_DIR,
117
+ label: "Bitwarden session establishment",
118
+ waitMs: sessionLockWaitMs(opts),
119
+ },
120
+ async () => {
121
+ // A process that waited must use the winner's disk cache, not the stale
122
+ // token it may still have held in memory before entering the lock. An
123
+ // unchanged cache needs no second `bw status`: we just watched that exact
124
+ // token be refused, and nothing has replaced it.
125
+ const winner = loadCachedSession(opts);
126
+ if (winner && winner !== cached) {
127
+ setSession(winner);
128
+ if (await sessionWorks(winner)) return;
129
+ setSession(null);
130
+ }
131
+
132
+ await establishSession(opts);
133
+ },
134
+ );
135
+ }
136
+
137
+ async function establishSession(opts: GlobalOptions): Promise<void> {
106
138
  const status = await getStatus();
107
139
 
108
140
  if (status.status === "unlocked") {
@@ -152,6 +184,13 @@ export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
152
184
  saveCachedSession(token);
153
185
  }
154
186
 
187
+ /** Four sequential `bw` calls cover stale validation plus status/login/unlock. */
188
+ function sessionLockWaitMs(opts: GlobalOptions): number {
189
+ const perCall =
190
+ opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
191
+ return Math.min(Math.max(perCall * 4 + 10_000, 10_000), 5 * 60_000);
192
+ }
193
+
155
194
  export async function lockVault(): Promise<void> {
156
195
  await runBw(["lock"], { session: null });
157
196
  setSession(null);
@@ -197,9 +236,9 @@ async function withAuthRetry<T>(
197
236
  return await fn();
198
237
  } catch (err) {
199
238
  if (err instanceof CliError && err.exitCode === ExitCode.AuthFailed) {
200
- // Clear stale session and re-auth
239
+ // Do not unlink the shared cache here: another process may already have
240
+ // atomically replaced our stale token with a freshly unlocked session.
201
241
  setSession(null);
202
- clearCachedSession();
203
242
  await ensureUnlocked(opts);
204
243
  return await fn();
205
244
  }
package/src/bw/totp.ts CHANGED
@@ -81,6 +81,12 @@ export function parseTotpSecret(raw: string): TotpConfig {
81
81
  throw new TotpUnsupportedError("secret is not base32");
82
82
  }
83
83
 
84
+ // A returned config must be computable, so that generateTotp cannot fail on
85
+ // one and strand a caller that has already chosen the local path over the bw
86
+ // fallback. Base32 alone does not promise that: a single character carries
87
+ // five bits and decodes to no whole byte.
88
+ base32Decode(config.secret);
89
+
84
90
  return config;
85
91
  }
86
92
 
@@ -11,7 +11,9 @@ import {
11
11
  assertSingleStdinSource,
12
12
  collect,
13
13
  listExplicitStdinInputs,
14
+ loginOnlyTextInputFlags,
14
15
  readOptionalStdin,
16
+ registerItemTextOptions,
15
17
  resolveFieldSources,
16
18
  resolveItemTextInputs,
17
19
  StdinReader,
@@ -36,36 +38,18 @@ interface CreateOptions extends ItemTextInputs {
36
38
  /** Flags a secure note has nowhere to store — see {@link assertLoginFlags}. */
37
39
  const LOGIN_ONLY_FLAGS: Array<[keyof CreateOptions, string]> = [
38
40
  ["username", "--username"],
39
- ["password", "--password"],
40
- ["passwordStdin", "--password-stdin"],
41
- ["passwordFile", "--password-file"],
42
- ["passwordEnv", "--password-env"],
43
- ["passwordGenerate", "--password-generate"],
44
- ["totp", "--totp"],
45
- ["totpStdin", "--totp-stdin"],
46
- ["totpFile", "--totp-file"],
47
- ["totpEnv", "--totp-env"],
41
+ ...loginOnlyTextInputFlags(),
48
42
  ];
49
43
 
50
44
  export function registerCreate(program: Command): void {
51
- program
45
+ const command = program
52
46
  .command("create").alias("add")
53
47
  .description("Create a new vault item")
54
48
  .option("--type <type>", "Item type: login|note", "note")
55
49
  .option("--name <name>", "Item name")
56
- .option("--notes <text>", "Notes content")
57
- .option("--notes-file <path>", "Read notes from file (use - for stdin)")
58
- .option("--username <user>", "Username (login type)")
59
- .option("--password <pass>", "Password (login type)")
60
- .option("--password-stdin", "Read password from stdin")
61
- .option("--password-file <path>", "Read password from file (use - for stdin)")
62
- .option("--password-env <name>", "Read password from environment variable")
63
- .option("--password-generate", "Generate the password; it is never printed")
64
- .option("--generate-length <n>", "Length for --password-generate", parseInt)
65
- .option("--totp <secret>", "TOTP secret: base32 or otpauth:// URI (login type)")
66
- .option("--totp-stdin", "Read the TOTP secret from stdin")
67
- .option("--totp-file <path>", "Read the TOTP secret from file (use - for stdin)")
68
- .option("--totp-env <name>", "Read the TOTP secret from environment variable")
50
+ .option("--username <user>", "Username (login items only)");
51
+
52
+ registerItemTextOptions(command)
69
53
  .option("--uri <url>", "URI (repeatable)", collect, [])
70
54
  .option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
71
55
  .option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
@@ -12,9 +12,13 @@ import {
12
12
  assertSingleStdinSource,
13
13
  collect,
14
14
  listExplicitStdinInputs,
15
+ listUsedSourceFlags,
16
+ loginOnlyTextInputFlags,
17
+ registerItemTextOptions,
15
18
  resolveFieldSources,
16
19
  resolveItemTextInputs,
17
20
  StdinReader,
21
+ TOTP_FAMILY,
18
22
  type ItemTextInputs,
19
23
  } from "../input.ts";
20
24
  import { redactGeneratedPassword } from "./generate.ts";
@@ -36,37 +40,19 @@ interface EditOptions extends ItemTextInputs {
36
40
 
37
41
  const LOGIN_ONLY_FLAGS: Array<[keyof EditOptions, string]> = [
38
42
  ["username", "--username"],
39
- ["password", "--password"],
40
- ["passwordStdin", "--password-stdin"],
41
- ["passwordFile", "--password-file"],
42
- ["passwordEnv", "--password-env"],
43
- ["passwordGenerate", "--password-generate"],
44
- ["totp", "--totp"],
45
- ["totpStdin", "--totp-stdin"],
46
- ["totpFile", "--totp-file"],
47
- ["totpEnv", "--totp-env"],
43
+ ...loginOnlyTextInputFlags(),
48
44
  ["rmTotp", "--rm-totp"],
49
45
  ];
50
46
 
51
47
  export function registerEdit(program: Command): void {
52
- program
48
+ const command = program
53
49
  .command("edit")
54
50
  .description("Edit an existing vault item")
55
51
  .argument("<item>", "Item name or ID")
56
52
  .option("--name <name>", "New name")
57
- .option("--notes <text>", "New notes")
58
- .option("--notes-file <path>", "Read notes from file (use - for stdin)")
59
- .option("--username <user>", "New username")
60
- .option("--password <pass>", "New password")
61
- .option("--password-stdin", "Read password from stdin")
62
- .option("--password-file <path>", "Read password from file (use - for stdin)")
63
- .option("--password-env <name>", "Read password from environment variable")
64
- .option("--password-generate", "Generate the password; it is never printed")
65
- .option("--generate-length <n>", "Length for --password-generate", parseInt)
66
- .option("--totp <secret>", "Set TOTP secret: base32 or otpauth:// URI")
67
- .option("--totp-stdin", "Read the TOTP secret from stdin")
68
- .option("--totp-file <path>", "Read the TOTP secret from file (use - for stdin)")
69
- .option("--totp-env <name>", "Read the TOTP secret from environment variable")
53
+ .option("--username <user>", "New username");
54
+
55
+ registerItemTextOptions(command)
70
56
  .option("--rm-totp", "Remove the stored TOTP secret")
71
57
  .option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
72
58
  .option("--add-field <kv>", "Add/update field k=v", collect, [])
@@ -165,12 +151,7 @@ export function registerEdit(program: Command): void {
165
151
  function assertTotpIntent(localOpts: EditOptions): void {
166
152
  if (!localOpts.rmTotp) return;
167
153
 
168
- const setters = [
169
- localOpts.totp !== undefined ? "--totp" : null,
170
- localOpts.totpStdin ? "--totp-stdin" : null,
171
- localOpts.totpFile !== undefined ? "--totp-file" : null,
172
- localOpts.totpEnv !== undefined ? "--totp-env" : null,
173
- ].filter((flag): flag is string => flag !== null);
154
+ const setters = listUsedSourceFlags(TOTP_FAMILY, localOpts);
174
155
 
175
156
  if (setters.length > 0) {
176
157
  throw new CliError(
@@ -80,15 +80,11 @@ async function emitTotp(
80
80
  return;
81
81
  }
82
82
 
83
- const threshold = parseFreshSeconds(localOpts.fresh);
84
- let totp = await readTotp(item, opts);
85
-
86
- if (threshold !== null && totp.secondsRemaining < threshold) {
87
- const wait = totp.secondsRemaining;
88
- emitLog(`Waiting ${wait}s for a fresh code…`, opts);
89
- await Bun.sleep(wait * 1000 + 250);
90
- totp = await readTotp(item, opts);
91
- }
83
+ const totp = await readTotp(item, opts, {
84
+ minSecondsRemaining: parseFreshSeconds(localOpts.fresh),
85
+ onWait: (seconds) =>
86
+ emitLog(`Waiting ${seconds}s for a fresh code…`, opts),
87
+ });
92
88
 
93
89
  emitLog(`Valid for ${totp.secondsRemaining}s`, opts);
94
90
  emitData(totp.code, opts, {
@@ -101,8 +97,8 @@ async function emitTotp(
101
97
  * `--fresh` is a bare flag by default and takes an explicit threshold when the
102
98
  * caller needs longer than {@link DEFAULT_FRESH_SECONDS} to use the code.
103
99
  */
104
- function parseFreshSeconds(raw: string | boolean | undefined): number | null {
105
- if (raw === undefined || raw === false) return null;
100
+ function parseFreshSeconds(raw: string | boolean | undefined): number | undefined {
101
+ if (raw === undefined || raw === false) return undefined;
106
102
  if (raw === true) return DEFAULT_FRESH_SECONDS;
107
103
 
108
104
  const value = Number(raw.trim());
package/src/cli/input.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Command } from "commander";
1
2
  import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
2
3
  import { normalizeTotpInput } from "../bw/totp.ts";
3
4
  import { CliError, ExitCode } from "./errors.ts";
@@ -80,15 +81,176 @@ export interface ExplicitStdinInputs extends ItemTextInputs {
80
81
  fieldFiles?: string[];
81
82
  }
82
83
 
83
- const NOTE_SOURCE_FLAGS = ["--notes", "--notes-file"];
84
- const PASSWORD_SOURCE_FLAGS = [
85
- "--password",
86
- "--password-stdin",
87
- "--password-file",
88
- "--password-env",
89
- "--password-generate",
90
- ];
91
- const TOTP_SOURCE_FLAGS = ["--totp", "--totp-stdin", "--totp-file", "--totp-env"];
84
+ /** Which slot of a {@link TextSource} a flag fills. */
85
+ type TextSourceKind = "inline" | "stdin" | "file" | "env" | "generate";
86
+
87
+ interface TextInputFlag {
88
+ /** Commander option spec, e.g. `--password-file <path>`. */
89
+ spec: string;
90
+ description: string;
91
+ /** The property commander derives from `spec`. */
92
+ key: keyof ItemTextInputs;
93
+ kind: TextSourceKind;
94
+ }
95
+
96
+ /**
97
+ * One value a caller can supply, and every flag that can supply it. Option
98
+ * registration, the "choose only one source" error, the stdin-conflict check and
99
+ * the login-only guard all read from here, so adding a source touches one table
100
+ * rather than both write commands and three hand-kept lists.
101
+ */
102
+ export interface TextInputFamily {
103
+ /** Names the value in error messages. */
104
+ label: string;
105
+ flags: TextInputFlag[];
106
+ /** True when only a login item has anywhere to store the value. */
107
+ loginOnly: boolean;
108
+ /** Whether empty is a legitimate value — it is, for notes. */
109
+ allowEmpty?: boolean;
110
+ }
111
+
112
+ const NOTES_FAMILY: TextInputFamily = {
113
+ label: "notes",
114
+ loginOnly: false,
115
+ allowEmpty: true,
116
+ flags: [
117
+ {
118
+ spec: "--notes <text>",
119
+ description: "Notes content",
120
+ key: "notes",
121
+ kind: "inline",
122
+ },
123
+ {
124
+ spec: "--notes-file <path>",
125
+ description: "Read notes from file (use - for stdin)",
126
+ key: "notesFile",
127
+ kind: "file",
128
+ },
129
+ ],
130
+ };
131
+
132
+ const PASSWORD_FAMILY: TextInputFamily = {
133
+ label: "password",
134
+ loginOnly: true,
135
+ flags: [
136
+ {
137
+ spec: "--password <pass>",
138
+ description: "Password (login items only)",
139
+ key: "password",
140
+ kind: "inline",
141
+ },
142
+ {
143
+ spec: "--password-stdin",
144
+ description: "Read password from stdin",
145
+ key: "passwordStdin",
146
+ kind: "stdin",
147
+ },
148
+ {
149
+ spec: "--password-file <path>",
150
+ description: "Read password from file (use - for stdin)",
151
+ key: "passwordFile",
152
+ kind: "file",
153
+ },
154
+ {
155
+ spec: "--password-env <name>",
156
+ description: "Read password from environment variable",
157
+ key: "passwordEnv",
158
+ kind: "env",
159
+ },
160
+ {
161
+ spec: "--password-generate",
162
+ description: "Generate the password; it is never printed",
163
+ key: "passwordGenerate",
164
+ kind: "generate",
165
+ },
166
+ ],
167
+ };
168
+
169
+ export const TOTP_FAMILY: TextInputFamily = {
170
+ label: "TOTP secret",
171
+ loginOnly: true,
172
+ flags: [
173
+ {
174
+ spec: "--totp <secret>",
175
+ description: "TOTP secret: base32 or otpauth:// URI (login items only)",
176
+ key: "totp",
177
+ kind: "inline",
178
+ },
179
+ {
180
+ spec: "--totp-stdin",
181
+ description: "Read the TOTP secret from stdin",
182
+ key: "totpStdin",
183
+ kind: "stdin",
184
+ },
185
+ {
186
+ spec: "--totp-file <path>",
187
+ description: "Read the TOTP secret from file (use - for stdin)",
188
+ key: "totpFile",
189
+ kind: "file",
190
+ },
191
+ {
192
+ spec: "--totp-env <name>",
193
+ description: "Read the TOTP secret from environment variable",
194
+ key: "totpEnv",
195
+ kind: "env",
196
+ },
197
+ ],
198
+ };
199
+
200
+ const ITEM_TEXT_FAMILIES = [NOTES_FAMILY, PASSWORD_FAMILY, TOTP_FAMILY];
201
+
202
+ function flagName(spec: string): string {
203
+ return spec.split(" ")[0]!;
204
+ }
205
+
206
+ function sourceFlagNames(family: TextInputFamily): string[] {
207
+ return family.flags.map((flag) => flagName(flag.spec));
208
+ }
209
+
210
+ /**
211
+ * Registers every flag that can supply notes, a password, or a TOTP secret, plus
212
+ * the length modifier `--password-generate` takes. `create` and `edit` accept the
213
+ * identical set and {@link resolveItemTextInputs} is their only reader, so the
214
+ * set is declared once here instead of per command.
215
+ */
216
+ export function registerItemTextOptions(command: Command): Command {
217
+ for (const family of ITEM_TEXT_FAMILIES) {
218
+ for (const flag of family.flags) {
219
+ command.option(flag.spec, flag.description);
220
+ }
221
+ }
222
+
223
+ return command.option(
224
+ "--generate-length <n>",
225
+ "Length for --password-generate",
226
+ (value) => Number.parseInt(value, 10),
227
+ );
228
+ }
229
+
230
+ /**
231
+ * The `[key, flag]` pairs a command must reject on a non-login item, because
232
+ * `buildNewItem` and `patchItem` have nowhere to put them and would accept the
233
+ * value only to drop it.
234
+ */
235
+ export function loginOnlyTextInputFlags(): Array<[keyof ItemTextInputs, string]> {
236
+ return ITEM_TEXT_FAMILIES.filter((family) => family.loginOnly).flatMap(
237
+ (family) =>
238
+ family.flags.map(
239
+ (flag) =>
240
+ [flag.key, flagName(flag.spec)] as [keyof ItemTextInputs, string],
241
+ ),
242
+ );
243
+ }
244
+
245
+ /** The flags a caller actually used to supply this family's value. */
246
+ export function listUsedSourceFlags(
247
+ family: TextInputFamily,
248
+ inputs: ItemTextInputs,
249
+ ): string[] {
250
+ return family.flags
251
+ .filter((flag) => inputs[flag.key] !== undefined)
252
+ .map((flag) => flagName(flag.spec));
253
+ }
92
254
 
93
255
  export async function resolveItemTextInputs(
94
256
  inputs: ItemTextInputs,
@@ -100,17 +262,17 @@ export async function resolveItemTextInputs(
100
262
  const [notes, password, totp] = await Promise.all([
101
263
  resolveOptionalTextSource(
102
264
  {
103
- label: "notes",
265
+ label: NOTES_FAMILY.label,
104
266
  inline: inputs.notes,
105
267
  file: inputs.notesFile,
106
- flagNames: NOTE_SOURCE_FLAGS,
107
- allowEmpty: true,
268
+ flagNames: sourceFlagNames(NOTES_FAMILY),
269
+ allowEmpty: NOTES_FAMILY.allowEmpty,
108
270
  },
109
271
  stdin,
110
272
  ),
111
273
  resolveOptionalTextSource(
112
274
  {
113
- label: "password",
275
+ label: PASSWORD_FAMILY.label,
114
276
  inline: inputs.password,
115
277
  stdin: inputs.passwordStdin,
116
278
  file: inputs.passwordFile,
@@ -119,18 +281,18 @@ export async function resolveItemTextInputs(
119
281
  inputs.passwordGenerate && generatePassword
120
282
  ? generatePassword
121
283
  : undefined,
122
- flagNames: PASSWORD_SOURCE_FLAGS,
284
+ flagNames: sourceFlagNames(PASSWORD_FAMILY),
123
285
  },
124
286
  stdin,
125
287
  ),
126
288
  resolveOptionalTextSource(
127
289
  {
128
- label: "TOTP secret",
290
+ label: TOTP_FAMILY.label,
129
291
  inline: inputs.totp,
130
292
  stdin: inputs.totpStdin,
131
293
  file: inputs.totpFile,
132
294
  env: inputs.totpEnv,
133
- flagNames: TOTP_SOURCE_FLAGS,
295
+ flagNames: sourceFlagNames(TOTP_FAMILY),
134
296
  },
135
297
  stdin,
136
298
  ),
@@ -147,16 +309,26 @@ export async function resolveItemTextInputs(
147
309
  }
148
310
 
149
311
  export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
150
- return [
151
- inputs.notesFile === "-" ? "--notes-file -" : null,
152
- inputs.passwordStdin ? "--password-stdin" : null,
153
- inputs.passwordFile === "-" ? "--password-file -" : null,
154
- inputs.totpStdin ? "--totp-stdin" : null,
155
- inputs.totpFile === "-" ? "--totp-file -" : null,
156
- ...(inputs.fieldFileFlag && inputs.fieldFiles
157
- ? stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles)
158
- : []),
159
- ].filter((value): value is string => value !== null);
312
+ const sources: string[] = [];
313
+
314
+ for (const family of ITEM_TEXT_FAMILIES) {
315
+ for (const flag of family.flags) {
316
+ const name = flagName(flag.spec);
317
+ if (flag.kind === "stdin" && inputs[flag.key]) {
318
+ sources.push(name);
319
+ } else if (flag.kind === "file" && inputs[flag.key] === "-") {
320
+ sources.push(`${name} -`);
321
+ }
322
+ }
323
+ }
324
+
325
+ if (inputs.fieldFileFlag && inputs.fieldFiles) {
326
+ sources.push(
327
+ ...stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles),
328
+ );
329
+ }
330
+
331
+ return sources;
160
332
  }
161
333
 
162
334
  export async function resolveOptionalTextSource(
@@ -8,5 +8,7 @@ export const CONFIG_DIR = `${CONFIG_HOME}/bwx`;
8
8
  export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
9
9
  /** Cached `bw` session token (owner-only). */
10
10
  export const SESSION_FILE = `${CONFIG_DIR}/session`;
11
+ /** Cross-process gate used only while establishing a session. */
12
+ export const SESSION_LOCK_DIR = `${CONFIG_DIR}/session.lock`;
11
13
  /** Cached vault-freshness snapshot — see bw/freshness.ts. */
12
14
  export const STATE_FILE = `${CONFIG_DIR}/state.json`;
@@ -0,0 +1,287 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ renameSync,
7
+ rmSync,
8
+ } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { CliError, ExitCode } from "../cli/errors.ts";
11
+ import { formatDuration } from "../cli/duration.ts";
12
+ import { readPrivateFile, writePrivateFile } from "./secure-file.ts";
13
+
14
+ const OWNER_FILE = "owner.json";
15
+ const DEFAULT_POLL_MS = 50;
16
+ const SHARED_BITS = 0o077;
17
+ const UUID_RE =
18
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
19
+
20
+ interface LockOwner {
21
+ pid: number;
22
+ token: string;
23
+ acquiredAt: string;
24
+ }
25
+
26
+ interface ProcessLockOptions {
27
+ path: string;
28
+ label: string;
29
+ waitMs: number;
30
+ pollMs?: number;
31
+ }
32
+
33
+ interface Ownership {
34
+ path: string;
35
+ owner: LockOwner;
36
+ }
37
+
38
+ /**
39
+ * Runs a critical section under a cross-process lock. Publishing a populated
40
+ * directory by rename keeps contenders from observing a half-created lock;
41
+ * live owners are waited on, while dead-owner generations are safely detached.
42
+ */
43
+ export async function withProcessLock<T>(
44
+ options: ProcessLockOptions,
45
+ fn: () => Promise<T>,
46
+ ): Promise<T> {
47
+ const ownership = await acquire(options);
48
+ let bodyFailed = false;
49
+
50
+ try {
51
+ return await fn();
52
+ } catch (err) {
53
+ bodyFailed = true;
54
+ throw err;
55
+ } finally {
56
+ try {
57
+ release(ownership, options.label);
58
+ } catch (err) {
59
+ // Preserve the operation's useful error if cleanup also failed.
60
+ if (!bodyFailed) throw err;
61
+ }
62
+ }
63
+ }
64
+
65
+ async function acquire(options: ProcessLockOptions): Promise<Ownership> {
66
+ const startedAt = performance.now();
67
+
68
+ while (true) {
69
+ const ownership = tryCreate(options.path);
70
+ if (ownership) return ownership;
71
+
72
+ const holder = readOwner(options.path, options.label);
73
+
74
+ if (holder && !isProcessAlive(holder.pid)) {
75
+ if (detachAbandonedLock(options.path, holder, options.label)) continue;
76
+ }
77
+
78
+ // The deadline is checked even when the lock turned out to be free, so a
79
+ // peer that keeps taking and releasing it cannot spin us indefinitely.
80
+ const elapsed = performance.now() - startedAt;
81
+ if (elapsed >= options.waitMs) throw timedOut(options, holder);
82
+
83
+ // A lock that vanished mid-inspection is free: contend for it again at once
84
+ // rather than sleeping on a holder that is already gone.
85
+ if (!holder) continue;
86
+
87
+ const remaining = options.waitMs - elapsed;
88
+ await Bun.sleep(Math.min(options.pollMs ?? DEFAULT_POLL_MS, remaining));
89
+ }
90
+ }
91
+
92
+ function timedOut(
93
+ options: ProcessLockOptions,
94
+ holder: LockOwner | null,
95
+ ): CliError {
96
+ const held = holder ? ` held by pid ${holder.pid}` : "";
97
+ return new CliError(
98
+ `Timed out after ${formatDuration(options.waitMs)} waiting for ${options.label}${held}`,
99
+ ExitCode.Timeout,
100
+ );
101
+ }
102
+
103
+ function tryCreate(path: string): Ownership | null {
104
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
105
+
106
+ const owner: LockOwner = {
107
+ pid: process.pid,
108
+ token: randomUUID(),
109
+ acquiredAt: new Date().toISOString(),
110
+ };
111
+ const candidate = `${path}.${owner.token}.tmp`;
112
+
113
+ try {
114
+ mkdirSync(candidate, { mode: 0o700 });
115
+ chmodSync(candidate, 0o700);
116
+ writePrivateFile(
117
+ `${candidate}/${OWNER_FILE}`,
118
+ JSON.stringify(owner) + "\n",
119
+ );
120
+ try {
121
+ renameSync(candidate, path);
122
+ return { path, owner };
123
+ } catch (err) {
124
+ if (pathExists(path)) return null;
125
+ throw err;
126
+ }
127
+ } finally {
128
+ if (pathExists(candidate)) {
129
+ rmSync(candidate, { recursive: true, force: true });
130
+ }
131
+ }
132
+ }
133
+
134
+ function readOwner(path: string, label: string): LockOwner | null {
135
+ let stat;
136
+ try {
137
+ stat = lstatSync(path);
138
+ } catch (err) {
139
+ if (errorCode(err) === "ENOENT") return null;
140
+ throw err;
141
+ }
142
+
143
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
144
+ throw invalidLock(label, `${path} is not a regular directory`);
145
+ }
146
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
147
+ throw invalidLock(label, `${path} is owned by uid ${stat.uid}`);
148
+ }
149
+ if (stat.mode & SHARED_BITS) {
150
+ const mode = (stat.mode & 0o777).toString(8).padStart(3, "0");
151
+ throw invalidLock(label, `${path} has mode ${mode}, expected 700`);
152
+ }
153
+
154
+ const read = readPrivateFile(`${path}/${OWNER_FILE}`);
155
+ if (read.kind !== "ok") {
156
+ // A holder publishes `owner.json` before the directory is renamed into
157
+ // place, and lets go by renaming the whole directory away. So an owner file
158
+ // that would not read, under a directory that is no longer the one just
159
+ // inspected, means the holder released mid-read — the caller should contend
160
+ // again rather than report a corrupt lock. Only an unreadable owner file in
161
+ // the *same* directory is genuinely wrong.
162
+ if (inodeAt(path) !== stat.ino) return null;
163
+
164
+ const reason =
165
+ read.kind === "missing"
166
+ ? `${path}/${OWNER_FILE} is missing`
167
+ : read.reason;
168
+ throw invalidLock(label, reason);
169
+ }
170
+
171
+ try {
172
+ const owner = JSON.parse(read.text) as Partial<LockOwner>;
173
+ if (
174
+ !Number.isSafeInteger(owner.pid) ||
175
+ (owner.pid ?? 0) <= 0 ||
176
+ typeof owner.token !== "string" ||
177
+ !UUID_RE.test(owner.token) ||
178
+ typeof owner.acquiredAt !== "string" ||
179
+ !Number.isFinite(Date.parse(owner.acquiredAt))
180
+ ) {
181
+ throw new Error("invalid owner fields");
182
+ }
183
+ return owner as LockOwner;
184
+ } catch {
185
+ throw invalidLock(label, `${path}/${OWNER_FILE} is malformed`);
186
+ }
187
+ }
188
+
189
+ function detachAbandonedLock(
190
+ path: string,
191
+ expected: LockOwner,
192
+ label: string,
193
+ ): boolean {
194
+ // The generation-specific recovery lock prevents two stale observations from
195
+ // detaching a successor that appeared between inspection and rename.
196
+ const recoveryPath = `${path}.reap.${expected.token}`;
197
+ const recovery = tryCreate(recoveryPath);
198
+ if (!recovery) {
199
+ const recoveryOwner = readOwner(recoveryPath, `${label} recovery lock`);
200
+ if (recoveryOwner && !isProcessAlive(recoveryOwner.pid)) {
201
+ detachAbandonedLock(
202
+ recoveryPath,
203
+ recoveryOwner,
204
+ `${label} recovery lock`,
205
+ );
206
+ }
207
+ return false;
208
+ }
209
+
210
+ let abandoned: string | null = null;
211
+ try {
212
+ const current = readOwner(path, label);
213
+ if (
214
+ !current ||
215
+ current.token !== expected.token ||
216
+ isProcessAlive(current.pid)
217
+ ) {
218
+ return false;
219
+ }
220
+
221
+ abandoned = `${path}.${expected.token}.abandoned`;
222
+ try {
223
+ renameSync(path, abandoned);
224
+ } catch (err) {
225
+ if (errorCode(err) === "ENOENT") return false;
226
+ throw err;
227
+ }
228
+
229
+ // Cleanup names the detached generation, never a successor at the lock path.
230
+ rmSync(abandoned, { recursive: true, force: true });
231
+ return true;
232
+ } finally {
233
+ release(recovery, `${label} recovery lock`);
234
+ }
235
+ }
236
+
237
+ function release(ownership: Ownership, label: string): void {
238
+ const current = readOwner(ownership.path, label);
239
+ if (!current || current.token !== ownership.owner.token) {
240
+ throw invalidLock(label, "ownership changed before release");
241
+ }
242
+
243
+ const released = `${ownership.path}.${ownership.owner.token}.released`;
244
+ renameSync(ownership.path, released);
245
+ rmSync(released, { recursive: true, force: true });
246
+ }
247
+
248
+ function isProcessAlive(pid: number): boolean {
249
+ try {
250
+ process.kill(pid, 0);
251
+ return true;
252
+ } catch (err) {
253
+ return errorCode(err) !== "ESRCH";
254
+ }
255
+ }
256
+
257
+ function pathExists(path: string): boolean {
258
+ try {
259
+ lstatSync(path);
260
+ return true;
261
+ } catch (err) {
262
+ if (errorCode(err) === "ENOENT") return false;
263
+ throw err;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Identifies what currently sits at `path`, or `null` when nothing does. Compared
269
+ * against an earlier `stat` it answers "is this still the same directory?", which
270
+ * distinguishes a released lock from a corrupt one.
271
+ */
272
+ function inodeAt(path: string): number | null {
273
+ try {
274
+ return lstatSync(path).ino;
275
+ } catch (err) {
276
+ if (errorCode(err) === "ENOENT") return null;
277
+ throw err;
278
+ }
279
+ }
280
+
281
+ function errorCode(err: unknown): string | undefined {
282
+ return (err as NodeJS.ErrnoException)?.code;
283
+ }
284
+
285
+ function invalidLock(label: string, reason: string): CliError {
286
+ return new CliError(`Unsafe or corrupt ${label}: ${reason}`, ExitCode.Config);
287
+ }