bwx 0.2.2 → 0.4.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/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
+ }
@@ -0,0 +1,112 @@
1
+ import type { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { listAttachments, resolveAttachment } from "../../bw/attachments.ts";
4
+ import { runBwOrThrow } from "../../bw/runner.ts";
5
+ import { withSession } from "../../bw/session.ts";
6
+ import { emitData, emitSuccess } from "../io.ts";
7
+ import { getGlobalOpts } from "../program.ts";
8
+
9
+ export function registerAttach(program: Command): void {
10
+ const attach = program
11
+ .command("attach")
12
+ .aliases(["attachment", "attachments"])
13
+ .description("List and download item attachments");
14
+
15
+ attach
16
+ .command("list")
17
+ .alias("ls")
18
+ .description("List attachments for an item")
19
+ .argument("<item>", "Item name or ID")
20
+ .action(async function (this: Command, item: string) {
21
+ const opts = getGlobalOpts(this);
22
+ const current = await getItem(item, opts);
23
+ const attachments = listAttachments(current);
24
+
25
+ if (opts.json) {
26
+ emitData(attachments, opts);
27
+ return;
28
+ }
29
+
30
+ if (opts.plain) {
31
+ for (const attachment of attachments) {
32
+ process.stdout.write(
33
+ [
34
+ attachment.id,
35
+ attachment.fileName,
36
+ attachment.sizeName ?? attachment.size ?? "-",
37
+ ].join("\t") + "\n",
38
+ );
39
+ }
40
+ return;
41
+ }
42
+
43
+ if (attachments.length === 0) {
44
+ const itemName = typeof current.name === "string" ? current.name : item;
45
+ process.stdout.write(`No attachments on "${itemName}"\n`);
46
+ return;
47
+ }
48
+
49
+ for (const attachment of attachments) {
50
+ const size = attachment.sizeName ?? attachment.size;
51
+ const sizePart = size === undefined ? "" : pc.dim(` (${size})`);
52
+ process.stdout.write(
53
+ `${pc.dim(attachment.id)} ${attachment.fileName}${sizePart}\n`,
54
+ );
55
+ }
56
+ });
57
+
58
+ attach
59
+ .command("get")
60
+ .description("Download an attachment from an item")
61
+ .argument("<item>", "Item name or ID")
62
+ .argument("<attachment>", "Attachment ID or filename")
63
+ .option("-o, --output <path>", "Output file or directory")
64
+ .action(async function (
65
+ this: Command,
66
+ item: string,
67
+ attachmentQuery: string,
68
+ localOpts: { output?: string },
69
+ ) {
70
+ const opts = getGlobalOpts(this);
71
+ const current = await getItem(item, opts);
72
+ const attachments = listAttachments(current);
73
+ const attachment = resolveAttachment(attachments, attachmentQuery);
74
+ const itemId = String(current.id);
75
+ const output = localOpts.output ?? attachment.fileName;
76
+
77
+ await withSession(opts, () =>
78
+ runBwOrThrow([
79
+ "get",
80
+ "attachment",
81
+ attachment.id,
82
+ "--itemid",
83
+ itemId,
84
+ "--output",
85
+ output,
86
+ ]),
87
+ );
88
+
89
+ const result = {
90
+ itemId,
91
+ attachmentId: attachment.id,
92
+ fileName: attachment.fileName,
93
+ output,
94
+ };
95
+
96
+ if (opts.json) {
97
+ emitData(result, opts);
98
+ } else if (opts.plain) {
99
+ emitData(output, opts);
100
+ } else {
101
+ emitSuccess(`Saved "${attachment.fileName}" to ${output}`, opts);
102
+ }
103
+ });
104
+ }
105
+
106
+ async function getItem(
107
+ item: string,
108
+ opts: ReturnType<typeof getGlobalOpts>,
109
+ ): Promise<Record<string, unknown>> {
110
+ const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
111
+ return JSON.parse(json) as Record<string, unknown>;
112
+ }
@@ -4,11 +4,29 @@ import { emitData, emitSuccess } from "../io.ts";
4
4
  import { CliError, ExitCode } from "../errors.ts";
5
5
  import { runBwOrThrow } from "../../bw/runner.ts";
6
6
  import { withSession } from "../../bw/session.ts";
7
- import { buildNewItem, parseFieldFlag, bwEncode } from "../../bw/encoding.ts";
7
+ import { buildNewItem, bwEncode } from "../../bw/encoding.ts";
8
+ import {
9
+ assertSingleStdinSource,
10
+ collect,
11
+ listExplicitStdinInputs,
12
+ readOptionalStdin,
13
+ resolveFieldSources,
14
+ resolveItemTextInputs,
15
+ StdinReader,
16
+ type ItemTextInputs,
17
+ } from "../input.ts";
8
18
 
9
- function collect(val: string, prev: string[]): string[] {
10
- prev.push(val);
11
- return prev;
19
+ interface CreateOptions extends ItemTextInputs {
20
+ type: string;
21
+ name?: string;
22
+ username?: string;
23
+ uri: string[];
24
+ field: string[];
25
+ fieldFile: string[];
26
+ fieldEnv: string[];
27
+ folder?: string;
28
+ favorite?: boolean;
29
+ fromJson?: boolean;
12
30
  }
13
31
 
14
32
  export function registerCreate(program: Command): void {
@@ -18,44 +36,40 @@ export function registerCreate(program: Command): void {
18
36
  .option("--type <type>", "Item type: login|note", "note")
19
37
  .option("--name <name>", "Item name")
20
38
  .option("--notes <text>", "Notes content")
39
+ .option("--notes-file <path>", "Read notes from file (use - for stdin)")
21
40
  .option("--username <user>", "Username (login type)")
22
41
  .option("--password <pass>", "Password (login type)")
42
+ .option("--password-stdin", "Read password from stdin")
43
+ .option("--password-file <path>", "Read password from file (use - for stdin)")
44
+ .option("--password-env <name>", "Read password from environment variable")
23
45
  .option("--uri <url>", "URI (repeatable)", collect, [])
24
46
  .option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
47
+ .option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
48
+ .option("--field-env <kv>", "Custom field value from env var k=ENV (repeatable)", collect, [])
25
49
  .option("--folder <id>", "Folder ID")
26
50
  .option("--favorite", "Mark as favorite")
27
51
  .option("--from-json", "Read full item JSON from stdin")
28
52
  .action(async function (
29
53
  this: Command,
30
- localOpts: {
31
- type: string;
32
- name?: string;
33
- notes?: string;
34
- username?: string;
35
- password?: string;
36
- uri: string[];
37
- field: string[];
38
- folder?: string;
39
- favorite?: boolean;
40
- fromJson?: boolean;
41
- },
54
+ localOpts: CreateOptions,
42
55
  ) {
43
56
  const opts = getGlobalOpts(this);
57
+ const stdin = new StdinReader();
44
58
 
45
59
  let encoded: string;
46
60
 
47
61
  if (localOpts.fromJson) {
48
- const stdin = await readStdin();
49
- if (!stdin) {
62
+ const stdinText = await stdin.read();
63
+ if (!stdinText) {
50
64
  throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
51
65
  }
52
66
  // Validate it's JSON
53
67
  try {
54
- JSON.parse(stdin);
68
+ JSON.parse(stdinText);
55
69
  } catch {
56
70
  throw new CliError("Invalid JSON on stdin", ExitCode.BadArgs);
57
71
  }
58
- encoded = bwEncode(stdin);
72
+ encoded = bwEncode(stdinText);
59
73
  } else {
60
74
  if (!localOpts.name) {
61
75
  throw new CliError("--name is required", ExitCode.BadArgs);
@@ -68,33 +82,41 @@ export function registerCreate(program: Command): void {
68
82
  );
69
83
  }
70
84
 
71
- // Read notes from stdin if not provided and not a TTY
72
- let notes = localOpts.notes ?? null;
73
- if (notes === null && !process.stdin.isTTY) {
74
- const stdinText = await readStdin();
75
- if (stdinText) notes = stdinText;
76
- }
85
+ const explicitStdinSources = listExplicitStdinInputs({
86
+ ...localOpts,
87
+ fieldFileFlag: "--field-file",
88
+ fieldFiles: localOpts.fieldFile,
89
+ });
90
+ assertSingleStdinSource(explicitStdinSources);
91
+
92
+ const textInputs = await resolveItemTextInputs(localOpts, stdin);
93
+ let notes = textInputs.notes;
77
94
 
78
- // Read password from stdin if not provided, login type, and not a TTY
79
- let password = localOpts.password ?? null;
95
+ // Backward compatibility: piped create input becomes note content.
80
96
  if (
81
- password === null &&
82
- localOpts.type === "login" &&
83
- !process.stdin.isTTY &&
84
- notes === null
97
+ notes === undefined &&
98
+ textInputs.password === undefined &&
99
+ explicitStdinSources.length === 0 &&
100
+ !process.stdin.isTTY
85
101
  ) {
86
- const stdinText = await readStdin();
87
- if (stdinText) password = stdinText;
102
+ notes = (await readOptionalStdin(stdin)) ?? undefined;
88
103
  }
89
104
 
90
- const fields = localOpts.field.map(parseFieldFlag);
105
+ const fields = await resolveFieldSources({
106
+ inline: localOpts.field,
107
+ file: localOpts.fieldFile,
108
+ env: localOpts.fieldEnv,
109
+ fileFlag: "--field-file",
110
+ envFlag: "--field-env",
111
+ stdin,
112
+ });
91
113
 
92
114
  const item = buildNewItem({
93
115
  type: localOpts.type,
94
116
  name: localOpts.name,
95
- notes,
117
+ notes: notes ?? null,
96
118
  username: localOpts.username ?? null,
97
- password,
119
+ password: textInputs.password ?? null,
98
120
  uris: localOpts.uri,
99
121
  fields,
100
122
  folderId: localOpts.folder ?? null,
@@ -118,16 +140,3 @@ export function registerCreate(program: Command): void {
118
140
  }
119
141
  });
120
142
  }
121
-
122
- async function readStdin(): Promise<string | null> {
123
- try {
124
- const chunks: Uint8Array[] = [];
125
- for await (const chunk of Bun.stdin.stream()) {
126
- chunks.push(chunk);
127
- }
128
- const text = Buffer.concat(chunks).toString().trim();
129
- return text || null;
130
- } catch {
131
- return null;
132
- }
133
- }
@@ -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);
@@ -4,11 +4,28 @@ import { emitData, emitSuccess } from "../io.ts";
4
4
  import { CliError, ExitCode } from "../errors.ts";
5
5
  import { runBwOrThrow } from "../../bw/runner.ts";
6
6
  import { withSession } from "../../bw/session.ts";
7
- import { patchItem, parseFieldFlag, bwEncode } from "../../bw/encoding.ts";
7
+ import { patchItem, bwEncode } from "../../bw/encoding.ts";
8
+ import {
9
+ assertSingleStdinSource,
10
+ collect,
11
+ listExplicitStdinInputs,
12
+ resolveFieldSources,
13
+ resolveItemTextInputs,
14
+ StdinReader,
15
+ type ItemTextInputs,
16
+ } from "../input.ts";
8
17
 
9
- function collect(val: string, prev: string[]): string[] {
10
- prev.push(val);
11
- return prev;
18
+ interface EditOptions extends ItemTextInputs {
19
+ name?: string;
20
+ username?: string;
21
+ uri: string[];
22
+ addField: string[];
23
+ addFieldFile: string[];
24
+ addFieldEnv: string[];
25
+ rmField: string[];
26
+ folder?: string;
27
+ favorite?: boolean;
28
+ fromJson?: boolean;
12
29
  }
13
30
 
14
31
  export function registerEdit(program: Command): void {
@@ -18,10 +35,16 @@ export function registerEdit(program: Command): void {
18
35
  .argument("<item>", "Item name or ID")
19
36
  .option("--name <name>", "New name")
20
37
  .option("--notes <text>", "New notes")
38
+ .option("--notes-file <path>", "Read notes from file (use - for stdin)")
21
39
  .option("--username <user>", "New username")
22
40
  .option("--password <pass>", "New password")
41
+ .option("--password-stdin", "Read password from stdin")
42
+ .option("--password-file <path>", "Read password from file (use - for stdin)")
43
+ .option("--password-env <name>", "Read password from environment variable")
23
44
  .option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
24
45
  .option("--add-field <kv>", "Add/update field k=v", collect, [])
46
+ .option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
47
+ .option("--add-field-env <kv>", "Add/update field value from env var k=ENV", collect, [])
25
48
  .option("--rm-field <name>", "Remove field by name", collect, [])
26
49
  .option("--folder <id>", "Move to folder")
27
50
  .option("--favorite", "Set favorite")
@@ -30,20 +53,10 @@ export function registerEdit(program: Command): void {
30
53
  .action(async function (
31
54
  this: Command,
32
55
  item: string,
33
- localOpts: {
34
- name?: string;
35
- notes?: string;
36
- username?: string;
37
- password?: string;
38
- uri: string[];
39
- addField: string[];
40
- rmField: string[];
41
- folder?: string;
42
- favorite?: boolean;
43
- fromJson?: boolean;
44
- },
56
+ localOpts: EditOptions,
45
57
  ) {
46
58
  const opts = getGlobalOpts(this);
59
+ const stdin = new StdinReader();
47
60
 
48
61
  const result = await withSession(opts, async () => {
49
62
  // Fetch current item
@@ -54,23 +67,38 @@ export function registerEdit(program: Command): void {
54
67
  let encoded: string;
55
68
 
56
69
  if (localOpts.fromJson) {
57
- const chunks: Uint8Array[] = [];
58
- for await (const chunk of Bun.stdin.stream()) {
59
- chunks.push(chunk);
60
- }
61
- const stdinText = Buffer.concat(chunks).toString().trim();
70
+ const stdinText = await stdin.read();
62
71
  if (!stdinText) {
63
72
  throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
64
73
  }
65
74
  encoded = bwEncode(stdinText);
66
75
  } else {
76
+ assertSingleStdinSource(
77
+ listExplicitStdinInputs({
78
+ ...localOpts,
79
+ fieldFileFlag: "--add-field-file",
80
+ fieldFiles: localOpts.addFieldFile,
81
+ }),
82
+ );
83
+
84
+ const textInputs = await resolveItemTextInputs(localOpts, stdin);
85
+
86
+ const addFields = await resolveFieldSources({
87
+ inline: localOpts.addField,
88
+ file: localOpts.addFieldFile,
89
+ env: localOpts.addFieldEnv,
90
+ fileFlag: "--add-field-file",
91
+ envFlag: "--add-field-env",
92
+ stdin,
93
+ });
94
+
67
95
  const patched = patchItem(current, {
68
96
  name: localOpts.name,
69
- notes: localOpts.notes,
97
+ notes: textInputs.notes,
70
98
  username: localOpts.username,
71
- password: localOpts.password,
99
+ password: textInputs.password,
72
100
  uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
73
- addFields: localOpts.addField.map(parseFieldFlag),
101
+ addFields,
74
102
  rmFields: localOpts.rmField,
75
103
  folderId: localOpts.folder,
76
104
  favorite: localOpts.favorite,