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.
- package/README.md +85 -4
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +57 -10
- package/src/bw/encoding.ts +2 -1
- package/src/bw/fields.ts +246 -27
- package/src/bw/folders.ts +96 -0
- package/src/bw/generate.ts +91 -0
- package/src/bw/session.ts +36 -10
- package/src/cli/commands/config.ts +1 -10
- package/src/cli/commands/create.ts +51 -5
- package/src/cli/commands/delete.ts +48 -21
- package/src/cli/commands/edit.ts +53 -5
- package/src/cli/commands/folders.ts +34 -0
- package/src/cli/commands/generate.ts +57 -0
- package/src/cli/commands/get.ts +28 -4
- package/src/cli/commands/list.ts +25 -8
- package/src/cli/commands/run.ts +14 -9
- package/src/cli/commands/search.ts +16 -12
- package/src/cli/commands/status.ts +32 -5
- package/src/cli/commands/trash.ts +92 -0
- package/src/cli/input.ts +30 -1
- package/src/cli/io.ts +12 -2
- package/src/cli/items.ts +52 -8
- package/src/cli/program.ts +6 -0
package/README.md
CHANGED
|
@@ -54,20 +54,27 @@ bwx doctor
|
|
|
54
54
|
|
|
55
55
|
| Command | Description |
|
|
56
56
|
|---------|-------------|
|
|
57
|
-
| `bwx status` | Show vault state |
|
|
57
|
+
| `bwx status` | Show vault state and whether reads work right now |
|
|
58
58
|
| `bwx unlock` | Unlock vault & cache session |
|
|
59
59
|
| `bwx lock` | Lock vault & clear session |
|
|
60
60
|
| `bwx sync` | Sync vault from server |
|
|
61
61
|
| `bwx doctor` | Check setup (bw CLI, email, password, config) |
|
|
62
62
|
|
|
63
|
+
`status` reports a `session` alongside the vault state, because `bw` answers for the
|
|
64
|
+
vault and not for bwx: a `locked` vault with a `valid` session reads fine and needs no
|
|
65
|
+
unlock. `valid` means reads work now, `stale` means the cached session was rejected, and
|
|
66
|
+
`none` means the next read unlocks.
|
|
67
|
+
|
|
63
68
|
### Read
|
|
64
69
|
|
|
65
70
|
| Command | Description |
|
|
66
71
|
|---------|-------------|
|
|
67
72
|
| `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
|
|
68
|
-
| `bwx field <
|
|
73
|
+
| `bwx field <name> <item>` | Get custom field by name (same argument order as `get`) |
|
|
69
74
|
| `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
70
|
-
| `bwx list [type]` | List items (`--folder`, `--limit`, `--full-items`) |
|
|
75
|
+
| `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
76
|
+
| `bwx folders` | List folder names and IDs |
|
|
77
|
+
| `bwx trash` | List items in the trash |
|
|
71
78
|
| `bwx run --env NAME=<field>:<item> -- <cmd>` | Run a command with secrets in its environment |
|
|
72
79
|
| `bwx attach list <item>` | List attachments for an item |
|
|
73
80
|
| `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
|
|
@@ -77,6 +84,41 @@ human, `--plain`, and `--json` form, so asking for machine-readable output never
|
|
|
77
84
|
what is exposed. `--full-items` is the explicit opt-in to complete item objects, which
|
|
78
85
|
carry passwords, notes, and custom fields.
|
|
79
86
|
|
|
87
|
+
A lookup that finds nothing suggests what you probably meant, and one that matches
|
|
88
|
+
several items lists them, so neither ends in a guessing round:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
$ bwx get password "GitHub PAT vforsh old"
|
|
92
|
+
error: No item matching "GitHub PAT vforsh old". Did you mean:
|
|
93
|
+
|
|
94
|
+
934c1f57-… note GitHub PAT (vforsh)
|
|
95
|
+
3683fb9a-… note GitHub PAT (yaforsh) [codex-monitor-pr]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Listings are capped
|
|
99
|
+
|
|
100
|
+
Listings stop at 50 items by default — an uncapped `bwx list` returns the whole vault,
|
|
101
|
+
and with `--full-items` that is every secret in one call. `--limit 0` opts out. Truncation
|
|
102
|
+
is never silent: it warns on stderr (surviving `--json`) and adds `meta` to the JSON
|
|
103
|
+
envelope.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
bwx list --limit 0 # everything
|
|
107
|
+
bwx list --json # { "data": [...], "meta": { "total": 491, "shown": 50, "truncated": true } }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Folders
|
|
111
|
+
|
|
112
|
+
`--folder` takes a name or an ID on `list`, `search`, `create`, and `edit`; `none` means
|
|
113
|
+
the unfiled bucket. Names are resolved against the vault, so `bwx folders` is only needed
|
|
114
|
+
to see what exists.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
bwx folders
|
|
118
|
+
bwx list --folder "API Keys"
|
|
119
|
+
bwx edit "Acme" --folder none # unfile
|
|
120
|
+
```
|
|
121
|
+
|
|
80
122
|
### Secrets into a child process
|
|
81
123
|
|
|
82
124
|
`bwx run` resolves secrets and places them only in the child's environment: nothing is
|
|
@@ -114,6 +156,34 @@ The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no ext
|
|
|
114
156
|
| `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--field-file k=path`) |
|
|
115
157
|
| `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--add-field-file k=path`, `--rm-field name`) |
|
|
116
158
|
| `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
|
|
159
|
+
| `bwx restore <item>` | Restore an item from the trash |
|
|
160
|
+
| `bwx generate` | Generate a password or passphrase (local, no vault access) |
|
|
161
|
+
|
|
162
|
+
Login-only flags (`--username`, any `--password-*`, `--uri`) require a login item. A
|
|
163
|
+
secure note has nowhere to put them, so passing them to one is an error rather than an
|
|
164
|
+
exit 0 over a credential that was silently dropped.
|
|
165
|
+
|
|
166
|
+
`bwx delete` requires `--force` whenever it is not talking to a terminal. The confirmation
|
|
167
|
+
prompt cannot render for a script or an agent, so without it `--force` would be decorative
|
|
168
|
+
and every non-interactive delete unguarded. Deletes go to the trash unless `--permanent`,
|
|
169
|
+
and `bwx trash` / `bwx restore` are the way back.
|
|
170
|
+
|
|
171
|
+
### Generated passwords
|
|
172
|
+
|
|
173
|
+
`--password-generate` on `create` and `edit` sends a new secret straight to the vault. It
|
|
174
|
+
is never printed, never echoed back in `--json`, and never reaches a terminal, a
|
|
175
|
+
transcript, or a log — which is what makes it safe to use from an agent.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
bwx create --type login --name "Deploy" --username svc --password-generate
|
|
179
|
+
bwx edit "Deploy" --password-generate --generate-length 32
|
|
180
|
+
bwx generate --length 32 # print one instead
|
|
181
|
+
bwx generate --passphrase --words 5
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`bwx generate` runs locally and needs no unlocked vault. Flags: `--length <n>`,
|
|
185
|
+
`--no-uppercase`, `--no-lowercase`, `--no-numbers`, `-s/--special`, `--ambiguous`,
|
|
186
|
+
`--passphrase`, `--words <n>`, `--separator <char>`, `--capitalize`, `--include-number`.
|
|
117
187
|
|
|
118
188
|
### Config
|
|
119
189
|
|
|
@@ -156,7 +226,7 @@ Supported source flags:
|
|
|
156
226
|
|
|
157
227
|
| Flag | Effect |
|
|
158
228
|
|------|--------|
|
|
159
|
-
| `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }` |
|
|
229
|
+
| `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }`, plus `meta` where a response describes itself |
|
|
160
230
|
| `--plain` | Tab-separated, one item per line |
|
|
161
231
|
| `-q, --quiet` | Suppress logs and warnings |
|
|
162
232
|
| `-v, --verbose` | Include stack traces |
|
|
@@ -191,6 +261,17 @@ Field syntax for `--field`, `--field-file`, `--field-env`, and edit variants:
|
|
|
191
261
|
| 8 | Timeout |
|
|
192
262
|
| 9 | User cancelled |
|
|
193
263
|
|
|
264
|
+
## Upgrading to 0.5.0
|
|
265
|
+
|
|
266
|
+
Breaking changes, all of them cases where the old behavior failed quietly:
|
|
267
|
+
|
|
268
|
+
- `bwx field` takes `<name> <item>`, matching `bwx get`. It was `<item> <name>`.
|
|
269
|
+
- `bwx delete` needs `--force` when not interactive; it used to delete unprompted.
|
|
270
|
+
- `bwx list` and `bwx search` stop at 50 items unless given `--limit`. Pass `--limit 0`
|
|
271
|
+
for the old behavior.
|
|
272
|
+
- `--limit` rejects non-numeric values instead of ignoring them (and returning everything).
|
|
273
|
+
- Login-only flags on a secure note are an error instead of a silent drop.
|
|
274
|
+
|
|
194
275
|
## Stack
|
|
195
276
|
|
|
196
277
|
Bun, TypeScript, Commander, Zod, picocolors.
|
package/package.json
CHANGED
package/skill/bwx/SKILL.md
CHANGED
|
@@ -17,7 +17,7 @@ bwx get notes "Deploy key" # secure note content
|
|
|
17
17
|
bwx get uri "GitHub" # first URI
|
|
18
18
|
bwx get item "GitHub" # full item JSON
|
|
19
19
|
bwx get "API Key" "Acme" # custom field by name
|
|
20
|
-
bwx field "
|
|
20
|
+
bwx field "API Key" "Acme" # custom-field-only lookup, same arg order
|
|
21
21
|
|
|
22
22
|
bwx search github # fuzzy search, human table
|
|
23
23
|
bwx search github --json # id/type/name/username only — no secrets
|
|
@@ -26,6 +26,9 @@ bwx search github --type login --limit 5
|
|
|
26
26
|
# hand a secret to a child process instead of printing it
|
|
27
27
|
bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
|
|
28
28
|
|
|
29
|
+
# generate straight into the vault — the value never enters your context
|
|
30
|
+
bwx create --type login --name "Deploy" --username svc --password-generate
|
|
31
|
+
|
|
29
32
|
bwx attach list "Apple Developer" # list item attachments
|
|
30
33
|
bwx attach get "Apple Developer" AuthKey.p8
|
|
31
34
|
bwx attach get "Apple Developer" AuthKey.p8 --output ./secrets/
|
|
@@ -43,18 +46,31 @@ echo "secret" | bwx create --name "Piped Note"
|
|
|
43
46
|
Built-in fields: `password | username | totp | notes | uri | item`.
|
|
44
47
|
Anything else is looked up as a custom field. Lists available fields on miss.
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
A miss suggests near matches; an ambiguous name lists candidates with IDs. Read the
|
|
50
|
+
suggestions instead of retrying variations — the answer is usually in them.
|
|
51
|
+
|
|
52
|
+
### `bwx field <name> <item>`
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
Custom fields only, so an item whose custom field is named `password` is still reachable.
|
|
55
|
+
Takes its arguments in the same order as `get` (this changed in 0.5.0).
|
|
49
56
|
|
|
50
57
|
### `bwx search <query>` / `bwx list [type]`
|
|
51
58
|
|
|
52
|
-
Flags: `--type login|note|card|identity
|
|
59
|
+
Flags: `--type login|note|card|identity`, `--folder <name|id>`, `--limit <n>`, `--full-items`
|
|
53
60
|
|
|
54
61
|
Human, `--plain`, and `--json` output all carry the same reduced projection — `id`, `type`,
|
|
55
62
|
`name`, `username` — so listings never leak passwords, notes, or custom fields. `--full-items`
|
|
56
63
|
opts into complete item objects and **does** include secrets; avoid it in logged sessions.
|
|
57
64
|
|
|
65
|
+
**Listings stop at 50 items** unless you pass `--limit` (`--limit 0` for all). Truncation is
|
|
66
|
+
reported on stderr and in `meta` on the JSON envelope — check it before concluding a listing
|
|
67
|
+
was complete. `--limit` rejects non-numeric values rather than ignoring them.
|
|
68
|
+
|
|
69
|
+
### `bwx folders`
|
|
70
|
+
|
|
71
|
+
Lists folder names and IDs. `--folder` accepts either on `list`, `search`, `create`, and
|
|
72
|
+
`edit`; `none` selects unfiled items.
|
|
73
|
+
|
|
58
74
|
### `bwx run --env NAME=<field>:<item> -- <command>`
|
|
59
75
|
|
|
60
76
|
Resolves secrets and injects them into the child's environment only — not stdout, not argv.
|
|
@@ -70,6 +86,8 @@ bwx run --env TOKEN='API Key:Acme' --env USER=username:Acme -- ./deploy.sh
|
|
|
70
86
|
- Put `--` before the command when it takes its own flags.
|
|
71
87
|
- Child stdio is inherited; bwx exits with the child's status (`128 + signal` if killed).
|
|
72
88
|
- `-v` logs injected variable names only, never values.
|
|
89
|
+
- Several variables from one item cost a single vault read, so group them here rather
|
|
90
|
+
than issuing separate `bwx get` calls.
|
|
73
91
|
|
|
74
92
|
### `bwx attach list <item>`
|
|
75
93
|
|
|
@@ -81,7 +99,10 @@ Downloads an attachment by ID or filename. Defaults to saving as the attachment
|
|
|
81
99
|
|
|
82
100
|
### `bwx create [options]`
|
|
83
101
|
|
|
84
|
-
Flags: `--type login|note`, `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--uri` (repeatable), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder
|
|
102
|
+
Flags: `--type login|note`, `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--uri` (repeatable), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder <name|id>`, `--favorite`, `--from-json`
|
|
103
|
+
|
|
104
|
+
`--username`, any `--password-*`, and `--uri` require `--type login`; a note cannot hold
|
|
105
|
+
them and passing them to one is now an error rather than a silent drop.
|
|
85
106
|
|
|
86
107
|
Field syntax: `k=v` (text), `!k=v` (hidden), `bool:k=v` (boolean). The same key syntax works for `--field-file` and `--field-env`, where the value is a path or environment variable name. Stdin is read as `--notes` when piped; prefer `--password-stdin` for login passwords.
|
|
87
108
|
|
|
@@ -93,26 +114,51 @@ bwx edit "API Key" --add-field region=us-east-1
|
|
|
93
114
|
printf '%s' "$TOKEN" | bwx edit "GitHub" --password-stdin --uri https://github.com
|
|
94
115
|
bwx edit "Note" --rm-field old-key
|
|
95
116
|
```
|
|
96
|
-
Flags: `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--uri` (replaces all), `--add-field k=v`, `--add-field-file k=path`, `--add-field-env k=ENV`, `--rm-field name`, `--folder
|
|
117
|
+
Flags: `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--uri` (replaces all), `--add-field k=v`, `--add-field-file k=path`, `--add-field-env k=ENV`, `--rm-field name`, `--folder <name|id>` (`none` unfiles), `--favorite/--no-favorite`, `--from-json`
|
|
97
118
|
|
|
98
119
|
### `bwx delete <item>`
|
|
99
120
|
|
|
100
121
|
```bash
|
|
101
|
-
bwx delete "Test Note"
|
|
102
|
-
bwx delete "Test Note" --force
|
|
103
|
-
bwx
|
|
122
|
+
bwx delete "Test Note" --force # --force is REQUIRED when not a TTY
|
|
123
|
+
bwx delete "Test Note" --permanent --force # skip the trash
|
|
124
|
+
bwx trash # what is recoverable
|
|
125
|
+
bwx restore "Test Note" # undo a non-permanent delete
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The prompt only exists for a human at a terminal, so an agent must pass `--force`
|
|
129
|
+
deliberately. Deletes are recoverable through `bwx restore` unless `--permanent`.
|
|
130
|
+
|
|
131
|
+
### `bwx generate`
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
bwx generate --length 32
|
|
135
|
+
bwx generate --passphrase --words 5 --separator -
|
|
104
136
|
```
|
|
105
137
|
|
|
138
|
+
Local, no vault access, no unlock. Flags: `--length <n>`, `--no-uppercase`,
|
|
139
|
+
`--no-lowercase`, `--no-numbers`, `-s/--special`, `--ambiguous`, `--passphrase`,
|
|
140
|
+
`--words <n>`, `--separator <char>`, `--capitalize`, `--include-number`.
|
|
141
|
+
|
|
142
|
+
Prefer `--password-generate` on `create`/`edit` over generating and passing the value:
|
|
143
|
+
it goes to the vault without ever entering your context, and `--json` returns
|
|
144
|
+
`***GENERATED***` in place of the password.
|
|
145
|
+
|
|
106
146
|
### Other
|
|
107
147
|
|
|
108
148
|
| Command | Description |
|
|
109
149
|
|---------|-------------|
|
|
110
|
-
| `bwx status` | Vault state (no auto-unlock) |
|
|
150
|
+
| `bwx status` | Vault state + session usability (no auto-unlock) |
|
|
111
151
|
| `bwx sync` | Force vault sync |
|
|
112
152
|
| `bwx unlock` / `bwx lock` | Manual session control |
|
|
153
|
+
| `bwx folders` | Folder names and IDs |
|
|
154
|
+
| `bwx trash` / `bwx restore <item>` | Inspect and undo deletes |
|
|
113
155
|
| `bwx config list` / `bwx cfg ls` | Show config path + redacted content |
|
|
114
156
|
| `bwx config master-password` | Set/update master password in Keychain |
|
|
115
157
|
|
|
158
|
+
`bwx status` reports `session: valid | stale | none` separately from the vault state.
|
|
159
|
+
**A `locked` vault with a `valid` session reads fine — do not run `bwx unlock` on it.**
|
|
160
|
+
Only `stale`/`none` mean the next read pays for an unlock.
|
|
161
|
+
|
|
116
162
|
## Global flags
|
|
117
163
|
|
|
118
164
|
| Flag | Effect |
|
|
@@ -137,6 +183,7 @@ even under `--json`; `-q` silences them.
|
|
|
137
183
|
|
|
138
184
|
- Never use `--full-items` (or `get item` for discovery) in logged sessions.
|
|
139
185
|
- Prefer `bwx run --env ...` over `export VAR="$(bwx get ...)"`; secrets never touch stdout or argv.
|
|
186
|
+
- Prefer `--password-generate` over generating a secret yourself and passing it in.
|
|
140
187
|
- Request the narrowest field, resolving an exact item ID first.
|
|
141
188
|
- Use `-q` for command substitution so unlock logs cannot contaminate merged output.
|
|
142
189
|
- On writes, prefer `--password-stdin`, `--password-env`, `--field-file`, `--field-env`.
|
package/src/bw/encoding.ts
CHANGED
|
@@ -98,7 +98,8 @@ export interface PatchOptions {
|
|
|
98
98
|
uris?: string[];
|
|
99
99
|
addFields?: ParsedField[];
|
|
100
100
|
rmFields?: string[];
|
|
101
|
-
|
|
101
|
+
/** `null` unfiles the item; `undefined` leaves its folder alone. */
|
|
102
|
+
folderId?: string | null;
|
|
102
103
|
favorite?: boolean;
|
|
103
104
|
}
|
|
104
105
|
|
package/src/bw/fields.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { GlobalOptions } from "../cli/types.ts";
|
|
|
4
4
|
import { summarizeItem, type ItemSummary } from "./items.ts";
|
|
5
5
|
import { runBwOrThrow } from "./runner.ts";
|
|
6
6
|
import { withSession } from "./session.ts";
|
|
7
|
-
import { BwItemSchema } from "./types.ts";
|
|
7
|
+
import { BwItemSchema, type BwItem } from "./types.ts";
|
|
8
8
|
|
|
9
9
|
/** Fields `bw get` resolves natively; anything else is a custom field name. */
|
|
10
10
|
const BUILTIN_FIELDS = [
|
|
@@ -16,6 +16,12 @@ const BUILTIN_FIELDS = [
|
|
|
16
16
|
"item",
|
|
17
17
|
] as const;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* `totp` is a computed code, not stored data — only `bw get totp` can derive it,
|
|
21
|
+
* so it is the one field that cannot be served from a fetched item.
|
|
22
|
+
*/
|
|
23
|
+
const DERIVED_FIELDS = new Set(["totp"]);
|
|
24
|
+
|
|
19
25
|
function isBuiltinField(field: string): boolean {
|
|
20
26
|
return (BUILTIN_FIELDS as readonly string[]).includes(field);
|
|
21
27
|
}
|
|
@@ -28,10 +34,23 @@ export interface ReadFieldOptions {
|
|
|
28
34
|
customOnly?: boolean;
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Raised when the item was found but the requested field was not. Distinct from
|
|
39
|
+
* a missing *item* so the "did you mean this item?" suggestions below never fire
|
|
40
|
+
* on an error that already names the fields that do exist.
|
|
41
|
+
*/
|
|
42
|
+
class FieldMissingError extends CliError {}
|
|
43
|
+
|
|
44
|
+
/** One `(field, item)` pair to resolve — see {@link readFields}. */
|
|
45
|
+
export interface FieldRequest {
|
|
46
|
+
field: string;
|
|
47
|
+
item: string;
|
|
48
|
+
options?: ReadFieldOptions;
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Reads one field from a vault item and returns it verbatim (`item` yields the
|
|
33
53
|
* item JSON), leaving the caller to decide whether the value is printed at all.
|
|
34
|
-
* Ambiguous item lookups fail with the candidate list instead of a raw bw error.
|
|
35
54
|
*/
|
|
36
55
|
export async function readItemField(
|
|
37
56
|
field: string,
|
|
@@ -39,22 +58,132 @@ export async function readItemField(
|
|
|
39
58
|
opts: GlobalOptions,
|
|
40
59
|
options?: ReadFieldOptions,
|
|
41
60
|
): Promise<string> {
|
|
61
|
+
return withLookupHelp(item, opts, () =>
|
|
62
|
+
isBuiltinField(field) && !options?.customOnly
|
|
63
|
+
? readBuiltinField(field, item, opts)
|
|
64
|
+
: readCustomField(field, item, opts),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolves many fields at once, paying a single `bw get item` per distinct item
|
|
70
|
+
* rather than one `bw` spawn per field. Each spawn costs ~2.5s, so a caller
|
|
71
|
+
* pulling a username and password from one item halves its own runtime.
|
|
72
|
+
*
|
|
73
|
+
* Returns values positionally aligned with `requests`.
|
|
74
|
+
*/
|
|
75
|
+
export async function readFields(
|
|
76
|
+
requests: FieldRequest[],
|
|
77
|
+
opts: GlobalOptions,
|
|
78
|
+
): Promise<string[]> {
|
|
79
|
+
const values = new Array<string>(requests.length);
|
|
80
|
+
const byItem = new Map<string, number[]>();
|
|
81
|
+
|
|
82
|
+
for (const [index, request] of requests.entries()) {
|
|
83
|
+
const bucket = byItem.get(request.item);
|
|
84
|
+
if (bucket) bucket.push(index);
|
|
85
|
+
else byItem.set(request.item, [index]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const [item, indexes] of byItem) {
|
|
89
|
+
// A single `bw` process at a time: each read may trigger an unlock, and
|
|
90
|
+
// concurrent unlocks would race over the cached session.
|
|
91
|
+
const derived = indexes.filter((i) => isDerived(requests[i]!));
|
|
92
|
+
const stored = indexes.filter((i) => !isDerived(requests[i]!));
|
|
93
|
+
|
|
94
|
+
if (stored.length > 0) {
|
|
95
|
+
const fetched = await withLookupHelp(item, opts, () =>
|
|
96
|
+
fetchItem(item, opts),
|
|
97
|
+
);
|
|
98
|
+
for (const index of stored) {
|
|
99
|
+
const request = requests[index]!;
|
|
100
|
+
values[index] = extractField(fetched, request.field, request.options);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const index of derived) {
|
|
105
|
+
const request = requests[index]!;
|
|
106
|
+
values[index] = await withLookupHelp(item, opts, () =>
|
|
107
|
+
readBuiltinField(request.field, item, opts),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return values;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isDerived(request: FieldRequest): boolean {
|
|
116
|
+
return !request.options?.customOnly && DERIVED_FIELDS.has(request.field);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Turns bw's bare "Not found." and "More than one result" into errors that name
|
|
121
|
+
* the candidates, so a caller can correct itself without a round of guessing.
|
|
122
|
+
*/
|
|
123
|
+
async function withLookupHelp<T>(
|
|
124
|
+
item: string,
|
|
125
|
+
opts: GlobalOptions,
|
|
126
|
+
fn: () => Promise<T>,
|
|
127
|
+
): Promise<T> {
|
|
42
128
|
try {
|
|
43
|
-
return
|
|
44
|
-
? await readBuiltinField(field, item, opts)
|
|
45
|
-
: await readCustomField(field, item, opts);
|
|
129
|
+
return await fn();
|
|
46
130
|
} catch (err) {
|
|
131
|
+
if (err instanceof FieldMissingError) throw err;
|
|
132
|
+
|
|
47
133
|
if (err instanceof CliError && /more than one result/i.test(err.message)) {
|
|
48
|
-
const matches = await findMatches(item);
|
|
49
134
|
throw new CliError(
|
|
50
|
-
|
|
135
|
+
formatAmbiguous(item, await findMatches(item), opts),
|
|
51
136
|
ExitCode.BadArgs,
|
|
52
137
|
);
|
|
53
138
|
}
|
|
139
|
+
|
|
140
|
+
if (err instanceof CliError && err.exitCode === ExitCode.NotFound) {
|
|
141
|
+
throw new CliError(
|
|
142
|
+
formatNotFound(item, await findSimilar(item), opts),
|
|
143
|
+
ExitCode.NotFound,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
54
147
|
throw err;
|
|
55
148
|
}
|
|
56
149
|
}
|
|
57
150
|
|
|
151
|
+
async function fetchItem(item: string, opts: GlobalOptions): Promise<BwItem> {
|
|
152
|
+
const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
|
|
153
|
+
return BwItemSchema.parse(JSON.parse(json));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Mirrors what `bw get <field>` would have returned for an already-fetched item. */
|
|
157
|
+
function extractField(
|
|
158
|
+
item: BwItem,
|
|
159
|
+
field: string,
|
|
160
|
+
options?: ReadFieldOptions,
|
|
161
|
+
): string {
|
|
162
|
+
if (!isBuiltinField(field) || options?.customOnly) {
|
|
163
|
+
return pickCustomField(item, field);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (field === "item") return JSON.stringify(item);
|
|
167
|
+
|
|
168
|
+
const value =
|
|
169
|
+
field === "notes"
|
|
170
|
+
? item.notes
|
|
171
|
+
: field === "uri"
|
|
172
|
+
? (item.login?.uris?.[0]?.uri ?? null)
|
|
173
|
+
: field === "username"
|
|
174
|
+
? (item.login?.username ?? null)
|
|
175
|
+
: (item.login?.password ?? null);
|
|
176
|
+
|
|
177
|
+
if (!value) {
|
|
178
|
+
throw new FieldMissingError(
|
|
179
|
+
`No ${field} found for item: ${item.name}`,
|
|
180
|
+
ExitCode.NotFound,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
58
187
|
async function readBuiltinField(
|
|
59
188
|
field: string,
|
|
60
189
|
item: string,
|
|
@@ -65,7 +194,7 @@ async function readBuiltinField(
|
|
|
65
194
|
);
|
|
66
195
|
|
|
67
196
|
if (!result) {
|
|
68
|
-
throw new
|
|
197
|
+
throw new FieldMissingError(
|
|
69
198
|
`No ${field} found for item: ${item}`,
|
|
70
199
|
ExitCode.NotFound,
|
|
71
200
|
);
|
|
@@ -79,20 +208,21 @@ async function readCustomField(
|
|
|
79
208
|
item: string,
|
|
80
209
|
opts: GlobalOptions,
|
|
81
210
|
): Promise<string> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
211
|
+
return pickCustomField(await fetchItem(item, opts), fieldName);
|
|
212
|
+
}
|
|
85
213
|
|
|
86
|
-
|
|
87
|
-
const fields =
|
|
214
|
+
function pickCustomField(item: BwItem, fieldName: string): string {
|
|
215
|
+
const fields = item.fields ?? [];
|
|
88
216
|
const match = fields.find((f) => f.name === fieldName);
|
|
89
217
|
|
|
90
218
|
if (!match) {
|
|
91
219
|
const available = fields.map((f) => f.name).join(", ");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
220
|
+
throw new FieldMissingError(
|
|
221
|
+
available
|
|
222
|
+
? `Field "${fieldName}" not found. Available: ${available}`
|
|
223
|
+
: `Field "${fieldName}" not found (item has no custom fields)`,
|
|
224
|
+
ExitCode.NotFound,
|
|
225
|
+
);
|
|
96
226
|
}
|
|
97
227
|
|
|
98
228
|
return match.value ?? "";
|
|
@@ -108,7 +238,95 @@ async function findMatches(query: string): Promise<ItemSummary[]> {
|
|
|
108
238
|
}
|
|
109
239
|
}
|
|
110
240
|
|
|
111
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Looks for what the caller probably meant. bw's own search already came back
|
|
243
|
+
* empty, so this pulls the item list once and scores it locally: one `bw` spawn
|
|
244
|
+
* on the error path, and matching that survives typos and extra words the way
|
|
245
|
+
* repeated `--search` calls do not.
|
|
246
|
+
*/
|
|
247
|
+
async function findSimilar(query: string): Promise<ItemSummary[]> {
|
|
248
|
+
let summaries: ItemSummary[];
|
|
249
|
+
try {
|
|
250
|
+
const json = await runBwOrThrow(["list", "items"]);
|
|
251
|
+
const items: Array<Record<string, unknown>> = JSON.parse(json);
|
|
252
|
+
summaries = items.map(summarizeItem);
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return rankSimilar(query, summaries);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Pure so the suggestion quality is testable without a vault. */
|
|
261
|
+
export function rankSimilar(
|
|
262
|
+
query: string,
|
|
263
|
+
items: ItemSummary[],
|
|
264
|
+
max = MAX_SUGGESTIONS,
|
|
265
|
+
): ItemSummary[] {
|
|
266
|
+
const tokens = tokenize(query);
|
|
267
|
+
if (tokens.length === 0) return [];
|
|
268
|
+
|
|
269
|
+
const scored = items
|
|
270
|
+
.map((item) => ({ item, score: scoreMatch(tokens, item) }))
|
|
271
|
+
.filter((entry) => entry.score > 0)
|
|
272
|
+
.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name));
|
|
273
|
+
|
|
274
|
+
return scored.slice(0, max).map((entry) => entry.item);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Length gates on the fuzzier rules keep short words from matching by accident —
|
|
279
|
+
* without them "not" pulls in every item containing "evernote", and a suggestion
|
|
280
|
+
* list full of noise is worse than none.
|
|
281
|
+
*/
|
|
282
|
+
function scoreMatch(tokens: string[], item: ItemSummary): number {
|
|
283
|
+
const haystack = `${item.name} ${item.username ?? ""}`.toLowerCase();
|
|
284
|
+
const itemTokens = tokenize(haystack);
|
|
285
|
+
|
|
286
|
+
let score = 0;
|
|
287
|
+
for (const token of tokens) {
|
|
288
|
+
if (itemTokens.includes(token)) score += 3;
|
|
289
|
+
else if (token.length >= 4 && haystack.includes(token)) score += 2;
|
|
290
|
+
else if (
|
|
291
|
+
token.length >= 3 &&
|
|
292
|
+
itemTokens.some((other) => other.startsWith(token))
|
|
293
|
+
) {
|
|
294
|
+
score += 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return score;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function tokenize(value: string): string[] {
|
|
302
|
+
return value
|
|
303
|
+
.toLowerCase()
|
|
304
|
+
.split(/[^a-z0-9]+/)
|
|
305
|
+
.filter((token) => token.length >= 2);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const MAX_SUGGESTIONS = 5;
|
|
309
|
+
|
|
310
|
+
function formatNotFound(
|
|
311
|
+
query: string,
|
|
312
|
+
matches: ItemSummary[],
|
|
313
|
+
opts: GlobalOptions,
|
|
314
|
+
): string {
|
|
315
|
+
if (matches.length === 0) {
|
|
316
|
+
return `No item matching "${query}". Run 'bwx search <query>' to find it.`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (opts.json) {
|
|
320
|
+
return `No item matching "${query}". Did you mean: ${JSON.stringify(matches)}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return [
|
|
324
|
+
`No item matching "${query}". Did you mean:\n`,
|
|
325
|
+
...matches.map(formatCandidate),
|
|
326
|
+
].join("\n");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function formatAmbiguous(
|
|
112
330
|
query: string,
|
|
113
331
|
matches: ItemSummary[],
|
|
114
332
|
opts: GlobalOptions,
|
|
@@ -121,13 +339,14 @@ function formatAmbiguousError(
|
|
|
121
339
|
return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
|
|
122
340
|
}
|
|
123
341
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
342
|
+
return [
|
|
343
|
+
`Multiple items match "${query}":\n`,
|
|
344
|
+
...matches.map(formatCandidate),
|
|
345
|
+
`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`,
|
|
346
|
+
].join("\n");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function formatCandidate(match: ItemSummary): string {
|
|
350
|
+
const user = match.username ? ` (${match.username})` : "";
|
|
351
|
+
return ` ${pc.dim(match.id)} ${pc.cyan(match.type)} ${match.name}${pc.dim(user)}`;
|
|
133
352
|
}
|