bwx 0.8.0 → 0.10.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.
@@ -19,9 +19,16 @@ import {
19
19
  StdinReader,
20
20
  type ItemTextInputs,
21
21
  } from "../input.ts";
22
+ import {
23
+ assertCardIntent,
24
+ registerCardOptions,
25
+ resolveCardPatch,
26
+ usedCardOnlyFlags,
27
+ type CardOptionInputs,
28
+ } from "../card-options.ts";
22
29
  import { redactGeneratedPassword } from "./generate.ts";
23
30
 
24
- interface CreateOptions extends ItemTextInputs {
31
+ interface CreateOptions extends ItemTextInputs, CardOptionInputs {
25
32
  type: string;
26
33
  name?: string;
27
34
  username?: string;
@@ -35,7 +42,10 @@ interface CreateOptions extends ItemTextInputs {
35
42
  generateLength?: number;
36
43
  }
37
44
 
38
- /** Flags a secure note has nowhere to store — see {@link assertLoginFlags}. */
45
+ const ITEM_TYPES = ["login", "note", "card"] as const;
46
+ type CreateItemType = (typeof ITEM_TYPES)[number];
47
+
48
+ /** Flags only a login has anywhere to store — see {@link assertTypeFlags}. */
39
49
  const LOGIN_ONLY_FLAGS: Array<[keyof CreateOptions, string]> = [
40
50
  ["username", "--username"],
41
51
  ...loginOnlyTextInputFlags(),
@@ -45,11 +55,12 @@ export function registerCreate(program: Command): void {
45
55
  const command = program
46
56
  .command("create").alias("add")
47
57
  .description("Create a new vault item")
48
- .option("--type <type>", "Item type: login|note", "note")
58
+ .option("--type <type>", "Item type: login|note|card", "note")
49
59
  .option("--name <name>", "Item name")
50
60
  .option("--username <user>", "Username (login items only)");
51
61
 
52
- registerItemTextOptions(command)
62
+ registerItemTextOptions(command);
63
+ registerCardOptions(command)
53
64
  .option("--uri <url>", "URI (repeatable)", collect, [])
54
65
  .option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
55
66
  .option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
@@ -84,14 +95,16 @@ export function registerCreate(program: Command): void {
84
95
  throw new CliError("--name is required", ExitCode.BadArgs);
85
96
  }
86
97
 
87
- if (localOpts.type !== "login" && localOpts.type !== "note") {
98
+ if (!ITEM_TYPES.includes(localOpts.type as CreateItemType)) {
88
99
  throw new CliError(
89
- `Invalid type "${localOpts.type}". Valid: login, note`,
100
+ `Invalid type "${localOpts.type}". Valid: ${ITEM_TYPES.join(", ")}`,
90
101
  ExitCode.BadArgs,
91
102
  );
92
103
  }
104
+ const type = localOpts.type as CreateItemType;
93
105
 
94
- assertLoginFlags(localOpts);
106
+ assertTypeFlags(localOpts, type);
107
+ assertCardIntent(localOpts);
95
108
 
96
109
  const explicitStdinSources = listExplicitStdinInputs({
97
110
  ...localOpts,
@@ -126,13 +139,17 @@ export function registerCreate(program: Command): void {
126
139
  });
127
140
 
128
141
  const item = buildNewItem({
129
- type: localOpts.type,
142
+ type,
130
143
  name: localOpts.name,
131
144
  notes: notes ?? null,
132
145
  username: localOpts.username ?? null,
133
146
  password: textInputs.password ?? null,
134
147
  totp: textInputs.totp ?? null,
135
148
  uris: localOpts.uri,
149
+ card:
150
+ type === "card"
151
+ ? resolveCardPatch(localOpts, textInputs)
152
+ : undefined,
136
153
  fields,
137
154
  folderId: localOpts.folder
138
155
  ? await resolveFolderRef(localOpts.folder, opts)
@@ -160,21 +177,26 @@ export function registerCreate(program: Command): void {
160
177
  }
161
178
 
162
179
  /**
163
- * A secure note has no login object, so `buildNewItem` drops usernames,
164
- * passwords, and URIs on the floor. Accepting them silently means a caller can
165
- * "store" a credential, get exit 0, and end up with an item that never held it.
180
+ * `buildNewItem` writes only the object the item type has — `login` for a login,
181
+ * `card` for a card — so any other type's values would be dropped on the floor.
182
+ * Accepting them silently means a caller can "store" a credential, get exit 0,
183
+ * and end up with an item that never held it.
166
184
  */
167
- function assertLoginFlags(localOpts: CreateOptions): void {
168
- if (localOpts.type === "login") return;
169
-
170
- const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
185
+ function assertTypeFlags(localOpts: CreateOptions, type: CreateItemType): void {
186
+ const login = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
171
187
  ([, flag]) => flag,
172
188
  );
173
- if (localOpts.uri.length > 0) used.push("--uri");
189
+ if (localOpts.uri.length > 0) login.push("--uri");
190
+
191
+ const required: Array<[CreateItemType, string[]]> = [
192
+ ["login", login],
193
+ ["card", usedCardOnlyFlags(localOpts)],
194
+ ];
174
195
 
175
- if (used.length > 0) {
196
+ for (const [needed, used] of required) {
197
+ if (needed === type || used.length === 0) continue;
176
198
  throw new CliError(
177
- `${used.join(", ")} ${used.length > 1 ? "need" : "needs"} --type login (a note cannot store them).`,
199
+ `${used.join(", ")} ${used.length > 1 ? "need" : "needs"} --type ${needed} (a ${type} cannot store them).`,
178
200
  ExitCode.BadArgs,
179
201
  );
180
202
  }
@@ -7,7 +7,7 @@ import { withSession } from "../../bw/session.ts";
7
7
  import { patchItem, bwEncode } from "../../bw/encoding.ts";
8
8
  import { generateSecret } from "../../bw/generate.ts";
9
9
  import { resolveFolderRef } from "../../bw/folders.ts";
10
- import { BwItemType } from "../../bw/types.ts";
10
+ import { BwItemType, itemTypeLabel, type BwCard } from "../../bw/types.ts";
11
11
  import {
12
12
  assertSingleStdinSource,
13
13
  collect,
@@ -21,9 +21,18 @@ import {
21
21
  TOTP_FAMILY,
22
22
  type ItemTextInputs,
23
23
  } from "../input.ts";
24
+ import {
25
+ assertCardIntent,
26
+ registerCardOptions,
27
+ registerCardRemovalOptions,
28
+ resolveCardPatch,
29
+ usedCardOnlyFlags,
30
+ type CardOptionInputs,
31
+ type CardRemovalInputs,
32
+ } from "../card-options.ts";
24
33
  import { redactGeneratedPassword } from "./generate.ts";
25
34
 
26
- interface EditOptions extends ItemTextInputs {
35
+ interface EditOptions extends ItemTextInputs, CardOptionInputs, CardRemovalInputs {
27
36
  name?: string;
28
37
  username?: string;
29
38
  uri: string[];
@@ -52,8 +61,12 @@ export function registerEdit(program: Command): void {
52
61
  .option("--name <name>", "New name")
53
62
  .option("--username <user>", "New username");
54
63
 
55
- registerItemTextOptions(command)
56
- .option("--rm-totp", "Remove the stored TOTP secret")
64
+ registerItemTextOptions(command).option(
65
+ "--rm-totp",
66
+ "Remove the stored TOTP secret",
67
+ );
68
+ registerCardOptions(command);
69
+ registerCardRemovalOptions(command)
57
70
  .option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
58
71
  .option("--add-field <kv>", "Add/update field k=v", collect, [])
59
72
  .option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
@@ -87,8 +100,9 @@ export function registerEdit(program: Command): void {
87
100
  }
88
101
  encoded = bwEncode(stdinText);
89
102
  } else {
90
- assertLoginFlags(localOpts, current);
103
+ assertTypeFlags(localOpts, current);
91
104
  assertTotpIntent(localOpts);
105
+ assertCardIntent(localOpts);
92
106
 
93
107
  assertSingleStdinSource(
94
108
  listExplicitStdinInputs({
@@ -119,6 +133,10 @@ export function registerEdit(program: Command): void {
119
133
  password: textInputs.password,
120
134
  totp: localOpts.rmTotp ? null : textInputs.totp,
121
135
  uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
136
+ card:
137
+ current.type === BwItemType.Card
138
+ ? resolveCardPatch(localOpts, textInputs, current.card as BwCard | null)
139
+ : undefined,
122
140
  addFields,
123
141
  rmFields: localOpts.rmField,
124
142
  folderId: localOpts.folder
@@ -162,24 +180,29 @@ function assertTotpIntent(localOpts: EditOptions): void {
162
180
  }
163
181
 
164
182
  /**
165
- * Same trap as `create`: only a login item has somewhere to put a password, so
166
- * setting one on a note would be accepted and then quietly lost.
183
+ * Same trap as `create`: only a login has somewhere to put a password and only a
184
+ * card somewhere to put a card number, so setting either on another type would
185
+ * be accepted and then quietly lost.
167
186
  */
168
- function assertLoginFlags(
187
+ function assertTypeFlags(
169
188
  localOpts: EditOptions,
170
189
  current: Record<string, unknown>,
171
190
  ): void {
172
- if (current.type === BwItemType.Login) return;
173
-
174
- const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
191
+ const login = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
175
192
  ([, flag]) => flag,
176
193
  );
177
- if (localOpts.uri.length > 0) used.push("--uri");
194
+ if (localOpts.uri.length > 0) login.push("--uri");
178
195
 
179
- if (used.length === 0) return;
196
+ const required: Array<[number, string[]]> = [
197
+ [BwItemType.Login, login],
198
+ [BwItemType.Card, usedCardOnlyFlags(localOpts)],
199
+ ];
180
200
 
181
- throw new CliError(
182
- `${used.join(", ")} ${used.length > 1 ? "need" : "needs"} a login item, but "${String(current.name)}" is not one.`,
183
- ExitCode.BadArgs,
184
- );
201
+ for (const [needed, used] of required) {
202
+ if (needed === current.type || used.length === 0) continue;
203
+ throw new CliError(
204
+ `${used.join(", ")} ${used.length > 1 ? "need" : "needs"} a ${itemTypeLabel(needed)} item, but "${String(current.name)}" is a ${itemTypeLabel(Number(current.type))}.`,
205
+ ExitCode.BadArgs,
206
+ );
207
+ }
185
208
  }
@@ -1,9 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData, emitLog, emitRaw } from "../io.ts";
4
- import { BUILTIN_FIELDS, readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
4
+ import {
5
+ BUILTIN_FIELDS, readItemField, readTotp, readTotpSecret, type FieldRequest,
6
+ } from "../../bw/fields.ts";
5
7
  import { CARD_FIELDS } from "../../bw/cards.ts";
6
8
  import { CliError, ExitCode } from "../errors.ts";
9
+ import { parseFieldReference, REFERENCE_PREFIX } from "../../bw/references.ts";
7
10
  import type { GlobalOptions } from "../types.ts";
8
11
 
9
12
  /** Seconds left below which `--fresh` waits, when the flag is given bare. */
@@ -22,12 +25,12 @@ interface GetOptions {
22
25
  export function registerGet(program: Command): void {
23
26
  program
24
27
  .command("get")
25
- .description("Get a field from a vault item (built-in or custom)")
28
+ .description("Get a field from a vault item or a discovered bwx:// reference")
26
29
  .argument(
27
30
  "<field>",
28
- `Field: ${BUILTIN_FIELDS.join(" | ")} | <custom>. Card items also take: ${CARD_FIELDS.join(" | ")}`,
31
+ `Field or bwx:// ref: ${BUILTIN_FIELDS.join(" | ")} | <custom>. Card items also take: ${CARD_FIELDS.join(" | ")}`,
29
32
  )
30
- .argument("<item>", "Item name or ID")
33
+ .argument("[item]", "Item name or ID (omit when the field is a bwx:// reference)")
31
34
  .option(
32
35
  "--fresh [seconds]",
33
36
  `Wait for the next TOTP window if fewer than N seconds remain (default ${DEFAULT_FRESH_SECONDS})`,
@@ -37,26 +40,28 @@ export function registerGet(program: Command): void {
37
40
  .action(async function (
38
41
  this: Command,
39
42
  field: string,
40
- item: string,
43
+ item: string | undefined,
41
44
  localOpts: GetOptions,
42
45
  ) {
43
46
  const opts = getGlobalOpts(this);
44
47
  assertRawUsable(localOpts, opts);
48
+ const request = parseGetRequest(field, item);
45
49
 
46
- if (field === "totp") {
47
- await emitTotp(item, localOpts, opts);
50
+ if (request.field === "totp" && !request.options?.customOnly) {
51
+ await emitTotp(request.item, localOpts, opts, request.options?.exactItemId);
48
52
  return;
49
53
  }
50
54
 
51
- assertTotpOnlyFlags(field, localOpts);
55
+ assertTotpOnlyFlags(request.field, localOpts);
52
56
 
53
- const value = await readItemField(field, item, opts, {
57
+ const value = await readItemField(request.field, request.item, opts, {
58
+ ...request.options,
54
59
  verbatim: localOpts.raw,
55
60
  });
56
61
 
57
62
  // `item` is the one field that is itself JSON, so it is emitted as
58
63
  // structure — except under `--raw`, which promised the bytes `bw` gave us.
59
- if (field === "item" && !localOpts.raw) {
64
+ if (request.field === "item" && !request.options?.customOnly && !localOpts.raw) {
60
65
  emitData(JSON.parse(value), opts);
61
66
  return;
62
67
  }
@@ -90,6 +95,20 @@ export function registerGet(program: Command): void {
90
95
  });
91
96
  }
92
97
 
98
+ function parseGetRequest(field: string, item: string | undefined): FieldRequest {
99
+ if (field.startsWith(REFERENCE_PREFIX)) {
100
+ const reference = parseFieldReference(field);
101
+ if (item !== undefined) {
102
+ throw new CliError("A field reference already contains the item ID; omit <item>.", ExitCode.BadArgs);
103
+ }
104
+ return reference;
105
+ }
106
+ if (item === undefined) {
107
+ throw new CliError("Expected get <field> <item> or get <ref>.", ExitCode.BadArgs);
108
+ }
109
+ return { field, item };
110
+ }
111
+
93
112
  /**
94
113
  * Emits one field value. `--raw` writes it alone, so piping a secret into a
95
114
  * consumer that reads stdin literally does not append bwx's newline to it;
@@ -131,6 +150,7 @@ async function emitTotp(
131
150
  item: string,
132
151
  localOpts: GetOptions,
133
152
  opts: GlobalOptions,
153
+ exactItemId = false,
134
154
  ): Promise<void> {
135
155
  if (localOpts.seed) {
136
156
  if (localOpts.fresh !== undefined) {
@@ -139,11 +159,12 @@ async function emitTotp(
139
159
  ExitCode.BadArgs,
140
160
  );
141
161
  }
142
- emitValue(await readTotpSecret(item, opts), localOpts, opts);
162
+ emitValue(await readTotpSecret(item, opts, exactItemId), localOpts, opts);
143
163
  return;
144
164
  }
145
165
 
146
166
  const totp = await readTotp(item, opts, {
167
+ exactItemId,
147
168
  minSecondsRemaining: parseFreshSeconds(localOpts.fresh),
148
169
  onWait: (seconds) =>
149
170
  emitLog(`Waiting ${seconds}s for a fresh code…`, opts),
@@ -1,22 +1,21 @@
1
1
  import type { Command } from "commander";
2
2
  import { constants } from "node:os";
3
- import { readFields } from "../../bw/fields.ts";
3
+ import { readFields, type FieldRequest } from "../../bw/fields.ts";
4
+ import { parseFieldReference, REFERENCE_PREFIX } from "../../bw/references.ts";
4
5
  import { CliError, ExitCode } from "../errors.ts";
5
6
  import { collect } from "../input.ts";
6
7
  import { emitLog } from "../io.ts";
7
8
  import { getGlobalOpts } from "../program.ts";
8
9
  import type { GlobalOptions } from "../types.ts";
9
10
 
10
- /** `NAME=<field>:<item>` — one secret to place in the child's environment. */
11
- export interface EnvSpec {
11
+ /** `NAME=<field>:<item>` or `NAME=<ref>` — one secret for the child's environment. */
12
+ export interface EnvSpec extends FieldRequest {
12
13
  name: string;
13
- field: string;
14
- item: string;
15
14
  }
16
15
 
17
16
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
17
 
19
- const SPEC_HINT = "Expected --env NAME=<field>:<item>, e.g. --env DB_PASS=password:'Prod DB'";
18
+ const SPEC_HINT = "Expected --env NAME=<field>:<item> or NAME=<ref>, e.g. --env DB_PASS=password:'Prod DB'";
20
19
 
21
20
  export function registerRun(program: Command): void {
22
21
  program
@@ -25,7 +24,7 @@ export function registerRun(program: Command): void {
25
24
  .argument("<command...>", "Command to run — use -- before it if it takes its own flags")
26
25
  .option(
27
26
  "--env <spec>",
28
- "Inject a secret as NAME=<field>:<item> (repeatable)",
27
+ "Inject a secret as NAME=<field>:<item> or NAME=<ref> (repeatable)",
29
28
  collect,
30
29
  [],
31
30
  )
@@ -71,7 +70,8 @@ export function parseEnvSpecs(raws: string[]): EnvSpec[] {
71
70
  * Splits `NAME=<field>:<item>` on the first `=` and then the first `:`, so item
72
71
  * names may contain colons (`password:http://my.router`) while field names may not.
73
72
  * Fields resolve exactly as in `bwx get`: built-ins first, then card fields on a card
74
- * item, otherwise custom fields.
73
+ * item, otherwise custom fields. A bwx:// reference supplies an exact ID and an
74
+ * explicit field kind, bypassing the legacy colon-separated grammar.
75
75
  */
76
76
  export function parseEnvSpec(raw: string): EnvSpec {
77
77
  const eq = raw.indexOf("=");
@@ -88,6 +88,9 @@ export function parseEnvSpec(raw: string): EnvSpec {
88
88
  }
89
89
 
90
90
  const ref = raw.slice(eq + 1);
91
+ if (ref.startsWith(REFERENCE_PREFIX)) {
92
+ return { name, ...parseFieldReference(ref) };
93
+ }
91
94
  const colon = ref.indexOf(":");
92
95
  if (colon <= 0 || colon === ref.length - 1) {
93
96
  throw new CliError(`Invalid --env "${raw}". ${SPEC_HINT}`, ExitCode.BadArgs);
@@ -105,10 +108,7 @@ async function resolveSecrets(
105
108
  specs: EnvSpec[],
106
109
  opts: GlobalOptions,
107
110
  ): Promise<Record<string, string>> {
108
- const values = await readFields(
109
- specs.map((spec) => ({ field: spec.field, item: spec.item })),
110
- opts,
111
- );
111
+ const values = await readFields(specs, opts);
112
112
 
113
113
  return Object.fromEntries(
114
114
  specs.map((spec, index) => [spec.name, values[index]!]),
@@ -131,9 +131,10 @@ async function spawnChild(
131
131
  stderr: "inherit",
132
132
  env: { ...process.env, ...secrets },
133
133
  });
134
- } catch (err) {
134
+ } catch {
135
+ // Spawn diagnostics may quote the environment that already holds secrets.
135
136
  throw new CliError(
136
- `Failed to run "${command[0]}": ${err instanceof Error ? err.message : String(err)}`,
137
+ "Failed to run requested command. Check the executable and environment.",
137
138
  ExitCode.BadArgs,
138
139
  );
139
140
  }
@@ -10,6 +10,12 @@ import {
10
10
  import { runBwOrThrow } from "../../bw/runner.ts";
11
11
  import { withSession } from "../../bw/session.ts";
12
12
  import { resolveFolderFilter } from "../../bw/folders.ts";
13
+ import { withSafeVaultErrors } from "../../bw/safe-errors.ts";
14
+ import type { GlobalOptions } from "../types.ts";
15
+
16
+ interface SearchOptions extends ItemListOptions {
17
+ folder?: string;
18
+ }
13
19
 
14
20
  export function registerSearch(program: Command): void {
15
21
  program
@@ -20,26 +26,37 @@ export function registerSearch(program: Command): void {
20
26
  .option("--type <type>", "Filter by type: login|note|card|identity")
21
27
  .option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
22
28
  .option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
29
+ .option("--fields", "Discover available fields and reusable references (no values)")
23
30
  .option("--full-items", "Emit complete item objects (includes secrets)")
24
31
  .action(async function (
25
32
  this: Command,
26
33
  query: string,
27
- localOpts: ItemListOptions & { folder?: string },
34
+ localOpts: SearchOptions,
28
35
  ) {
29
36
  const opts = getGlobalOpts(this);
30
-
31
- const args = ["list", "items", "--search", query];
32
- if (localOpts.folder) {
33
- args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
37
+ if (localOpts.fields && localOpts.fullItems) {
38
+ throw new CliError("--fields and --full-items cannot be combined.", ExitCode.BadArgs);
34
39
  }
40
+ const search = () => searchItems(query, localOpts, opts);
41
+ if (localOpts.fields) await withSafeVaultErrors("Could not discover fields", search);
42
+ else await search();
43
+ });
44
+ }
35
45
 
36
- const json = await withSession(opts, () => runBwOrThrow(args));
37
- const filtered = filterItems(JSON.parse(json), localOpts);
38
-
39
- if (filtered.total === 0) {
40
- throw new CliError(`No items matching "${query}"`, ExitCode.NotFound);
41
- }
46
+ async function searchItems(
47
+ query: string,
48
+ localOpts: SearchOptions,
49
+ opts: GlobalOptions,
50
+ ): Promise<void> {
51
+ const args = ["list", "items", "--search", query];
52
+ if (localOpts.folder) {
53
+ args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
54
+ }
42
55
 
43
- writeItems(filtered, opts, localOpts);
44
- });
56
+ const json = await withSession(opts, () => runBwOrThrow(args));
57
+ const filtered = filterItems(JSON.parse(json), localOpts);
58
+ if (filtered.total === 0) {
59
+ throw new CliError(`No items matching "${query}"`, ExitCode.NotFound);
60
+ }
61
+ writeItems(filtered, opts, localOpts);
45
62
  }