bwx 0.3.0 → 0.4.1

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/runner.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CliError, ExitCode, classifyBwError } from "../cli/errors.ts";
2
+ import { formatDuration } from "../cli/duration.ts";
2
3
 
3
4
  export interface BwResult {
4
5
  stdout: string;
@@ -6,7 +7,20 @@ export interface BwResult {
6
7
  exitCode: number;
7
8
  }
8
9
 
10
+ export interface RunBwOptions {
11
+ /** Explicit session, or `null` to run without one (login/unlock/status). */
12
+ session?: string | null;
13
+ env?: Record<string, string>;
14
+ }
15
+
16
+ /** Deadline for a single `bw` call; overridable with `--timeout`. */
17
+ export const DEFAULT_TIMEOUT_MS = 60_000;
18
+
19
+ /** Grace period between SIGTERM and SIGKILL for a `bw` process past its deadline. */
20
+ const KILL_GRACE_MS = 2_000;
21
+
9
22
  let sessionToken: string | null = null;
23
+ let timeoutMs: number = DEFAULT_TIMEOUT_MS;
10
24
 
11
25
  export function setSession(token: string | null): void {
12
26
  sessionToken = token;
@@ -16,33 +30,51 @@ export function getSession(): string | null {
16
30
  return sessionToken;
17
31
  }
18
32
 
33
+ /** Applies the `--timeout` value for the rest of the process. */
34
+ export function setBwTimeout(ms: number): void {
35
+ timeoutMs = ms;
36
+ }
37
+
19
38
  export async function runBw(
20
39
  args: string[],
21
- options?: { session?: string | null; env?: Record<string, string> },
40
+ options?: RunBwOptions,
22
41
  ): Promise<BwResult> {
23
42
  const session = options?.session ?? sessionToken;
24
- const fullArgs = session ? [...args, "--session", session] : args;
25
43
 
26
- const proc = Bun.spawn(["bw", ...fullArgs], {
44
+ const proc = Bun.spawn(["bw", ...args], {
27
45
  stdout: "pipe",
28
46
  stderr: "pipe",
29
47
  stdin: "ignore",
30
- env: options?.env ? { ...process.env, ...options.env } : undefined,
48
+ // The session goes in the environment, never in argv: command lines are
49
+ // readable by any same-user process. Nothing here is ever logged.
50
+ env: buildEnv(session, options?.env),
31
51
  });
32
52
 
33
- const [stdout, stderr] = await Promise.all([
34
- new Response(proc.stdout).text(),
35
- new Response(proc.stderr).text(),
36
- ]);
53
+ const guard = timeoutMs > 0 ? armDeadline(proc, timeoutMs) : null;
54
+
55
+ try {
56
+ const [stdout, stderr] = await Promise.all([
57
+ new Response(proc.stdout).text(),
58
+ new Response(proc.stderr).text(),
59
+ ]);
60
+ const exitCode = await proc.exited;
37
61
 
38
- const exitCode = await proc.exited;
62
+ if (guard?.expired) {
63
+ throw new CliError(
64
+ `bw ${args[0] ?? ""} timed out after ${formatDuration(timeoutMs)}`.trim(),
65
+ ExitCode.Timeout,
66
+ );
67
+ }
39
68
 
40
- return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
69
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
70
+ } finally {
71
+ guard?.disarm();
72
+ }
41
73
  }
42
74
 
43
75
  export async function runBwOrThrow(
44
76
  args: string[],
45
- options?: { session?: string | null; env?: Record<string, string> },
77
+ options?: RunBwOptions,
46
78
  ): Promise<string> {
47
79
  const result = await runBw(args, options);
48
80
  if (result.exitCode !== 0) {
@@ -55,6 +87,70 @@ export async function runBwOrThrow(
55
87
  return result.stdout;
56
88
  }
57
89
 
58
- export function isAuthError(result: BwResult): boolean {
59
- return /not logged in|vault is locked|unauthenticated/i.test(result.stderr);
90
+ /**
91
+ * Child environment for `bw`. `BW_SESSION` is set from our session, or removed
92
+ * outright so an ambient one from the caller's shell cannot silently substitute
93
+ * for it (`status`, `login`, and `unlock` rely on seeing the real vault state).
94
+ *
95
+ * `BW_NOINTERACTION` is mandatory: bwx runs `bw` with stdin ignored, so a prompt
96
+ * can only ever produce a hang or garbled output — a locked vault must fail
97
+ * loudly instead, which is what lets callers auto-unlock and retry.
98
+ */
99
+ function buildEnv(
100
+ session: string | null,
101
+ extra?: Record<string, string>,
102
+ ): Record<string, string | undefined> {
103
+ const env: Record<string, string | undefined> = {
104
+ ...process.env,
105
+ BW_NOINTERACTION: "true",
106
+ ...extra,
107
+ };
108
+ if (session) {
109
+ env.BW_SESSION = session;
110
+ } else {
111
+ delete env.BW_SESSION;
112
+ }
113
+ return env;
114
+ }
115
+
116
+ interface Deadline {
117
+ expired: boolean;
118
+ disarm(): void;
119
+ }
120
+
121
+ /**
122
+ * Kills `bw` once the deadline passes so a wedged vault operation (stuck Keychain
123
+ * prompt, unreachable self-hosted server) cannot block the caller forever.
124
+ */
125
+ function armDeadline(proc: Bun.Subprocess, ms: number): Deadline {
126
+ const timers: ReturnType<typeof setTimeout>[] = [];
127
+ const deadline: Deadline = {
128
+ expired: false,
129
+ disarm: () => timers.forEach(clearTimeout),
130
+ };
131
+
132
+ const arm = (delay: number, fn: () => void) => {
133
+ const timer = setTimeout(fn, delay);
134
+ timer.unref?.();
135
+ timers.push(timer);
136
+ };
137
+
138
+ // A timer can still fire in the moment between exit and disarm, where the
139
+ // signal has nowhere to go.
140
+ const kill = (signal: NodeJS.Signals) => {
141
+ try {
142
+ proc.kill(signal);
143
+ } catch {
144
+ // Already gone.
145
+ }
146
+ };
147
+
148
+ arm(ms, () => {
149
+ deadline.expired = true;
150
+ kill("SIGTERM");
151
+ // Escalate if bw ignores SIGTERM.
152
+ arm(KILL_GRACE_MS, () => kill("SIGKILL"));
153
+ });
154
+
155
+ return deadline;
60
156
  }
package/src/bw/session.ts CHANGED
@@ -1,58 +1,41 @@
1
+ import { unlinkSync } from "node:fs";
1
2
  import { CliError, ExitCode } from "../cli/errors.ts";
2
3
  import type { GlobalOptions } from "../cli/types.ts";
3
- import { emitLog } from "../cli/io.ts";
4
- import {
5
- runBw,
6
- runBwOrThrow,
7
- setSession,
8
- getSession,
9
- isAuthError,
10
- type BwResult,
11
- } from "./runner.ts";
12
- import { BwStatusSchema, type BwStatus } from "./types.ts";
4
+ import { emitLog, emitWarn } from "../cli/io.ts";
5
+ import { SESSION_FILE } from "../config/paths.ts";
6
+ import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
7
+ import { runBw, runBwOrThrow, setSession, getSession } from "./runner.ts";
8
+ import { checkVaultFreshness } from "./freshness.ts";
9
+ import { getStatus } from "./status.ts";
10
+ import { BwStatusSchema } from "./types.ts";
13
11
  import { loadConfig } from "../config/store.ts";
14
12
 
15
- const SESSION_PATH = `${process.env.HOME}/.config/bwx/session`;
16
-
17
- async function loadCachedSession(): Promise<string | null> {
18
- try {
19
- const token = await Bun.file(SESSION_PATH).text();
20
- return token.trim() || null;
21
- } catch {
13
+ /**
14
+ * Loads the cached session, ignoring a cache file that another user could have
15
+ * planted or read. Rejection is not fatal: the caller just unlocks again.
16
+ */
17
+ function loadCachedSession(opts: GlobalOptions): string | null {
18
+ const read = readPrivateFile(SESSION_FILE);
19
+ if (read.kind === "rejected") {
20
+ emitWarn(`Ignoring cached session: ${read.reason}`, opts);
22
21
  return null;
23
22
  }
23
+ if (read.kind === "missing") return null;
24
+ return read.text.trim() || null;
24
25
  }
25
26
 
26
- async function saveCachedSession(token: string): Promise<void> {
27
- const dir = SESSION_PATH.replace(/\/[^/]+$/, "");
28
- const { mkdirSync } = await import("node:fs");
29
- mkdirSync(dir, { recursive: true });
30
- await Bun.write(SESSION_PATH, token);
31
- const proc = Bun.spawn(["chmod", "600", SESSION_PATH]);
32
- await proc.exited;
27
+ function saveCachedSession(token: string): void {
28
+ writePrivateFile(SESSION_FILE, token);
33
29
  }
34
30
 
35
- async function clearCachedSession(): Promise<void> {
31
+ function clearCachedSession(): void {
36
32
  try {
37
- const { unlinkSync } = await import("node:fs");
38
- unlinkSync(SESSION_PATH);
33
+ unlinkSync(SESSION_FILE);
39
34
  } catch {
40
35
  // Already gone
41
36
  }
42
37
  }
43
38
 
44
- export async function getStatus(): Promise<BwStatus> {
45
- const result = await runBw(["status"], { session: null });
46
- try {
47
- return BwStatusSchema.parse(JSON.parse(result.stdout));
48
- } catch {
49
- throw new CliError(
50
- `Failed to parse bw status: ${result.stdout}`,
51
- ExitCode.BwError,
52
- );
53
- }
54
- }
55
-
56
39
  async function getMasterPassword(): Promise<string> {
57
40
  const proc = Bun.spawn(
58
41
  [
@@ -77,11 +60,9 @@ async function getMasterPassword(): Promise<string> {
77
60
  return pw;
78
61
  }
79
62
 
80
- export async function ensureUnlocked(
81
- opts: GlobalOptions,
82
- ): Promise<void> {
63
+ export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
83
64
  // Try cached session first
84
- const cached = getSession() || (await loadCachedSession());
65
+ const cached = getSession() || loadCachedSession(opts);
85
66
  if (cached) {
86
67
  setSession(cached);
87
68
  // Quick check — try a lightweight command
@@ -114,17 +95,9 @@ export async function ensureUnlocked(
114
95
  const pw = await getMasterPassword();
115
96
  emitLog("Logging in...", opts);
116
97
  const loginResult = await runBw(
117
- [
118
- "login",
119
- config.email,
120
- "--passwordenv",
121
- "BW_MASTER_PW",
122
- "--quiet",
123
- ],
124
- {
125
- session: null,
126
- env: { BW_MASTER_PW: pw, BW_NOINTERACTION: "true" },
127
- },
98
+ ["login", config.email, "--passwordenv", "BW_MASTER_PW", "--quiet"],
99
+ // BW_NOINTERACTION is set for every bw call by the runner.
100
+ { session: null, env: { BW_MASTER_PW: pw } },
128
101
  );
129
102
  if (loginResult.exitCode !== 0) {
130
103
  throw new CliError(
@@ -150,35 +123,57 @@ export async function ensureUnlocked(
150
123
 
151
124
  const token = unlockResult.stdout;
152
125
  setSession(token);
153
- await saveCachedSession(token);
126
+ saveCachedSession(token);
154
127
  }
155
128
 
156
129
  export async function lockVault(): Promise<void> {
157
130
  await runBw(["lock"], { session: null });
158
131
  setSession(null);
159
- await clearCachedSession();
132
+ clearCachedSession();
133
+ }
134
+
135
+ interface SessionOptions {
136
+ /**
137
+ * Skips the stale-vault check. Set by operations that own vault freshness
138
+ * themselves (`sync`) to avoid recursion.
139
+ */
140
+ skipFreshnessCheck?: boolean;
160
141
  }
161
142
 
162
143
  export async function withSession<T>(
163
144
  opts: GlobalOptions,
164
145
  fn: () => Promise<T>,
146
+ options?: SessionOptions,
165
147
  ): Promise<T> {
166
148
  // Try with current/cached session
167
- const cached = getSession() || (await loadCachedSession());
149
+ const cached = getSession() || loadCachedSession(opts);
168
150
  if (cached) {
169
151
  setSession(cached);
170
152
  }
171
153
 
154
+ if (!options?.skipFreshnessCheck) {
155
+ await checkVaultFreshness(opts, () => syncVault(opts));
156
+ }
157
+
158
+ return withAuthRetry(opts, fn);
159
+ }
160
+
161
+ async function syncVault(opts: GlobalOptions): Promise<void> {
162
+ await withAuthRetry(opts, () => runBwOrThrow(["sync"]));
163
+ }
164
+
165
+ /** Runs `fn`, re-authenticating once if the cached session turned out to be stale. */
166
+ async function withAuthRetry<T>(
167
+ opts: GlobalOptions,
168
+ fn: () => Promise<T>,
169
+ ): Promise<T> {
172
170
  try {
173
171
  return await fn();
174
172
  } catch (err) {
175
- if (
176
- err instanceof CliError &&
177
- err.exitCode === ExitCode.AuthFailed
178
- ) {
173
+ if (err instanceof CliError && err.exitCode === ExitCode.AuthFailed) {
179
174
  // Clear stale session and re-auth
180
175
  setSession(null);
181
- await clearCachedSession();
176
+ clearCachedSession();
182
177
  await ensureUnlocked(opts);
183
178
  return await fn();
184
179
  }
@@ -0,0 +1,19 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+ import { runBw } from "./runner.ts";
3
+ import { BwStatusSchema, type BwStatus } from "./types.ts";
4
+
5
+ /**
6
+ * Reads the vault state. Deliberately runs without a session so the result
7
+ * reflects the vault itself rather than our cached unlock.
8
+ */
9
+ export async function getStatus(): Promise<BwStatus> {
10
+ const result = await runBw(["status"], { session: null });
11
+ try {
12
+ return BwStatusSchema.parse(JSON.parse(result.stdout));
13
+ } catch {
14
+ throw new CliError(
15
+ `Failed to parse bw status: ${result.stdout}`,
16
+ ExitCode.BwError,
17
+ );
18
+ }
19
+ }
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData } from "../io.ts";
4
4
  import { loadConfig } from "../../config/store.ts";
5
+ import { CONFIG_DIR } from "../../config/paths.ts";
5
6
  import pc from "picocolors";
6
7
 
7
8
  interface Check {
@@ -140,9 +141,7 @@ async function checkMasterPassword(): Promise<Check> {
140
141
  }
141
142
 
142
143
  async function checkConfigDir(): Promise<Check> {
143
- const configDir =
144
- process.env.XDG_CONFIG_HOME ?? `${process.env.HOME}/.config`;
145
- const dir = `${configDir}/bwx`;
144
+ const dir = CONFIG_DIR;
146
145
  try {
147
146
  const { accessSync, constants } = await import("node:fs");
148
147
  accessSync(dir, constants.W_OK);
@@ -1,13 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData } from "../io.ts";
4
- import { CliError, ExitCode } from "../errors.ts";
5
- import { runBwOrThrow } from "../../bw/runner.ts";
6
- import { withSession } from "../../bw/session.ts";
7
- import { BW_TYPE_LABELS, BwItemSchema } from "../../bw/types.ts";
8
- import pc from "picocolors";
9
-
10
- const BUILTIN_FIELDS = ["password", "username", "totp", "notes", "uri", "item"];
4
+ import { readItemField } from "../../bw/fields.ts";
11
5
 
12
6
  export function registerGet(program: Command): void {
13
7
  program
@@ -17,12 +11,9 @@ export function registerGet(program: Command): void {
17
11
  .argument("<item>", "Item name or ID")
18
12
  .action(async function (this: Command, field: string, item: string) {
19
13
  const opts = getGlobalOpts(this);
20
-
21
- if (BUILTIN_FIELDS.includes(field)) {
22
- await getBuiltinField(field, item, opts);
23
- } else {
24
- await getCustomField(field, item, opts);
25
- }
14
+ const value = await readItemField(field, item, opts);
15
+ // `item` is the one field that is itself JSON — emit it as structure.
16
+ emitData(field === "item" ? JSON.parse(value) : value, opts);
26
17
  });
27
18
 
28
19
  program
@@ -32,121 +23,6 @@ export function registerGet(program: Command): void {
32
23
  .argument("<name>", "Custom field name")
33
24
  .action(async function (this: Command, item: string, name: string) {
34
25
  const opts = getGlobalOpts(this);
35
- await getCustomField(name, item, opts);
36
- });
37
- }
38
-
39
- async function getBuiltinField(
40
- field: string,
41
- item: string,
42
- opts: ReturnType<typeof getGlobalOpts>,
43
- ): Promise<void> {
44
- try {
45
- const result = await withSession(opts, async () => {
46
- if (field === "item") {
47
- return runBwOrThrow(["get", "item", item]);
48
- }
49
- return runBwOrThrow(["get", field, item]);
26
+ emitData(await readItemField(name, item, opts, { customOnly: true }), opts);
50
27
  });
51
-
52
- if (!result) {
53
- throw new CliError(
54
- `No ${field} found for item: ${item}`,
55
- ExitCode.NotFound,
56
- );
57
- }
58
-
59
- if (field === "item") {
60
- emitData(JSON.parse(result), opts);
61
- } else {
62
- emitData(result, opts);
63
- }
64
- } catch (err) {
65
- if (
66
- err instanceof CliError &&
67
- /more than one result/i.test(err.message)
68
- ) {
69
- const matches = await resolveAmbiguous(item);
70
- throw new CliError(
71
- formatAmbiguousError(item, matches, opts),
72
- ExitCode.BadArgs,
73
- );
74
- }
75
- throw err;
76
- }
77
- }
78
-
79
- async function getCustomField(
80
- fieldName: string,
81
- item: string,
82
- opts: ReturnType<typeof getGlobalOpts>,
83
- ): Promise<void> {
84
- const json = await withSession(opts, () =>
85
- runBwOrThrow(["get", "item", item]),
86
- );
87
-
88
- const parsed = BwItemSchema.parse(JSON.parse(json));
89
- const fields = parsed.fields ?? [];
90
- const match = fields.find((f) => f.name === fieldName);
91
-
92
- if (!match) {
93
- const available = fields.map((f) => f.name).join(", ");
94
- const msg = available
95
- ? `Field "${fieldName}" not found. Available: ${available}`
96
- : `Field "${fieldName}" not found (item has no custom fields)`;
97
- throw new CliError(msg, ExitCode.NotFound);
98
- }
99
-
100
- emitData(match.value ?? "", opts);
101
- }
102
-
103
- interface MatchInfo {
104
- id: string;
105
- name: string;
106
- type: string;
107
- username?: string;
108
- }
109
-
110
- async function resolveAmbiguous(query: string): Promise<MatchInfo[]> {
111
- try {
112
- const json = await runBwOrThrow([
113
- "list",
114
- "items",
115
- "--search",
116
- query,
117
- ]);
118
- const items: Array<Record<string, unknown>> = JSON.parse(json);
119
- return items.map((i) => ({
120
- id: i.id as string,
121
- name: i.name as string,
122
- type: BW_TYPE_LABELS[i.type as number] ?? `type:${i.type}`,
123
- username: (i.login as Record<string, unknown> | null)?.username as
124
- | string
125
- | undefined,
126
- }));
127
- } catch {
128
- return [];
129
- }
130
- }
131
-
132
- function formatAmbiguousError(
133
- query: string,
134
- matches: MatchInfo[],
135
- opts: { json?: boolean },
136
- ): string {
137
- if (matches.length === 0) {
138
- return `Multiple items match "${query}". Use a specific ID instead.`;
139
- }
140
-
141
- if (opts.json) {
142
- return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
143
- }
144
-
145
- const lines = [`Multiple items match "${query}":\n`];
146
- for (const m of matches) {
147
- const user = m.username ? ` (${m.username})` : "";
148
- lines.push(` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`);
149
- }
150
- lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
151
- return lines.join("\n");
152
28
  }
@@ -1,11 +1,9 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
- import { emitData } from "../io.ts";
4
3
  import { CliError, ExitCode } from "../errors.ts";
4
+ import { filterItems, writeItems, type ItemListOptions } from "../items.ts";
5
5
  import { runBwOrThrow } from "../../bw/runner.ts";
6
6
  import { withSession } from "../../bw/session.ts";
7
- import { BW_TYPE_LABELS, BW_TYPE_FROM_NAME } from "../../bw/types.ts";
8
- import pc from "picocolors";
9
7
 
10
8
  export function registerList(program: Command): void {
11
9
  program
@@ -15,10 +13,11 @@ export function registerList(program: Command): void {
15
13
  .argument("[type]", "Filter by type: logins|notes|cards|identities")
16
14
  .option("--folder <id>", "Filter by folder ID")
17
15
  .option("--limit <n>", "Limit results", parseInt)
16
+ .option("--full-items", "Emit complete item objects (includes secrets)")
18
17
  .action(async function (
19
18
  this: Command,
20
19
  type: string | undefined,
21
- localOpts: { folder?: string; limit?: number },
20
+ localOpts: ItemListOptions & { folder?: string },
22
21
  ) {
23
22
  const opts = getGlobalOpts(this);
24
23
 
@@ -28,54 +27,12 @@ export function registerList(program: Command): void {
28
27
  }
29
28
 
30
29
  const json = await withSession(opts, () => runBwOrThrow(args));
31
- let items: Array<Record<string, unknown>> = JSON.parse(json);
32
-
33
- if (type) {
34
- const typeVal = BW_TYPE_FROM_NAME[type];
35
- if (typeVal === undefined) {
36
- throw new CliError(
37
- `Invalid type "${type}". Valid: logins, notes, cards, identities`,
38
- ExitCode.BadArgs,
39
- );
40
- }
41
- items = items.filter((i) => i.type === typeVal);
42
- }
43
-
44
- if (localOpts.limit && localOpts.limit > 0) {
45
- items = items.slice(0, localOpts.limit);
46
- }
30
+ const items = filterItems(JSON.parse(json), { ...localOpts, type });
47
31
 
48
32
  if (items.length === 0) {
49
33
  throw new CliError("No items found", ExitCode.NotFound);
50
34
  }
51
35
 
52
- if (opts.json) {
53
- emitData(items, opts);
54
- return;
55
- }
56
-
57
- if (opts.plain) {
58
- for (const item of items) {
59
- const t =
60
- BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`;
61
- const login = item.login as Record<string, unknown> | null;
62
- const username = login?.username ?? "-";
63
- process.stdout.write(
64
- `${item.id}\t${t}\t${item.name}\t${username}\n`,
65
- );
66
- }
67
- return;
68
- }
69
-
70
- for (const item of items) {
71
- const t =
72
- BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`;
73
- const login = item.login as Record<string, unknown> | null;
74
- const username = login?.username ?? "";
75
- const userPart = username ? pc.dim(` (${username})`) : "";
76
- process.stdout.write(
77
- `${pc.dim(item.id as string)} ${pc.cyan(t)} ${item.name}${userPart}\n`,
78
- );
79
- }
36
+ writeItems(items, opts, localOpts);
80
37
  });
81
38
  }