bwx 0.4.1 → 0.6.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/cli/input.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
2
+ import { normalizeTotpInput } from "../bw/totp.ts";
2
3
  import { CliError, ExitCode } from "./errors.ts";
3
4
 
4
5
  export function collect(val: string, prev: string[]): string[] {
@@ -6,6 +7,17 @@ export function collect(val: string, prev: string[]): string[] {
6
7
  return prev;
7
8
  }
8
9
 
10
+ /** Reads a single line from stdin, for the few interactive prompts bwx has. */
11
+ export async function readLine(): Promise<string> {
12
+ const chunks: Uint8Array[] = [];
13
+ for await (const chunk of Bun.stdin.stream()) {
14
+ chunks.push(chunk);
15
+ const text = Buffer.concat(chunks).toString();
16
+ if (text.includes("\n")) return text.split("\n")[0]!.trim();
17
+ }
18
+ return Buffer.concat(chunks).toString().trim();
19
+ }
20
+
9
21
  /**
10
22
  * Lazily reads stdin once. Commands validate that only one explicit stdin source
11
23
  * is selected, but caching also keeps implicit legacy stdin reads predictable.
@@ -25,6 +37,8 @@ export interface TextSource {
25
37
  stdin?: boolean;
26
38
  file?: string;
27
39
  env?: string;
40
+ /** Produces the value instead of reading it — see `--password-generate`. */
41
+ generate?: () => Promise<string>;
28
42
  flagNames: string[];
29
43
  allowEmpty?: boolean;
30
44
  }
@@ -45,11 +59,20 @@ export interface ItemTextInputs {
45
59
  passwordStdin?: boolean;
46
60
  passwordFile?: string;
47
61
  passwordEnv?: string;
62
+ passwordGenerate?: boolean;
63
+ totp?: string;
64
+ totpStdin?: boolean;
65
+ totpFile?: string;
66
+ totpEnv?: string;
48
67
  }
49
68
 
50
69
  export interface ResolvedItemTextInputs {
51
70
  notes?: string;
52
71
  password?: string;
72
+ /** True when the password was generated, so callers can keep it unprinted. */
73
+ passwordGenerated?: boolean;
74
+ /** Normalized and validated by {@link normalizeTotpInput} before it is stored. */
75
+ totp?: string;
53
76
  }
54
77
 
55
78
  export interface ExplicitStdinInputs extends ItemTextInputs {
@@ -63,15 +86,18 @@ const PASSWORD_SOURCE_FLAGS = [
63
86
  "--password-stdin",
64
87
  "--password-file",
65
88
  "--password-env",
89
+ "--password-generate",
66
90
  ];
91
+ const TOTP_SOURCE_FLAGS = ["--totp", "--totp-stdin", "--totp-file", "--totp-env"];
67
92
 
68
93
  export async function resolveItemTextInputs(
69
94
  inputs: ItemTextInputs,
70
95
  stdin: StdinReader,
96
+ generatePassword?: () => Promise<string>,
71
97
  ): Promise<ResolvedItemTextInputs> {
72
98
  assertSingleStdinSource(listExplicitStdinInputs(inputs));
73
99
 
74
- const [notes, password] = await Promise.all([
100
+ const [notes, password, totp] = await Promise.all([
75
101
  resolveOptionalTextSource(
76
102
  {
77
103
  label: "notes",
@@ -89,13 +115,35 @@ export async function resolveItemTextInputs(
89
115
  stdin: inputs.passwordStdin,
90
116
  file: inputs.passwordFile,
91
117
  env: inputs.passwordEnv,
118
+ generate:
119
+ inputs.passwordGenerate && generatePassword
120
+ ? generatePassword
121
+ : undefined,
92
122
  flagNames: PASSWORD_SOURCE_FLAGS,
93
123
  },
94
124
  stdin,
95
125
  ),
126
+ resolveOptionalTextSource(
127
+ {
128
+ label: "TOTP secret",
129
+ inline: inputs.totp,
130
+ stdin: inputs.totpStdin,
131
+ file: inputs.totpFile,
132
+ env: inputs.totpEnv,
133
+ flagNames: TOTP_SOURCE_FLAGS,
134
+ },
135
+ stdin,
136
+ ),
96
137
  ]);
97
138
 
98
- return { notes, password };
139
+ return {
140
+ notes,
141
+ password,
142
+ passwordGenerated: inputs.passwordGenerate ?? false,
143
+ // Validated here rather than at the call sites so every write path gets
144
+ // the check: a malformed seed is only ever noticed at the login it breaks.
145
+ totp: totp === undefined ? undefined : normalizeTotpInput(totp),
146
+ };
99
147
  }
100
148
 
101
149
  export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
@@ -103,6 +151,8 @@ export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
103
151
  inputs.notesFile === "-" ? "--notes-file -" : null,
104
152
  inputs.passwordStdin ? "--password-stdin" : null,
105
153
  inputs.passwordFile === "-" ? "--password-file -" : null,
154
+ inputs.totpStdin ? "--totp-stdin" : null,
155
+ inputs.totpFile === "-" ? "--totp-file -" : null,
106
156
  ...(inputs.fieldFileFlag && inputs.fieldFiles
107
157
  ? stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles)
108
158
  : []),
@@ -118,6 +168,7 @@ export async function resolveOptionalTextSource(
118
168
  source.stdin ? "stdin" : null,
119
169
  source.file !== undefined ? "file" : null,
120
170
  source.env !== undefined ? "env" : null,
171
+ source.generate !== undefined ? "generate" : null,
121
172
  ].filter((value): value is string => value !== null);
122
173
 
123
174
  if (selected.length > 1) {
@@ -136,6 +187,8 @@ export async function resolveOptionalTextSource(
136
187
  value = await stdin.read();
137
188
  } else if (source.file !== undefined) {
138
189
  value = await readFileOrStdin(source.file, stdin, source.label);
190
+ } else if (source.generate !== undefined) {
191
+ value = await source.generate();
139
192
  } else {
140
193
  value = readEnvValue(source.env!, source.label);
141
194
  }
package/src/cli/io.ts CHANGED
@@ -2,9 +2,19 @@ import pc from "picocolors";
2
2
  import { CliError, ExitCode } from "./errors.ts";
3
3
  import type { GlobalOptions } from "./types.ts";
4
4
 
5
- export function emitData(value: unknown, opts: GlobalOptions): void {
5
+ /**
6
+ * `meta` rides along in the JSON envelope for facts about the response itself
7
+ * (truncation, counts) that a machine caller cannot recover from `data` alone.
8
+ * It is omitted entirely when absent, so `{ data }` stays the stable shape.
9
+ */
10
+ export function emitData(
11
+ value: unknown,
12
+ opts: GlobalOptions,
13
+ meta?: Record<string, unknown>,
14
+ ): void {
6
15
  if (opts.json) {
7
- process.stdout.write(JSON.stringify({ data: value }, null, 2) + "\n");
16
+ const payload = meta ? { data: value, meta } : { data: value };
17
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
8
18
  } else if (opts.plain) {
9
19
  if (typeof value === "string") {
10
20
  process.stdout.write(value + "\n");
package/src/cli/items.ts CHANGED
@@ -2,9 +2,17 @@ import pc from "picocolors";
2
2
  import { summarizeItem, type ItemSummary } from "../bw/items.ts";
3
3
  import { BW_TYPE_FROM_NAME } from "../bw/types.ts";
4
4
  import { CliError, ExitCode } from "./errors.ts";
5
- import { emitData } from "./io.ts";
5
+ import { emitData, emitWarn } from "./io.ts";
6
6
  import type { GlobalOptions } from "./types.ts";
7
7
 
8
+ /**
9
+ * Listings are capped by default: an uncapped `bwx list` returns the whole vault,
10
+ * which floods an agent's context and, with `--full-items`, dumps every secret in
11
+ * one call. `--limit 0` is the explicit opt-out, and truncation is always
12
+ * reported so a capped listing can never be mistaken for a complete one.
13
+ */
14
+ export const DEFAULT_LIMIT = 50;
15
+
8
16
  export interface ItemListOptions {
9
17
  type?: string;
10
18
  limit?: number;
@@ -12,10 +20,29 @@ export interface ItemListOptions {
12
20
  fullItems?: boolean;
13
21
  }
14
22
 
23
+ export interface FilteredItems {
24
+ items: Array<Record<string, unknown>>;
25
+ /** Items matching the filters, before the limit was applied. */
26
+ total: number;
27
+ }
28
+
29
+ /** Commander option parser: rejects garbage instead of silently ignoring it. */
30
+ export function parseLimit(raw: string): number {
31
+ // `Number("")` and `Number(" ")` are 0, which would read as "no limit".
32
+ const value = raw.trim() === "" ? Number.NaN : Number(raw);
33
+ if (!Number.isInteger(value) || value < 0) {
34
+ throw new CliError(
35
+ `Invalid --limit "${raw}". Use a non-negative integer (0 for no limit).`,
36
+ ExitCode.BadArgs,
37
+ );
38
+ }
39
+ return value;
40
+ }
41
+
15
42
  export function filterItems(
16
43
  items: Array<Record<string, unknown>>,
17
44
  options: ItemListOptions,
18
- ): Array<Record<string, unknown>> {
45
+ ): FilteredItems {
19
46
  let result = items;
20
47
 
21
48
  if (options.type) {
@@ -29,11 +56,10 @@ export function filterItems(
29
56
  result = result.filter((item) => item.type === typeVal);
30
57
  }
31
58
 
32
- if (options.limit && options.limit > 0) {
33
- result = result.slice(0, options.limit);
34
- }
59
+ const total = result.length;
60
+ const limit = options.limit ?? DEFAULT_LIMIT;
35
61
 
36
- return result;
62
+ return { items: limit > 0 ? result.slice(0, limit) : result, total };
37
63
  }
38
64
 
39
65
  /**
@@ -41,14 +67,32 @@ export function filterItems(
41
67
  * the same reduced projection, so asking for machine-readable output is a
42
68
  * serialization choice and never widens data access. `--full-items` is the
43
69
  * explicit opt-in for callers that genuinely need the complete objects.
70
+ *
71
+ * A truncated listing reports what it dropped through every channel — a stderr
72
+ * warning that survives `--json`, plus `meta` in the JSON envelope — because a
73
+ * silently capped list reads exactly like a complete one.
44
74
  */
45
75
  export function writeItems(
46
- items: Array<Record<string, unknown>>,
76
+ filtered: FilteredItems,
47
77
  opts: GlobalOptions,
48
78
  options: ItemListOptions = {},
49
79
  ): void {
80
+ const { items, total } = filtered;
81
+ const omitted = total - items.length;
82
+
83
+ if (omitted > 0) {
84
+ emitWarn(
85
+ `Showing ${items.length} of ${total} items — pass --limit 0 for all.`,
86
+ opts,
87
+ );
88
+ }
89
+
50
90
  if (opts.json) {
51
- emitData(options.fullItems ? items : items.map(summarizeItem), opts);
91
+ emitData(
92
+ options.fullItems ? items : items.map(summarizeItem),
93
+ opts,
94
+ { total, shown: items.length, truncated: omitted > 0 },
95
+ );
52
96
  return;
53
97
  }
54
98
 
@@ -63,12 +107,14 @@ export function writeItems(
63
107
  }
64
108
 
65
109
  function plainLine(summary: ItemSummary): string {
66
- return `${summary.id}\t${summary.type}\t${summary.name}\t${summary.username ?? "-"}`;
110
+ const totp = summary.hasTotp ? "totp" : "-";
111
+ return `${summary.id}\t${summary.type}\t${summary.name}\t${summary.username ?? "-"}\t${totp}`;
67
112
  }
68
113
 
69
114
  function humanLine(summary: ItemSummary): string {
70
115
  const userPart = summary.username ? pc.dim(` (${summary.username})`) : "";
71
- return `${pc.dim(summary.id)} ${pc.cyan(summary.type)} ${summary.name}${userPart}`;
116
+ const totpPart = summary.hasTotp ? " " + pc.yellow("2fa") : "";
117
+ return `${pc.dim(summary.id)} ${pc.cyan(summary.type)} ${summary.name}${userPart}${totpPart}`;
72
118
  }
73
119
 
74
120
  function writeLines<T>(values: T[], format: (value: T) => string): void {
@@ -17,6 +17,9 @@ import { registerDoctor } from "./commands/doctor.ts";
17
17
  import { registerList } from "./commands/list.ts";
18
18
  import { registerAttach } from "./commands/attach.ts";
19
19
  import { registerRun } from "./commands/run.ts";
20
+ import { registerGenerate } from "./commands/generate.ts";
21
+ import { registerFolders } from "./commands/folders.ts";
22
+ import { registerTrash } from "./commands/trash.ts";
20
23
 
21
24
  export function buildProgram(): Command {
22
25
  const program = new Command();
@@ -59,8 +62,11 @@ export function buildProgram(): Command {
59
62
  registerCreate(program);
60
63
  registerEdit(program);
61
64
  registerDelete(program);
65
+ registerTrash(program);
62
66
  registerAttach(program);
63
67
  registerList(program);
68
+ registerFolders(program);
69
+ registerGenerate(program);
64
70
  registerConfig(program);
65
71
  registerDoctor(program);
66
72