bwx 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ import { CliError, ExitCode } from "../errors.ts";
5
5
  import { runBwOrThrow } from "../../bw/runner.ts";
6
6
  import { withSession } from "../../bw/session.ts";
7
7
  import { buildNewItem, bwEncode } from "../../bw/encoding.ts";
8
+ import { generateSecret } from "../../bw/generate.ts";
9
+ import { resolveFolderRef } from "../../bw/folders.ts";
8
10
  import {
9
11
  assertSingleStdinSource,
10
12
  collect,
@@ -15,6 +17,7 @@ import {
15
17
  StdinReader,
16
18
  type ItemTextInputs,
17
19
  } from "../input.ts";
20
+ import { redactGeneratedPassword } from "./generate.ts";
18
21
 
19
22
  interface CreateOptions extends ItemTextInputs {
20
23
  type: string;
@@ -27,8 +30,19 @@ interface CreateOptions extends ItemTextInputs {
27
30
  folder?: string;
28
31
  favorite?: boolean;
29
32
  fromJson?: boolean;
33
+ generateLength?: number;
30
34
  }
31
35
 
36
+ /** Flags a secure note has nowhere to store — see {@link assertLoginFlags}. */
37
+ const LOGIN_ONLY_FLAGS: Array<[keyof CreateOptions, string]> = [
38
+ ["username", "--username"],
39
+ ["password", "--password"],
40
+ ["passwordStdin", "--password-stdin"],
41
+ ["passwordFile", "--password-file"],
42
+ ["passwordEnv", "--password-env"],
43
+ ["passwordGenerate", "--password-generate"],
44
+ ];
45
+
32
46
  export function registerCreate(program: Command): void {
33
47
  program
34
48
  .command("create").alias("add")
@@ -42,11 +56,13 @@ export function registerCreate(program: Command): void {
42
56
  .option("--password-stdin", "Read password from stdin")
43
57
  .option("--password-file <path>", "Read password from file (use - for stdin)")
44
58
  .option("--password-env <name>", "Read password from environment variable")
59
+ .option("--password-generate", "Generate the password; it is never printed")
60
+ .option("--generate-length <n>", "Length for --password-generate", parseInt)
45
61
  .option("--uri <url>", "URI (repeatable)", collect, [])
46
62
  .option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
47
63
  .option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
48
64
  .option("--field-env <kv>", "Custom field value from env var k=ENV (repeatable)", collect, [])
49
- .option("--folder <id>", "Folder ID")
65
+ .option("--folder <name|id>", "Folder name or ID")
50
66
  .option("--favorite", "Mark as favorite")
51
67
  .option("--from-json", "Read full item JSON from stdin")
52
68
  .action(async function (
@@ -57,6 +73,7 @@ export function registerCreate(program: Command): void {
57
73
  const stdin = new StdinReader();
58
74
 
59
75
  let encoded: string;
76
+ let generated = false;
60
77
 
61
78
  if (localOpts.fromJson) {
62
79
  const stdinText = await stdin.read();
@@ -82,6 +99,8 @@ export function registerCreate(program: Command): void {
82
99
  );
83
100
  }
84
101
 
102
+ assertLoginFlags(localOpts);
103
+
85
104
  const explicitStdinSources = listExplicitStdinInputs({
86
105
  ...localOpts,
87
106
  fieldFileFlag: "--field-file",
@@ -89,7 +108,10 @@ export function registerCreate(program: Command): void {
89
108
  });
90
109
  assertSingleStdinSource(explicitStdinSources);
91
110
 
92
- const textInputs = await resolveItemTextInputs(localOpts, stdin);
111
+ const textInputs = await resolveItemTextInputs(localOpts, stdin, () =>
112
+ generateSecret({ length: localOpts.generateLength }),
113
+ );
114
+ generated = textInputs.passwordGenerated ?? false;
93
115
  let notes = textInputs.notes;
94
116
 
95
117
  // Backward compatibility: piped create input becomes note content.
@@ -119,7 +141,9 @@ export function registerCreate(program: Command): void {
119
141
  password: textInputs.password ?? null,
120
142
  uris: localOpts.uri,
121
143
  fields,
122
- folderId: localOpts.folder ?? null,
144
+ folderId: localOpts.folder
145
+ ? await resolveFolderRef(localOpts.folder, opts)
146
+ : null,
123
147
  favorite: localOpts.favorite ?? false,
124
148
  });
125
149
 
@@ -132,11 +156,33 @@ export function registerCreate(program: Command): void {
132
156
 
133
157
  const created = JSON.parse(result);
134
158
  if (opts.json) {
135
- emitData(created, opts);
159
+ emitData(redactGeneratedPassword(created, generated), opts);
136
160
  } else if (opts.plain) {
137
161
  emitData(created.id, opts);
138
162
  } else {
139
- emitSuccess(`Created "${created.name}" (${created.id})`, opts);
163
+ const note = generated ? " — password generated (not printed)" : "";
164
+ emitSuccess(`Created "${created.name}" (${created.id})${note}`, opts);
140
165
  }
141
166
  });
142
167
  }
168
+
169
+ /**
170
+ * A secure note has no login object, so `buildNewItem` drops usernames,
171
+ * passwords, and URIs on the floor. Accepting them silently means a caller can
172
+ * "store" a credential, get exit 0, and end up with an item that never held it.
173
+ */
174
+ function assertLoginFlags(localOpts: CreateOptions): void {
175
+ if (localOpts.type === "login") return;
176
+
177
+ const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
178
+ ([, flag]) => flag,
179
+ );
180
+ if (localOpts.uri.length > 0) used.push("--uri");
181
+
182
+ if (used.length > 0) {
183
+ throw new CliError(
184
+ `${used.join(", ")} ${used.length > 1 ? "need" : "needs"} --type login (a note cannot store them).`,
185
+ ExitCode.BadArgs,
186
+ );
187
+ }
188
+ }
@@ -4,6 +4,8 @@ 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 { readLine } from "../input.ts";
8
+ import type { GlobalOptions } from "../types.ts";
7
9
  import pc from "picocolors";
8
10
 
9
11
  export function registerDelete(program: Command): void {
@@ -12,7 +14,7 @@ export function registerDelete(program: Command): void {
12
14
  .description("Delete a vault item")
13
15
  .argument("<item>", "Item name or ID")
14
16
  .option("--permanent", "Permanently delete (no trash)")
15
- .option("--force", "Skip confirmation prompt")
17
+ .option("--force", "Skip confirmation prompt (required when not interactive)")
16
18
  .action(async function (
17
19
  this: Command,
18
20
  item: string,
@@ -28,17 +30,7 @@ export function registerDelete(program: Command): void {
28
30
  const itemId: string = current.id;
29
31
  const itemName: string = current.name;
30
32
 
31
- // Confirm unless --force, --json, or --plain
32
- if (!localOpts.force && !opts.json && !opts.plain && process.stdin.isTTY) {
33
- const label = localOpts.permanent ? "permanently delete" : "delete";
34
- process.stderr.write(
35
- `${pc.yellow("?")} ${label} "${itemName}" (${itemId})? [y/N] `,
36
- );
37
- const answer = await readLine();
38
- if (answer.toLowerCase() !== "y") {
39
- throw new CliError("Cancelled", ExitCode.UserCancelled);
40
- }
41
- }
33
+ await confirmDelete(itemName, itemId, localOpts, opts);
42
34
 
43
35
  const args = ["delete", "item", itemId];
44
36
  if (localOpts.permanent) args.push("--permanent");
@@ -46,22 +38,57 @@ export function registerDelete(program: Command): void {
46
38
  await withSession(opts, () => runBwOrThrow(args));
47
39
 
48
40
  if (opts.json) {
49
- emitData({ id: itemId, name: itemName, deleted: true }, opts);
41
+ emitData(
42
+ {
43
+ id: itemId,
44
+ name: itemName,
45
+ deleted: true,
46
+ permanent: localOpts.permanent ?? false,
47
+ },
48
+ opts,
49
+ );
50
50
  } else if (opts.plain) {
51
51
  emitData(itemId, opts);
52
52
  } else {
53
53
  const label = localOpts.permanent ? "Permanently deleted" : "Deleted";
54
- emitSuccess(`${label} "${itemName}" (${itemId})`, opts);
54
+ const undo = localOpts.permanent
55
+ ? ""
56
+ : ` — undo with ${pc.dim(`bwx restore ${itemId}`)}`;
57
+ emitSuccess(`${label} "${itemName}" (${itemId})${undo}`, opts);
55
58
  }
56
59
  });
60
+
57
61
  }
58
62
 
59
- async function readLine(): Promise<string> {
60
- const chunks: Uint8Array[] = [];
61
- for await (const chunk of Bun.stdin.stream()) {
62
- chunks.push(chunk);
63
- const text = Buffer.concat(chunks).toString();
64
- if (text.includes("\n")) return text.split("\n")[0]!.trim();
63
+ /**
64
+ * A prompt only guards a human at a terminal. The common caller here is a script
65
+ * or an agent, where the prompt never renders — so anything non-interactive must
66
+ * say `--force` out loud instead of being deleted on the strength of a flag that
67
+ * silently did nothing.
68
+ */
69
+ async function confirmDelete(
70
+ itemName: string,
71
+ itemId: string,
72
+ localOpts: { permanent?: boolean; force?: boolean },
73
+ opts: GlobalOptions,
74
+ ): Promise<void> {
75
+ if (localOpts.force) return;
76
+
77
+ const interactive = process.stdin.isTTY && !opts.json && !opts.plain;
78
+ const label = localOpts.permanent ? "permanently delete" : "delete";
79
+
80
+ if (!interactive) {
81
+ throw new CliError(
82
+ `Refusing to ${label} "${itemName}" (${itemId}) without confirmation. Pass --force.`,
83
+ ExitCode.BadArgs,
84
+ );
85
+ }
86
+
87
+ process.stderr.write(
88
+ `${pc.yellow("?")} ${label} "${itemName}" (${itemId})? [y/N] `,
89
+ );
90
+ const answer = await readLine();
91
+ if (answer.toLowerCase() !== "y") {
92
+ throw new CliError("Cancelled", ExitCode.UserCancelled);
65
93
  }
66
- return Buffer.concat(chunks).toString().trim();
67
94
  }
@@ -5,6 +5,9 @@ import { CliError, ExitCode } from "../errors.ts";
5
5
  import { runBwOrThrow } from "../../bw/runner.ts";
6
6
  import { withSession } from "../../bw/session.ts";
7
7
  import { patchItem, bwEncode } from "../../bw/encoding.ts";
8
+ import { generateSecret } from "../../bw/generate.ts";
9
+ import { resolveFolderRef } from "../../bw/folders.ts";
10
+ import { BwItemType } from "../../bw/types.ts";
8
11
  import {
9
12
  assertSingleStdinSource,
10
13
  collect,
@@ -14,6 +17,7 @@ import {
14
17
  StdinReader,
15
18
  type ItemTextInputs,
16
19
  } from "../input.ts";
20
+ import { redactGeneratedPassword } from "./generate.ts";
17
21
 
18
22
  interface EditOptions extends ItemTextInputs {
19
23
  name?: string;
@@ -26,8 +30,18 @@ interface EditOptions extends ItemTextInputs {
26
30
  folder?: string;
27
31
  favorite?: boolean;
28
32
  fromJson?: boolean;
33
+ generateLength?: number;
29
34
  }
30
35
 
36
+ const LOGIN_ONLY_FLAGS: Array<[keyof EditOptions, string]> = [
37
+ ["username", "--username"],
38
+ ["password", "--password"],
39
+ ["passwordStdin", "--password-stdin"],
40
+ ["passwordFile", "--password-file"],
41
+ ["passwordEnv", "--password-env"],
42
+ ["passwordGenerate", "--password-generate"],
43
+ ];
44
+
31
45
  export function registerEdit(program: Command): void {
32
46
  program
33
47
  .command("edit")
@@ -41,12 +55,14 @@ export function registerEdit(program: Command): void {
41
55
  .option("--password-stdin", "Read password from stdin")
42
56
  .option("--password-file <path>", "Read password from file (use - for stdin)")
43
57
  .option("--password-env <name>", "Read password from environment variable")
58
+ .option("--password-generate", "Generate the password; it is never printed")
59
+ .option("--generate-length <n>", "Length for --password-generate", parseInt)
44
60
  .option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
45
61
  .option("--add-field <kv>", "Add/update field k=v", collect, [])
46
62
  .option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
47
63
  .option("--add-field-env <kv>", "Add/update field value from env var k=ENV", collect, [])
48
64
  .option("--rm-field <name>", "Remove field by name", collect, [])
49
- .option("--folder <id>", "Move to folder")
65
+ .option("--folder <name|id>", "Move to folder ('none' to unfile)")
50
66
  .option("--favorite", "Set favorite")
51
67
  .option("--no-favorite", "Unset favorite")
52
68
  .option("--from-json", "Read full item JSON from stdin (replaces item)")
@@ -57,6 +73,7 @@ export function registerEdit(program: Command): void {
57
73
  ) {
58
74
  const opts = getGlobalOpts(this);
59
75
  const stdin = new StdinReader();
76
+ let generated = false;
60
77
 
61
78
  const result = await withSession(opts, async () => {
62
79
  // Fetch current item
@@ -73,6 +90,8 @@ export function registerEdit(program: Command): void {
73
90
  }
74
91
  encoded = bwEncode(stdinText);
75
92
  } else {
93
+ assertLoginFlags(localOpts, current);
94
+
76
95
  assertSingleStdinSource(
77
96
  listExplicitStdinInputs({
78
97
  ...localOpts,
@@ -81,7 +100,10 @@ export function registerEdit(program: Command): void {
81
100
  }),
82
101
  );
83
102
 
84
- const textInputs = await resolveItemTextInputs(localOpts, stdin);
103
+ const textInputs = await resolveItemTextInputs(localOpts, stdin, () =>
104
+ generateSecret({ length: localOpts.generateLength }),
105
+ );
106
+ generated = textInputs.passwordGenerated ?? false;
85
107
 
86
108
  const addFields = await resolveFieldSources({
87
109
  inline: localOpts.addField,
@@ -100,7 +122,9 @@ export function registerEdit(program: Command): void {
100
122
  uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
101
123
  addFields,
102
124
  rmFields: localOpts.rmField,
103
- folderId: localOpts.folder,
125
+ folderId: localOpts.folder
126
+ ? await resolveFolderRef(localOpts.folder, opts)
127
+ : undefined,
104
128
  favorite: localOpts.favorite,
105
129
  });
106
130
  encoded = bwEncode(JSON.stringify(patched));
@@ -111,11 +135,35 @@ export function registerEdit(program: Command): void {
111
135
 
112
136
  const updated = JSON.parse(result);
113
137
  if (opts.json) {
114
- emitData(updated, opts);
138
+ emitData(redactGeneratedPassword(updated, generated), opts);
115
139
  } else if (opts.plain) {
116
140
  emitData(updated.id, opts);
117
141
  } else {
118
- emitSuccess(`Updated "${updated.name}" (${updated.id})`, opts);
142
+ const note = generated ? " — password generated (not printed)" : "";
143
+ emitSuccess(`Updated "${updated.name}" (${updated.id})${note}`, opts);
119
144
  }
120
145
  });
121
146
  }
147
+
148
+ /**
149
+ * Same trap as `create`: only a login item has somewhere to put a password, so
150
+ * setting one on a note would be accepted and then quietly lost.
151
+ */
152
+ function assertLoginFlags(
153
+ localOpts: EditOptions,
154
+ current: Record<string, unknown>,
155
+ ): void {
156
+ if (current.type === BwItemType.Login) return;
157
+
158
+ const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
159
+ ([, flag]) => flag,
160
+ );
161
+ if (localOpts.uri.length > 0) used.push("--uri");
162
+
163
+ if (used.length === 0) return;
164
+
165
+ throw new CliError(
166
+ `${used.join(", ")} ${used.length > 1 ? "need" : "needs"} a login item, but "${String(current.name)}" is not one.`,
167
+ ExitCode.BadArgs,
168
+ );
169
+ }
@@ -0,0 +1,34 @@
1
+ import type { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { listFolders } from "../../bw/folders.ts";
4
+ import { emitData } from "../io.ts";
5
+ import { getGlobalOpts } from "../program.ts";
6
+
7
+ /**
8
+ * Folder IDs are the only handle `--folder` used to accept, and nothing else in
9
+ * bwx ever printed one — so this is what makes that flag usable at all.
10
+ */
11
+ export function registerFolders(program: Command): void {
12
+ program
13
+ .command("folders")
14
+ .alias("folder")
15
+ .description("List vault folders")
16
+ .action(async function (this: Command) {
17
+ const opts = getGlobalOpts(this);
18
+ const folders = await listFolders(opts);
19
+
20
+ if (opts.json) {
21
+ emitData(folders, opts);
22
+ return;
23
+ }
24
+
25
+ for (const folder of folders) {
26
+ const id = folder.id ?? "-";
27
+ process.stdout.write(
28
+ opts.plain
29
+ ? `${id}\t${folder.name}\n`
30
+ : `${pc.dim(id)} ${folder.name}\n`,
31
+ );
32
+ }
33
+ });
34
+ }
@@ -0,0 +1,57 @@
1
+ import type { Command } from "commander";
2
+ import { generateSecret, type GenerateOptions } from "../../bw/generate.ts";
3
+ import { CliError, ExitCode } from "../errors.ts";
4
+ import { emitData } from "../io.ts";
5
+ import { getGlobalOpts } from "../program.ts";
6
+
7
+ export function registerGenerate(program: Command): void {
8
+ program
9
+ .command("generate").alias("gen")
10
+ .description("Generate a password or passphrase (local, no vault access)")
11
+ .option("--length <n>", "Password length", parsePositiveInt("--length"))
12
+ .option("--no-uppercase", "Exclude uppercase letters")
13
+ .option("--no-lowercase", "Exclude lowercase letters")
14
+ .option("--no-numbers", "Exclude digits")
15
+ .option("-s, --special", "Include special characters")
16
+ .option("--ambiguous", "Allow ambiguous characters")
17
+ .option("--passphrase", "Generate a passphrase instead")
18
+ .option("--words <n>", "Words in the passphrase", parsePositiveInt("--words"))
19
+ .option("--separator <char>", "Passphrase word separator")
20
+ .option("--capitalize", "Capitalize passphrase words")
21
+ .option("--include-number", "Include a number in the passphrase")
22
+ .action(async function (this: Command, localOpts: GenerateOptions) {
23
+ const opts = getGlobalOpts(this);
24
+ emitData(await generateSecret(localOpts), opts);
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Strips a password bwx generated out of the echoed item. The entire point of
30
+ * `--password-generate` is that the value goes to the vault without passing
31
+ * through the caller's terminal, transcript, or logs — echoing it back in the
32
+ * `--json` result would hand it straight back.
33
+ */
34
+ export function redactGeneratedPassword(
35
+ item: Record<string, unknown>,
36
+ generated: boolean,
37
+ ): Record<string, unknown> {
38
+ if (!generated) return item;
39
+
40
+ const login = item.login as Record<string, unknown> | null | undefined;
41
+ if (!login) return item;
42
+
43
+ return { ...item, login: { ...login, password: "***GENERATED***" } };
44
+ }
45
+
46
+ function parsePositiveInt(flag: string): (raw: string) => number {
47
+ return (raw: string) => {
48
+ const value = Number(raw);
49
+ if (!Number.isInteger(value) || value <= 0) {
50
+ throw new CliError(
51
+ `Invalid ${flag} "${raw}". Use a positive integer.`,
52
+ ExitCode.BadArgs,
53
+ );
54
+ }
55
+ return value;
56
+ };
57
+ }
@@ -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 { readItemField } from "../../bw/fields.ts";
5
+ import { CliError } from "../errors.ts";
5
6
 
6
7
  export function registerGet(program: Command): void {
7
8
  program
@@ -18,11 +19,34 @@ export function registerGet(program: Command): void {
18
19
 
19
20
  program
20
21
  .command("field")
21
- .description("Get a custom field from a vault item")
22
- .argument("<item>", "Item name or ID")
22
+ .description("Get a custom field from a vault item (never a built-in)")
23
+ // Same order as `get`, which is the whole point of this command existing
24
+ // alongside it — the two used to take their arguments back to front.
23
25
  .argument("<name>", "Custom field name")
24
- .action(async function (this: Command, item: string, name: string) {
26
+ .argument("<item>", "Item name or ID")
27
+ .action(async function (this: Command, name: string, item: string) {
25
28
  const opts = getGlobalOpts(this);
26
- emitData(await readItemField(name, item, opts, { customOnly: true }), opts);
29
+ try {
30
+ emitData(
31
+ await readItemField(name, item, opts, { customOnly: true }),
32
+ opts,
33
+ );
34
+ } catch (err) {
35
+ throw withOrderHint(err);
36
+ }
27
37
  });
28
38
  }
39
+
40
+ /**
41
+ * `bwx field` took `<item> <name>` until 0.5.0. A caller using the old order
42
+ * fails somewhere unhelpful — a missing item, or a missing field on the wrong
43
+ * item — so say what changed rather than leaving them to guess.
44
+ */
45
+ function withOrderHint(err: unknown): unknown {
46
+ if (!(err instanceof CliError)) return err;
47
+
48
+ return new CliError(
49
+ `${err.message}\n\nNote: 'bwx field' takes <name> <item> as of 0.5.0 (it was <item> <name>).`,
50
+ err.exitCode,
51
+ );
52
+ }
@@ -1,9 +1,15 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { CliError, ExitCode } from "../errors.ts";
4
- import { filterItems, writeItems, type ItemListOptions } from "../items.ts";
4
+ import {
5
+ filterItems,
6
+ parseLimit,
7
+ writeItems,
8
+ type ItemListOptions,
9
+ } from "../items.ts";
5
10
  import { runBwOrThrow } from "../../bw/runner.ts";
6
11
  import { withSession } from "../../bw/session.ts";
12
+ import { resolveFolderFilter } from "../../bw/folders.ts";
7
13
 
8
14
  export function registerList(program: Command): void {
9
15
  program
@@ -11,28 +17,39 @@ export function registerList(program: Command): void {
11
17
  .alias("ls")
12
18
  .description("List all vault items")
13
19
  .argument("[type]", "Filter by type: logins|notes|cards|identities")
14
- .option("--folder <id>", "Filter by folder ID")
15
- .option("--limit <n>", "Limit results", parseInt)
20
+ .option("--type <type>", "Filter by type (same as the positional form)")
21
+ .option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
22
+ .option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
16
23
  .option("--full-items", "Emit complete item objects (includes secrets)")
17
24
  .action(async function (
18
25
  this: Command,
19
- type: string | undefined,
26
+ positionalType: string | undefined,
20
27
  localOpts: ItemListOptions & { folder?: string },
21
28
  ) {
22
29
  const opts = getGlobalOpts(this);
23
30
 
31
+ if (positionalType && localOpts.type && positionalType !== localOpts.type) {
32
+ throw new CliError(
33
+ `Conflicting types: "${positionalType}" and --type ${localOpts.type}`,
34
+ ExitCode.BadArgs,
35
+ );
36
+ }
37
+
24
38
  const args = ["list", "items"];
25
39
  if (localOpts.folder) {
26
- args.push("--folderid", localOpts.folder);
40
+ args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
27
41
  }
28
42
 
29
43
  const json = await withSession(opts, () => runBwOrThrow(args));
30
- const items = filterItems(JSON.parse(json), { ...localOpts, type });
44
+ const filtered = filterItems(JSON.parse(json), {
45
+ ...localOpts,
46
+ type: positionalType ?? localOpts.type,
47
+ });
31
48
 
32
- if (items.length === 0) {
49
+ if (filtered.total === 0) {
33
50
  throw new CliError("No items found", ExitCode.NotFound);
34
51
  }
35
52
 
36
- writeItems(items, opts, localOpts);
53
+ writeItems(filtered, opts, localOpts);
37
54
  });
38
55
  }
@@ -1,6 +1,6 @@
1
1
  import type { Command } from "commander";
2
2
  import { constants } from "node:os";
3
- import { readItemField } from "../../bw/fields.ts";
3
+ import { readFields } from "../../bw/fields.ts";
4
4
  import { CliError, ExitCode } from "../errors.ts";
5
5
  import { collect } from "../input.ts";
6
6
  import { emitLog } from "../io.ts";
@@ -95,18 +95,23 @@ export function parseEnvSpec(raw: string): EnvSpec {
95
95
  return { name, field: ref.slice(0, colon), item: ref.slice(colon + 1) };
96
96
  }
97
97
 
98
- /** Resolves secrets one at a time: each read spawns `bw`, and unlocks must not race. */
98
+ /**
99
+ * Resolves every secret, paying one `bw` spawn per distinct *item* rather than
100
+ * per variable — pulling a username and password from the same item is a single
101
+ * vault read.
102
+ */
99
103
  async function resolveSecrets(
100
104
  specs: EnvSpec[],
101
105
  opts: GlobalOptions,
102
106
  ): Promise<Record<string, string>> {
103
- const secrets: Record<string, string> = {};
104
-
105
- for (const spec of specs) {
106
- secrets[spec.name] = await readItemField(spec.field, spec.item, opts);
107
- }
108
-
109
- return secrets;
107
+ const values = await readFields(
108
+ specs.map((spec) => ({ field: spec.field, item: spec.item })),
109
+ opts,
110
+ );
111
+
112
+ return Object.fromEntries(
113
+ specs.map((spec, index) => [spec.name, values[index]!]),
114
+ );
110
115
  }
111
116
 
112
117
  /**
@@ -1,18 +1,25 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { CliError, ExitCode } from "../errors.ts";
4
- import { filterItems, writeItems, type ItemListOptions } from "../items.ts";
4
+ import {
5
+ filterItems,
6
+ parseLimit,
7
+ writeItems,
8
+ type ItemListOptions,
9
+ } from "../items.ts";
5
10
  import { runBwOrThrow } from "../../bw/runner.ts";
6
11
  import { withSession } from "../../bw/session.ts";
12
+ import { resolveFolderFilter } from "../../bw/folders.ts";
7
13
 
8
14
  export function registerSearch(program: Command): void {
9
15
  program
10
- .command("search").alias("find")
16
+ .command("search")
17
+ .alias("find")
11
18
  .description("Search vault items")
12
19
  .argument("<query>", "Search query")
13
20
  .option("--type <type>", "Filter by type: login|note|card|identity")
14
- .option("--folder <id>", "Filter by folder ID")
15
- .option("--limit <n>", "Limit results", parseInt)
21
+ .option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
22
+ .option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
16
23
  .option("--full-items", "Emit complete item objects (includes secrets)")
17
24
  .action(async function (
18
25
  this: Command,
@@ -23,19 +30,16 @@ export function registerSearch(program: Command): void {
23
30
 
24
31
  const args = ["list", "items", "--search", query];
25
32
  if (localOpts.folder) {
26
- args.push("--folderid", localOpts.folder);
33
+ args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
27
34
  }
28
35
 
29
36
  const json = await withSession(opts, () => runBwOrThrow(args));
30
- const items = filterItems(JSON.parse(json), localOpts);
37
+ const filtered = filterItems(JSON.parse(json), localOpts);
31
38
 
32
- if (items.length === 0) {
33
- throw new CliError(
34
- `No items matching "${query}"`,
35
- ExitCode.NotFound,
36
- );
39
+ if (filtered.total === 0) {
40
+ throw new CliError(`No items matching "${query}"`, ExitCode.NotFound);
37
41
  }
38
42
 
39
- writeItems(items, opts, localOpts);
43
+ writeItems(filtered, opts, localOpts);
40
44
  });
41
45
  }