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.
@@ -3,8 +3,15 @@ import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData } from "../io.ts";
4
4
  import { recordStatus } from "../../bw/freshness.ts";
5
5
  import { getStatus } from "../../bw/status.ts";
6
+ import { probeSessionState, type SessionState } from "../../bw/session.ts";
6
7
  import pc from "picocolors";
7
8
 
9
+ const SESSION_HINTS: Record<SessionState, string> = {
10
+ valid: "reads work without unlocking",
11
+ stale: "cached session rejected — next read re-unlocks",
12
+ none: "no cached session — next read unlocks",
13
+ };
14
+
8
15
  export function registerStatus(program: Command): void {
9
16
  program
10
17
  .command("status")
@@ -15,8 +22,11 @@ export function registerStatus(program: Command): void {
15
22
  // Reads reuse this for their staleness check instead of paying for `bw status`.
16
23
  recordStatus(status);
17
24
 
25
+ const session = await resolveSessionState(status.status, opts);
26
+ const payload = { ...status, session };
27
+
18
28
  if (opts.json || opts.plain) {
19
- emitData(status, opts);
29
+ emitData(payload, opts);
20
30
  return;
21
31
  }
22
32
 
@@ -27,12 +37,29 @@ export function registerStatus(program: Command): void {
27
37
  ? pc.yellow
28
38
  : pc.red;
29
39
 
40
+ const sessionColor = session === "valid" ? pc.green : pc.yellow;
41
+
30
42
  const lines = [
31
- `${pc.dim("Status:")} ${statusColor(status.status)}`,
32
- `${pc.dim("Email:")} ${status.userEmail ?? "-"}`,
33
- `${pc.dim("Server:")} ${status.serverUrl ?? "-"}`,
34
- `${pc.dim("Synced:")} ${status.lastSync ?? "never"}`,
43
+ `${pc.dim("Status:")} ${statusColor(status.status)}`,
44
+ `${pc.dim("Session:")} ${sessionColor(session)} ${pc.dim(`(${SESSION_HINTS[session]})`)}`,
45
+ `${pc.dim("Email:")} ${status.userEmail ?? "-"}`,
46
+ `${pc.dim("Server:")} ${status.serverUrl ?? "-"}`,
47
+ `${pc.dim("Synced:")} ${status.lastSync ?? "never"}`,
35
48
  ];
36
49
  process.stdout.write(lines.join("\n") + "\n");
37
50
  });
38
51
  }
52
+
53
+ /**
54
+ * Only a locked vault leaves the question open — an unlocked one reads fine, and
55
+ * an unauthenticated one has nothing a session could unlock. Skipping the probe
56
+ * in those cases keeps `status` at a single `bw` call.
57
+ */
58
+ async function resolveSessionState(
59
+ vaultStatus: string,
60
+ opts: ReturnType<typeof getGlobalOpts>,
61
+ ): Promise<SessionState> {
62
+ if (vaultStatus === "unlocked") return "valid";
63
+ if (vaultStatus === "unauthenticated") return "none";
64
+ return probeSessionState(opts);
65
+ }
@@ -0,0 +1,92 @@
1
+ import type { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { runBwOrThrow } from "../../bw/runner.ts";
4
+ import { withSession } from "../../bw/session.ts";
5
+ import { CliError, ExitCode } from "../errors.ts";
6
+ import { emitData, emitSuccess } from "../io.ts";
7
+ import { filterItems, parseLimit, writeItems, type ItemListOptions } from "../items.ts";
8
+ import { getGlobalOpts } from "../program.ts";
9
+ import type { GlobalOptions } from "../types.ts";
10
+
11
+ /**
12
+ * `bwx delete` puts an item in the trash rather than destroying it, which is only
13
+ * an undo if the trash can be read and reversed from here.
14
+ */
15
+ export function registerTrash(program: Command): void {
16
+ program
17
+ .command("trash")
18
+ .description("List items in the trash")
19
+ .option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
20
+ .option("--full-items", "Emit complete item objects (includes secrets)")
21
+ .action(async function (this: Command, localOpts: ItemListOptions) {
22
+ const opts = getGlobalOpts(this);
23
+ const filtered = filterItems(await listTrashed(opts), localOpts);
24
+
25
+ if (filtered.total === 0) {
26
+ throw new CliError("Trash is empty", ExitCode.NotFound);
27
+ }
28
+
29
+ writeItems(filtered, opts, localOpts);
30
+ });
31
+
32
+ program
33
+ .command("restore")
34
+ .description("Restore a deleted item from the trash")
35
+ .argument("<item>", "Item name or ID, as shown by 'bwx trash'")
36
+ .action(async function (this: Command, item: string) {
37
+ const opts = getGlobalOpts(this);
38
+ const trashed = await findTrashed(item, opts);
39
+ const id = String(trashed.id);
40
+ const name = String(trashed.name);
41
+
42
+ await withSession(opts, () => runBwOrThrow(["restore", "item", id]));
43
+
44
+ if (opts.json) {
45
+ emitData({ id, name, restored: true }, opts);
46
+ } else if (opts.plain) {
47
+ emitData(id, opts);
48
+ } else {
49
+ emitSuccess(`Restored "${name}" (${id})`, opts);
50
+ }
51
+ });
52
+ }
53
+
54
+ async function listTrashed(
55
+ opts: GlobalOptions,
56
+ ): Promise<Array<Record<string, unknown>>> {
57
+ const json = await withSession(opts, () =>
58
+ runBwOrThrow(["list", "items", "--trash"]),
59
+ );
60
+ return JSON.parse(json) as Array<Record<string, unknown>>;
61
+ }
62
+
63
+ async function findTrashed(
64
+ item: string,
65
+ opts: GlobalOptions,
66
+ ): Promise<Record<string, unknown>> {
67
+ const trashed = await listTrashed(opts);
68
+
69
+ const matches = trashed.filter(
70
+ (entry) =>
71
+ entry.id === item ||
72
+ String(entry.name ?? "").toLowerCase() === item.toLowerCase(),
73
+ );
74
+
75
+ if (matches.length === 0) {
76
+ throw new CliError(
77
+ `No trashed item matching "${item}". Run 'bwx trash' to list them.`,
78
+ ExitCode.NotFound,
79
+ );
80
+ }
81
+
82
+ if (matches.length > 1) {
83
+ const lines = [`Multiple trashed items match "${item}":\n`];
84
+ for (const match of matches) {
85
+ lines.push(` ${pc.dim(String(match.id))} ${String(match.name)}`);
86
+ }
87
+ lines.push("\nUse a specific ID.");
88
+ throw new CliError(lines.join("\n"), ExitCode.BadArgs);
89
+ }
90
+
91
+ return matches[0]!;
92
+ }
package/src/cli/input.ts CHANGED
@@ -6,6 +6,17 @@ export function collect(val: string, prev: string[]): string[] {
6
6
  return prev;
7
7
  }
8
8
 
9
+ /** Reads a single line from stdin, for the few interactive prompts bwx has. */
10
+ export async function readLine(): Promise<string> {
11
+ const chunks: Uint8Array[] = [];
12
+ for await (const chunk of Bun.stdin.stream()) {
13
+ chunks.push(chunk);
14
+ const text = Buffer.concat(chunks).toString();
15
+ if (text.includes("\n")) return text.split("\n")[0]!.trim();
16
+ }
17
+ return Buffer.concat(chunks).toString().trim();
18
+ }
19
+
9
20
  /**
10
21
  * Lazily reads stdin once. Commands validate that only one explicit stdin source
11
22
  * is selected, but caching also keeps implicit legacy stdin reads predictable.
@@ -25,6 +36,8 @@ export interface TextSource {
25
36
  stdin?: boolean;
26
37
  file?: string;
27
38
  env?: string;
39
+ /** Produces the value instead of reading it — see `--password-generate`. */
40
+ generate?: () => Promise<string>;
28
41
  flagNames: string[];
29
42
  allowEmpty?: boolean;
30
43
  }
@@ -45,11 +58,14 @@ export interface ItemTextInputs {
45
58
  passwordStdin?: boolean;
46
59
  passwordFile?: string;
47
60
  passwordEnv?: string;
61
+ passwordGenerate?: boolean;
48
62
  }
49
63
 
50
64
  export interface ResolvedItemTextInputs {
51
65
  notes?: string;
52
66
  password?: string;
67
+ /** True when the password was generated, so callers can keep it unprinted. */
68
+ passwordGenerated?: boolean;
53
69
  }
54
70
 
55
71
  export interface ExplicitStdinInputs extends ItemTextInputs {
@@ -63,11 +79,13 @@ const PASSWORD_SOURCE_FLAGS = [
63
79
  "--password-stdin",
64
80
  "--password-file",
65
81
  "--password-env",
82
+ "--password-generate",
66
83
  ];
67
84
 
68
85
  export async function resolveItemTextInputs(
69
86
  inputs: ItemTextInputs,
70
87
  stdin: StdinReader,
88
+ generatePassword?: () => Promise<string>,
71
89
  ): Promise<ResolvedItemTextInputs> {
72
90
  assertSingleStdinSource(listExplicitStdinInputs(inputs));
73
91
 
@@ -89,13 +107,21 @@ export async function resolveItemTextInputs(
89
107
  stdin: inputs.passwordStdin,
90
108
  file: inputs.passwordFile,
91
109
  env: inputs.passwordEnv,
110
+ generate:
111
+ inputs.passwordGenerate && generatePassword
112
+ ? generatePassword
113
+ : undefined,
92
114
  flagNames: PASSWORD_SOURCE_FLAGS,
93
115
  },
94
116
  stdin,
95
117
  ),
96
118
  ]);
97
119
 
98
- return { notes, password };
120
+ return {
121
+ notes,
122
+ password,
123
+ passwordGenerated: inputs.passwordGenerate ?? false,
124
+ };
99
125
  }
100
126
 
101
127
  export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
@@ -118,6 +144,7 @@ export async function resolveOptionalTextSource(
118
144
  source.stdin ? "stdin" : null,
119
145
  source.file !== undefined ? "file" : null,
120
146
  source.env !== undefined ? "env" : null,
147
+ source.generate !== undefined ? "generate" : null,
121
148
  ].filter((value): value is string => value !== null);
122
149
 
123
150
  if (selected.length > 1) {
@@ -136,6 +163,8 @@ export async function resolveOptionalTextSource(
136
163
  value = await stdin.read();
137
164
  } else if (source.file !== undefined) {
138
165
  value = await readFileOrStdin(source.file, stdin, source.label);
166
+ } else if (source.generate !== undefined) {
167
+ value = await source.generate();
139
168
  } else {
140
169
  value = readEnvValue(source.env!, source.label);
141
170
  }
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
 
@@ -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