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.
@@ -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
+ }
@@ -1,7 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
- import { emitData } from "../io.ts";
4
- import { readItemField } from "../../bw/fields.ts";
3
+ import { emitData, emitLog } from "../io.ts";
4
+ import { readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
5
+ import { CliError, ExitCode } from "../errors.ts";
6
+ import type { GlobalOptions } from "../types.ts";
7
+
8
+ /** Seconds left below which `--fresh` waits, when the flag is given bare. */
9
+ const DEFAULT_FRESH_SECONDS = 5;
5
10
 
6
11
  export function registerGet(program: Command): void {
7
12
  program
@@ -9,8 +14,26 @@ export function registerGet(program: Command): void {
9
14
  .description("Get a field from a vault item (built-in or custom)")
10
15
  .argument("<field>", "Field: password | username | totp | notes | uri | item | <custom>")
11
16
  .argument("<item>", "Item name or ID")
12
- .action(async function (this: Command, field: string, item: string) {
17
+ .option(
18
+ "--fresh [seconds]",
19
+ `Wait for the next TOTP window if fewer than N seconds remain (default ${DEFAULT_FRESH_SECONDS})`,
20
+ )
21
+ .option("--seed", "Emit the stored TOTP secret instead of a code")
22
+ .action(async function (
23
+ this: Command,
24
+ field: string,
25
+ item: string,
26
+ localOpts: { fresh?: string | boolean; seed?: boolean },
27
+ ) {
13
28
  const opts = getGlobalOpts(this);
29
+
30
+ if (field === "totp") {
31
+ await emitTotp(item, localOpts, opts);
32
+ return;
33
+ }
34
+
35
+ assertTotpOnlyFlags(field, localOpts);
36
+
14
37
  const value = await readItemField(field, item, opts);
15
38
  // `item` is the one field that is itself JSON — emit it as structure.
16
39
  emitData(field === "item" ? JSON.parse(value) : value, opts);
@@ -18,11 +41,111 @@ export function registerGet(program: Command): void {
18
41
 
19
42
  program
20
43
  .command("field")
21
- .description("Get a custom field from a vault item")
22
- .argument("<item>", "Item name or ID")
44
+ .description("Get a custom field from a vault item (never a built-in)")
45
+ // Same order as `get`, which is the whole point of this command existing
46
+ // alongside it — the two used to take their arguments back to front.
23
47
  .argument("<name>", "Custom field name")
24
- .action(async function (this: Command, item: string, name: string) {
48
+ .argument("<item>", "Item name or ID")
49
+ .action(async function (this: Command, name: string, item: string) {
25
50
  const opts = getGlobalOpts(this);
26
- emitData(await readItemField(name, item, opts, { customOnly: true }), opts);
51
+ try {
52
+ emitData(
53
+ await readItemField(name, item, opts, { customOnly: true }),
54
+ opts,
55
+ );
56
+ } catch (err) {
57
+ throw withOrderHint(err);
58
+ }
27
59
  });
28
60
  }
61
+
62
+ /**
63
+ * Emits a TOTP alongside how long it stays valid. The remainder rides in the
64
+ * `--json` envelope's `meta` rather than replacing `data`, so `data` is still
65
+ * the plain code every other field returns and existing parsers keep working.
66
+ */
67
+ async function emitTotp(
68
+ item: string,
69
+ localOpts: { fresh?: string | boolean; seed?: boolean },
70
+ opts: GlobalOptions,
71
+ ): Promise<void> {
72
+ if (localOpts.seed) {
73
+ if (localOpts.fresh !== undefined) {
74
+ throw new CliError(
75
+ "--seed and --fresh cannot be combined: a stored secret has no window.",
76
+ ExitCode.BadArgs,
77
+ );
78
+ }
79
+ emitData(await readTotpSecret(item, opts), opts);
80
+ return;
81
+ }
82
+
83
+ const threshold = parseFreshSeconds(localOpts.fresh);
84
+ let totp = await readTotp(item, opts);
85
+
86
+ if (threshold !== null && totp.secondsRemaining < threshold) {
87
+ const wait = totp.secondsRemaining;
88
+ emitLog(`Waiting ${wait}s for a fresh code…`, opts);
89
+ await Bun.sleep(wait * 1000 + 250);
90
+ totp = await readTotp(item, opts);
91
+ }
92
+
93
+ emitLog(`Valid for ${totp.secondsRemaining}s`, opts);
94
+ emitData(totp.code, opts, {
95
+ secondsRemaining: totp.secondsRemaining,
96
+ period: totp.period,
97
+ });
98
+ }
99
+
100
+ /**
101
+ * `--fresh` is a bare flag by default and takes an explicit threshold when the
102
+ * caller needs longer than {@link DEFAULT_FRESH_SECONDS} to use the code.
103
+ */
104
+ function parseFreshSeconds(raw: string | boolean | undefined): number | null {
105
+ if (raw === undefined || raw === false) return null;
106
+ if (raw === true) return DEFAULT_FRESH_SECONDS;
107
+
108
+ const value = Number(raw.trim());
109
+ if (!Number.isInteger(value) || value < 0) {
110
+ throw new CliError(
111
+ `Invalid --fresh "${raw}". Use a non-negative integer of seconds.`,
112
+ ExitCode.BadArgs,
113
+ );
114
+ }
115
+ return value;
116
+ }
117
+
118
+ /**
119
+ * Accepting `--fresh` on a password would read as "give me a fresh password",
120
+ * which is not what it does. Rejecting it beats quietly ignoring it.
121
+ */
122
+ function assertTotpOnlyFlags(
123
+ field: string,
124
+ localOpts: { fresh?: string | boolean; seed?: boolean },
125
+ ): void {
126
+ const used = [
127
+ localOpts.fresh !== undefined ? "--fresh" : null,
128
+ localOpts.seed ? "--seed" : null,
129
+ ].filter((flag): flag is string => flag !== null);
130
+
131
+ if (used.length > 0) {
132
+ throw new CliError(
133
+ `${used.join(", ")} ${used.length > 1 ? "apply" : "applies"} to 'get totp', not '${field}'.`,
134
+ ExitCode.BadArgs,
135
+ );
136
+ }
137
+ }
138
+
139
+ /**
140
+ * `bwx field` took `<item> <name>` until 0.5.0. A caller using the old order
141
+ * fails somewhere unhelpful — a missing item, or a missing field on the wrong
142
+ * item — so say what changed rather than leaving them to guess.
143
+ */
144
+ function withOrderHint(err: unknown): unknown {
145
+ if (!(err instanceof CliError)) return err;
146
+
147
+ return new CliError(
148
+ `${err.message}\n\nNote: 'bwx field' takes <name> <item> as of 0.5.0 (it was <item> <name>).`,
149
+ err.exitCode,
150
+ );
151
+ }
@@ -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
  }
@@ -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
+ }