bwx 0.4.1 → 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.
@@ -0,0 +1,96 @@
1
+ import pc from "picocolors";
2
+ import { CliError, ExitCode } from "../cli/errors.ts";
3
+ import type { GlobalOptions } from "../cli/types.ts";
4
+ import { runBwOrThrow } from "./runner.ts";
5
+ import { withSession } from "./session.ts";
6
+
7
+ export interface BwFolder {
8
+ /** `null` is bw's "No Folder" bucket, which every unfiled item belongs to. */
9
+ id: string | null;
10
+ name: string;
11
+ }
12
+
13
+ const UUID_RE =
14
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+
16
+ /** The spelling bw expects on `--folderid` for items that are in no folder. */
17
+ export const NO_FOLDER = "null";
18
+
19
+ export async function listFolders(opts: GlobalOptions): Promise<BwFolder[]> {
20
+ const json = await withSession(opts, () => runBwOrThrow(["list", "folders"]));
21
+ const raw = JSON.parse(json) as Array<Record<string, unknown>>;
22
+ return raw.map((folder) => ({
23
+ id: typeof folder.id === "string" ? folder.id : null,
24
+ name: String(folder.name ?? ""),
25
+ }));
26
+ }
27
+
28
+ /**
29
+ * Turns whatever the caller wrote in `--folder` into an id. IDs pass straight
30
+ * through, so this costs nothing for callers that already have one; names are
31
+ * resolved against the vault because a folder id is not something a human — or
32
+ * an agent reading `bwx list` output — can otherwise discover.
33
+ *
34
+ * Returns `null` for the "no folder" bucket (`--folder none`).
35
+ */
36
+ export async function resolveFolderRef(
37
+ ref: string,
38
+ opts: GlobalOptions,
39
+ ): Promise<string | null> {
40
+ if (UUID_RE.test(ref)) return ref;
41
+ if (ref === NO_FOLDER || ref.toLowerCase() === "none") return null;
42
+
43
+ const folders = await listFolders(opts);
44
+ const named = folders.filter((folder) => folder.id !== null);
45
+
46
+ const exact = named.filter(
47
+ (folder) => folder.name.toLowerCase() === ref.toLowerCase(),
48
+ );
49
+ const matches =
50
+ exact.length > 0
51
+ ? exact
52
+ : named.filter((folder) =>
53
+ folder.name.toLowerCase().includes(ref.toLowerCase()),
54
+ );
55
+
56
+ if (matches.length === 0) {
57
+ throw new CliError(
58
+ `No folder matching "${ref}". Run 'bwx folders' to list them.`,
59
+ ExitCode.NotFound,
60
+ );
61
+ }
62
+
63
+ if (matches.length > 1) {
64
+ throw new CliError(
65
+ formatAmbiguousFolders(ref, matches, opts),
66
+ ExitCode.BadArgs,
67
+ );
68
+ }
69
+
70
+ return matches[0]!.id;
71
+ }
72
+
73
+ /** Resolves `--folder` for read commands, where "no folder" is a real filter. */
74
+ export async function resolveFolderFilter(
75
+ ref: string,
76
+ opts: GlobalOptions,
77
+ ): Promise<string> {
78
+ return (await resolveFolderRef(ref, opts)) ?? NO_FOLDER;
79
+ }
80
+
81
+ function formatAmbiguousFolders(
82
+ ref: string,
83
+ matches: BwFolder[],
84
+ opts: GlobalOptions,
85
+ ): string {
86
+ if (opts.json) {
87
+ return `Multiple folders match "${ref}": ${JSON.stringify(matches)}`;
88
+ }
89
+
90
+ const lines = [`Multiple folders match "${ref}":\n`];
91
+ for (const folder of matches) {
92
+ lines.push(` ${pc.dim(folder.id ?? "-")} ${folder.name}`);
93
+ }
94
+ lines.push(`\nUse the full name or its ID.`);
95
+ return lines.join("\n");
96
+ }
@@ -0,0 +1,91 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+ import { runBwOrThrow } from "./runner.ts";
3
+
4
+ /**
5
+ * `bw generate` is local — it needs no session and no unlocked vault, so a
6
+ * generated secret costs one fast spawn and never touches the network.
7
+ */
8
+
9
+ export interface GenerateOptions {
10
+ length?: number;
11
+ passphrase?: boolean;
12
+ words?: number;
13
+ separator?: string;
14
+ capitalize?: boolean;
15
+ includeNumber?: boolean;
16
+ uppercase?: boolean;
17
+ lowercase?: boolean;
18
+ numbers?: boolean;
19
+ special?: boolean;
20
+ ambiguous?: boolean;
21
+ }
22
+
23
+ /** bw's own defaults, restated here so `--no-numbers` can turn one off without
24
+ * silently disabling the rest: bw needs every wanted character set named. */
25
+ const CHARSET_DEFAULTS = {
26
+ uppercase: true,
27
+ lowercase: true,
28
+ numbers: true,
29
+ special: false,
30
+ } as const;
31
+
32
+ export function buildGenerateArgs(options: GenerateOptions): string[] {
33
+ const args = ["generate"];
34
+
35
+ if (options.passphrase) {
36
+ args.push("--passphrase");
37
+ if (options.words !== undefined) args.push("--words", String(options.words));
38
+ if (options.separator !== undefined) {
39
+ args.push("--separator", options.separator);
40
+ }
41
+ if (options.capitalize) args.push("--capitalize");
42
+ if (options.includeNumber) args.push("--includeNumber");
43
+ return args;
44
+ }
45
+
46
+ const charsets = {
47
+ uppercase: options.uppercase ?? CHARSET_DEFAULTS.uppercase,
48
+ lowercase: options.lowercase ?? CHARSET_DEFAULTS.lowercase,
49
+ numbers: options.numbers ?? CHARSET_DEFAULTS.numbers,
50
+ special: options.special ?? CHARSET_DEFAULTS.special,
51
+ };
52
+
53
+ if (!Object.values(charsets).some(Boolean)) {
54
+ throw new CliError(
55
+ "At least one character set must stay enabled.",
56
+ ExitCode.BadArgs,
57
+ );
58
+ }
59
+
60
+ if (options.length !== undefined) {
61
+ if (!Number.isInteger(options.length) || options.length < 5) {
62
+ throw new CliError(
63
+ `Invalid --length "${options.length}". Use an integer of at least 5.`,
64
+ ExitCode.BadArgs,
65
+ );
66
+ }
67
+ args.push("--length", String(options.length));
68
+ }
69
+
70
+ if (charsets.uppercase) args.push("--uppercase");
71
+ if (charsets.lowercase) args.push("--lowercase");
72
+ if (charsets.numbers) args.push("--number");
73
+ if (charsets.special) args.push("--special");
74
+ if (options.ambiguous) args.push("--ambiguous");
75
+
76
+ return args;
77
+ }
78
+
79
+ export async function generateSecret(
80
+ options: GenerateOptions,
81
+ ): Promise<string> {
82
+ const value = await runBwOrThrow(buildGenerateArgs(options), {
83
+ session: null,
84
+ });
85
+
86
+ if (!value) {
87
+ throw new CliError("bw generate returned nothing", ExitCode.BwError);
88
+ }
89
+
90
+ return value;
91
+ }
package/src/bw/session.ts CHANGED
@@ -60,21 +60,47 @@ async function getMasterPassword(): Promise<string> {
60
60
  return pw;
61
61
  }
62
62
 
63
+ /**
64
+ * Whether reads will work right now without an unlock. `bw status` answers for
65
+ * the vault, not for us: it reports "locked" whenever the vault has no session
66
+ * of its own, even when bwx holds a cached one that works perfectly. Reporting
67
+ * that raw answer sends callers off to unlock a vault they can already read.
68
+ */
69
+ export type SessionState =
70
+ /** Cached session works — reads succeed with no unlock. */
71
+ | "valid"
72
+ /** A session was cached but the vault no longer accepts it. */
73
+ | "stale"
74
+ /** Nothing cached; the next read unlocks. */
75
+ | "none";
76
+
77
+ /** Confirms a token by asking the vault, which is the only authority on it. */
78
+ async function sessionWorks(token: string): Promise<boolean> {
79
+ const check = await runBw(["status"], { session: token });
80
+ if (check.exitCode !== 0) return false;
81
+ try {
82
+ return (
83
+ BwStatusSchema.parse(JSON.parse(check.stdout)).status === "unlocked"
84
+ );
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export async function probeSessionState(
91
+ opts: GlobalOptions,
92
+ ): Promise<SessionState> {
93
+ const cached = getSession() || loadCachedSession(opts);
94
+ if (!cached) return "none";
95
+ return (await sessionWorks(cached)) ? "valid" : "stale";
96
+ }
97
+
63
98
  export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
64
99
  // Try cached session first
65
100
  const cached = getSession() || loadCachedSession(opts);
66
101
  if (cached) {
67
102
  setSession(cached);
68
- // Quick check — try a lightweight command
69
- const check = await runBw(["status"]);
70
- if (check.exitCode === 0) {
71
- try {
72
- const status = BwStatusSchema.parse(JSON.parse(check.stdout));
73
- if (status.status === "unlocked") return;
74
- } catch {
75
- // Fall through
76
- }
77
- }
103
+ if (await sessionWorks(cached)) return;
78
104
  }
79
105
 
80
106
  const status = await getStatus();
@@ -7,6 +7,7 @@ import {
7
7
  loadConfig,
8
8
  saveConfig,
9
9
  } from "../../config/store.ts";
10
+ import { readLine } from "../input.ts";
10
11
 
11
12
  export function registerConfig(program: Command): void {
12
13
  const configCmd = program
@@ -161,13 +162,3 @@ function redactSensitive(value: unknown): unknown {
161
162
  function isSensitiveKey(key: string): boolean {
162
163
  return SENSITIVE_KEY_RE.some((pattern) => pattern.test(key));
163
164
  }
164
-
165
- async function readLine(): Promise<string> {
166
- const chunks: Uint8Array[] = [];
167
- for await (const chunk of Bun.stdin.stream()) {
168
- chunks.push(chunk);
169
- const text = Buffer.concat(chunks).toString();
170
- if (text.includes("\n")) return text.split("\n")[0]!.trim();
171
- }
172
- return Buffer.concat(chunks).toString().trim();
173
- }
@@ -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
+ }