bwx 0.3.0 → 0.4.1
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 +49 -3
- package/package.json +3 -2
- package/skill/bwx/SKILL.md +149 -0
- package/src/bw/fields.ts +133 -0
- package/src/bw/freshness.ts +161 -0
- package/src/bw/items.ts +25 -0
- package/src/bw/runner.ts +109 -13
- package/src/bw/session.ts +57 -62
- package/src/bw/status.ts +19 -0
- package/src/cli/commands/doctor.ts +2 -3
- package/src/cli/commands/get.ts +5 -129
- package/src/cli/commands/list.ts +5 -48
- package/src/cli/commands/run.ts +138 -0
- package/src/cli/commands/search.ts +5 -49
- package/src/cli/commands/status.ts +4 -2
- package/src/cli/commands/sync.ts +6 -1
- package/src/cli/duration.ts +34 -0
- package/src/cli/io.ts +7 -1
- package/src/cli/items.ts +78 -0
- package/src/cli/program.ts +32 -1
- package/src/cli/types.ts +7 -4
- package/src/config/paths.ts +12 -0
- package/src/config/secure-file.ts +83 -0
- package/src/config/store.ts +6 -10
package/README.md
CHANGED
|
@@ -17,9 +17,11 @@ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, sessio
|
|
|
17
17
|
The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
|
|
18
18
|
|
|
19
19
|
- **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
|
|
20
|
-
- **Session caching** — token
|
|
20
|
+
- **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`, passed to `bw` through `BW_SESSION` so it never appears in a process command line
|
|
21
21
|
- **Structured output** — `--json` and `--plain` flags for machine-parseable output
|
|
22
|
+
- **Leak-resistant** — listings never print secrets, `bwx run` hands them to a child process instead of stdout
|
|
22
23
|
- **No interaction** — credentials from config + Keychain, never prompts
|
|
24
|
+
- **Bounded** — every `bw` call has a deadline, so a wedged vault operation cannot hang a caller
|
|
23
25
|
|
|
24
26
|
## Install
|
|
25
27
|
|
|
@@ -64,10 +66,47 @@ bwx doctor
|
|
|
64
66
|
|---------|-------------|
|
|
65
67
|
| `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
|
|
66
68
|
| `bwx field <item> <name>` | Get custom field by name |
|
|
67
|
-
| `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`) |
|
|
69
|
+
| `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
70
|
+
| `bwx list [type]` | List items (`--folder`, `--limit`, `--full-items`) |
|
|
71
|
+
| `bwx run --env NAME=<field>:<item> -- <cmd>` | Run a command with secrets in its environment |
|
|
68
72
|
| `bwx attach list <item>` | List attachments for an item |
|
|
69
73
|
| `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
|
|
70
74
|
|
|
75
|
+
`search` and `list` print the same reduced projection — id, type, name, username — in
|
|
76
|
+
human, `--plain`, and `--json` form, so asking for machine-readable output never widens
|
|
77
|
+
what is exposed. `--full-items` is the explicit opt-in to complete item objects, which
|
|
78
|
+
carry passwords, notes, and custom fields.
|
|
79
|
+
|
|
80
|
+
### Secrets into a child process
|
|
81
|
+
|
|
82
|
+
`bwx run` resolves secrets and places them only in the child's environment: nothing is
|
|
83
|
+
printed to stdout, and nothing lands in a command line where other processes could read it.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
|
|
87
|
+
bwx run --env DB_PASS=password:'Prod DB' --env TOKEN='API Key:Acme' -- ./deploy.sh
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `--env NAME=<field>:<item>` is repeatable; the field resolves exactly as in `bwx get`
|
|
91
|
+
(built-ins first, otherwise a custom field name).
|
|
92
|
+
- The item may contain colons — only the first one separates field from item.
|
|
93
|
+
- Use `--` before the command whenever it takes its own flags.
|
|
94
|
+
- The child inherits stdio and bwx exits with the child's status (`128 + signal` when killed).
|
|
95
|
+
- `-v` logs the injected variable *names*; values are never logged.
|
|
96
|
+
|
|
97
|
+
### Vault freshness
|
|
98
|
+
|
|
99
|
+
`bw` answers reads from the local vault copy, so a vault that has not synced in a while
|
|
100
|
+
returns stale secrets. Reads warn on stderr when the last sync is over 24h old, and
|
|
101
|
+
`--sync-if-older-than <duration>` syncs first instead of warning:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bwx get password "Router" # warns if the vault is stale
|
|
105
|
+
bwx --sync-if-older-than 15m get password "Router" # syncs first when needed
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no extra `bw` call.
|
|
109
|
+
|
|
71
110
|
### Write
|
|
72
111
|
|
|
73
112
|
| Command | Description |
|
|
@@ -119,8 +158,15 @@ Supported source flags:
|
|
|
119
158
|
|------|--------|
|
|
120
159
|
| `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }` |
|
|
121
160
|
| `--plain` | Tab-separated, one item per line |
|
|
122
|
-
| `-q, --quiet` | Suppress logs |
|
|
161
|
+
| `-q, --quiet` | Suppress logs and warnings |
|
|
123
162
|
| `-v, --verbose` | Include stack traces |
|
|
163
|
+
| `--timeout <duration>` | Deadline for each `bw` call (default `1m`, `0` disables) |
|
|
164
|
+
| `--sync-if-older-than <duration>` | Sync before vault access when the last sync is older |
|
|
165
|
+
|
|
166
|
+
Durations accept `250ms`, `30s`, `15m`, `2h`, `1d`; a bare number means seconds.
|
|
167
|
+
|
|
168
|
+
Warnings ("vault is stale", "ignoring cached session") go to stderr even under `--json`,
|
|
169
|
+
because they say the returned data may be wrong. `-q` silences them.
|
|
124
170
|
|
|
125
171
|
## Custom fields
|
|
126
172
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bwx
|
|
3
|
+
description: Bitwarden Extended CLI — retrieve, create, edit, delete vault items with auto-unlock. Use when the agent needs passwords, API keys, TOTP codes, custom fields, or vault management. Triggers on mentions of Bitwarden, bw, bwx, vault, password, secrets, TOTP, save key/token.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# bwx
|
|
7
|
+
|
|
8
|
+
Extended Bitwarden CLI. Wraps `bw` with auto-unlock, session caching, and structured output.
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
bwx get password "GitHub" # retrieve a password (auto-unlocks)
|
|
14
|
+
bwx get totp "AWS (user@example.com)" # TOTP code
|
|
15
|
+
bwx get username "GitHub" # username
|
|
16
|
+
bwx get notes "Deploy key" # secure note content
|
|
17
|
+
bwx get uri "GitHub" # first URI
|
|
18
|
+
bwx get item "GitHub" # full item JSON
|
|
19
|
+
bwx get "API Key" "Acme" # custom field by name
|
|
20
|
+
bwx field "Acme" "API Key" # custom field alias
|
|
21
|
+
|
|
22
|
+
bwx search github # fuzzy search, human table
|
|
23
|
+
bwx search github --json # id/type/name/username only — no secrets
|
|
24
|
+
bwx search github --type login --limit 5
|
|
25
|
+
|
|
26
|
+
# hand a secret to a child process instead of printing it
|
|
27
|
+
bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
|
|
28
|
+
|
|
29
|
+
bwx attach list "Apple Developer" # list item attachments
|
|
30
|
+
bwx attach get "Apple Developer" AuthKey.p8
|
|
31
|
+
bwx attach get "Apple Developer" AuthKey.p8 --output ./secrets/
|
|
32
|
+
|
|
33
|
+
bwx create --name "API Key" --notes "sk_live_abc123" --field account=acme
|
|
34
|
+
printf '%s' "$TOKEN" | bwx create --type login --name "GitHub" --username user --password-stdin --uri https://github.com
|
|
35
|
+
bwx create --name "Acme" --field-file '!API Key=./token.txt' --field-env account=ACME_ACCOUNT
|
|
36
|
+
echo "secret" | bwx create --name "Piped Note"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
### `bwx get <field> <item>`
|
|
42
|
+
|
|
43
|
+
Built-in fields: `password | username | totp | notes | uri | item`.
|
|
44
|
+
Anything else is looked up as a custom field. Lists available fields on miss.
|
|
45
|
+
|
|
46
|
+
### `bwx field <item> <name>`
|
|
47
|
+
|
|
48
|
+
Alias for retrieving custom fields with item-first argument order.
|
|
49
|
+
|
|
50
|
+
### `bwx search <query>` / `bwx list [type]`
|
|
51
|
+
|
|
52
|
+
Flags: `--type login|note|card|identity` (`search`), `--folder <id>`, `--limit <n>`, `--full-items`
|
|
53
|
+
|
|
54
|
+
Human, `--plain`, and `--json` output all carry the same reduced projection — `id`, `type`,
|
|
55
|
+
`name`, `username` — so listings never leak passwords, notes, or custom fields. `--full-items`
|
|
56
|
+
opts into complete item objects and **does** include secrets; avoid it in logged sessions.
|
|
57
|
+
|
|
58
|
+
### `bwx run --env NAME=<field>:<item> -- <command>`
|
|
59
|
+
|
|
60
|
+
Resolves secrets and injects them into the child's environment only — not stdout, not argv.
|
|
61
|
+
This is the preferred way to give a subprocess a credential.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bwx run --env DB_PASS=password:'Prod DB' -- ./migrate.sh
|
|
65
|
+
bwx run --env TOKEN='API Key:Acme' --env USER=username:Acme -- ./deploy.sh
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- `--env` is repeatable; the field resolves as in `bwx get` (built-in, else custom field).
|
|
69
|
+
- Only the first `:` splits field from item, so `password:http://my.router` works.
|
|
70
|
+
- Put `--` before the command when it takes its own flags.
|
|
71
|
+
- Child stdio is inherited; bwx exits with the child's status (`128 + signal` if killed).
|
|
72
|
+
- `-v` logs injected variable names only, never values.
|
|
73
|
+
|
|
74
|
+
### `bwx attach list <item>`
|
|
75
|
+
|
|
76
|
+
Lists attachments for an item. Aliases: `bwx attachment`, `bwx attachments`; `list` has `ls`.
|
|
77
|
+
|
|
78
|
+
### `bwx attach get <item> <attachment>`
|
|
79
|
+
|
|
80
|
+
Downloads an attachment by ID or filename. Defaults to saving as the attachment filename in the current directory. Use `--output <path>` to choose a file or directory.
|
|
81
|
+
|
|
82
|
+
### `bwx create [options]`
|
|
83
|
+
|
|
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`, `--favorite`, `--from-json`
|
|
85
|
+
|
|
86
|
+
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
|
+
|
|
88
|
+
### `bwx edit <item> [options]`
|
|
89
|
+
|
|
90
|
+
Fetch → patch → push.
|
|
91
|
+
```bash
|
|
92
|
+
bwx edit "API Key" --add-field region=us-east-1
|
|
93
|
+
printf '%s' "$TOKEN" | bwx edit "GitHub" --password-stdin --uri https://github.com
|
|
94
|
+
bwx edit "Note" --rm-field old-key
|
|
95
|
+
```
|
|
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`, `--favorite/--no-favorite`, `--from-json`
|
|
97
|
+
|
|
98
|
+
### `bwx delete <item>`
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bwx delete "Test Note" # prompts in TTY
|
|
102
|
+
bwx delete "Test Note" --force # skip prompt
|
|
103
|
+
bwx delete "Test Note" --permanent # no trash
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Other
|
|
107
|
+
|
|
108
|
+
| Command | Description |
|
|
109
|
+
|---------|-------------|
|
|
110
|
+
| `bwx status` | Vault state (no auto-unlock) |
|
|
111
|
+
| `bwx sync` | Force vault sync |
|
|
112
|
+
| `bwx unlock` / `bwx lock` | Manual session control |
|
|
113
|
+
| `bwx config list` / `bwx cfg ls` | Show config path + redacted content |
|
|
114
|
+
| `bwx config master-password` | Set/update master password in Keychain |
|
|
115
|
+
|
|
116
|
+
## Global flags
|
|
117
|
+
|
|
118
|
+
| Flag | Effect |
|
|
119
|
+
|------|--------|
|
|
120
|
+
| `--json` | `{ "data": ... }` on success, `{ "error": { "message", "exitCode" } }` on failure |
|
|
121
|
+
| `--plain` | Stable parseable lines on stdout |
|
|
122
|
+
| `-q` | Suppress logs and warnings |
|
|
123
|
+
| `-v` | Verbose (stack traces on error) |
|
|
124
|
+
| `--timeout <duration>` | Deadline per `bw` call (default `1m`, `0` disables) → exit 8 |
|
|
125
|
+
| `--sync-if-older-than <duration>` | Sync first when the vault last synced longer ago |
|
|
126
|
+
|
|
127
|
+
Durations: `250ms`, `30s`, `15m`, `2h`, `1d`; a bare number is seconds.
|
|
128
|
+
|
|
129
|
+
## Vault freshness
|
|
130
|
+
|
|
131
|
+
Reads come from the local vault copy. Reads warn on stderr when the last sync is over 24h
|
|
132
|
+
old — a rotated secret can otherwise look valid or missing. Pass
|
|
133
|
+
`--sync-if-older-than 15m` to sync first instead, or run `bwx sync`. Warnings reach stderr
|
|
134
|
+
even under `--json`; `-q` silences them.
|
|
135
|
+
|
|
136
|
+
## Safety rules
|
|
137
|
+
|
|
138
|
+
- Never use `--full-items` (or `get item` for discovery) in logged sessions.
|
|
139
|
+
- Prefer `bwx run --env ...` over `export VAR="$(bwx get ...)"`; secrets never touch stdout or argv.
|
|
140
|
+
- Request the narrowest field, resolving an exact item ID first.
|
|
141
|
+
- Use `-q` for command substitution so unlock logs cannot contaminate merged output.
|
|
142
|
+
- On writes, prefer `--password-stdin`, `--password-env`, `--field-file`, `--field-env`.
|
|
143
|
+
- Never echo, log, checksum, or commit retrieved values; `unset` temporary variables.
|
|
144
|
+
|
|
145
|
+
## Auth
|
|
146
|
+
|
|
147
|
+
- Master password: macOS Keychain (account `bitwarden`, service `bitwarden-master`)
|
|
148
|
+
- Session cached to `~/.config/bwx/session` (mode 600), given to `bw` via `BW_SESSION`
|
|
149
|
+
- Freshness snapshot cached to `~/.config/bwx/state.json`
|
package/src/bw/fields.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
3
|
+
import type { GlobalOptions } from "../cli/types.ts";
|
|
4
|
+
import { summarizeItem, type ItemSummary } from "./items.ts";
|
|
5
|
+
import { runBwOrThrow } from "./runner.ts";
|
|
6
|
+
import { withSession } from "./session.ts";
|
|
7
|
+
import { BwItemSchema } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Fields `bw get` resolves natively; anything else is a custom field name. */
|
|
10
|
+
const BUILTIN_FIELDS = [
|
|
11
|
+
"password",
|
|
12
|
+
"username",
|
|
13
|
+
"totp",
|
|
14
|
+
"notes",
|
|
15
|
+
"uri",
|
|
16
|
+
"item",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
function isBuiltinField(field: string): boolean {
|
|
20
|
+
return (BUILTIN_FIELDS as readonly string[]).includes(field);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ReadFieldOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Always resolve `field` as a custom field name, so an item whose custom
|
|
26
|
+
* field is called e.g. `password` is still reachable.
|
|
27
|
+
*/
|
|
28
|
+
customOnly?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reads one field from a vault item and returns it verbatim (`item` yields the
|
|
33
|
+
* 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
|
+
*/
|
|
36
|
+
export async function readItemField(
|
|
37
|
+
field: string,
|
|
38
|
+
item: string,
|
|
39
|
+
opts: GlobalOptions,
|
|
40
|
+
options?: ReadFieldOptions,
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
try {
|
|
43
|
+
return isBuiltinField(field) && !options?.customOnly
|
|
44
|
+
? await readBuiltinField(field, item, opts)
|
|
45
|
+
: await readCustomField(field, item, opts);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err instanceof CliError && /more than one result/i.test(err.message)) {
|
|
48
|
+
const matches = await findMatches(item);
|
|
49
|
+
throw new CliError(
|
|
50
|
+
formatAmbiguousError(item, matches, opts),
|
|
51
|
+
ExitCode.BadArgs,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readBuiltinField(
|
|
59
|
+
field: string,
|
|
60
|
+
item: string,
|
|
61
|
+
opts: GlobalOptions,
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const result = await withSession(opts, () =>
|
|
64
|
+
runBwOrThrow(["get", field, item]),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (!result) {
|
|
68
|
+
throw new CliError(
|
|
69
|
+
`No ${field} found for item: ${item}`,
|
|
70
|
+
ExitCode.NotFound,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readCustomField(
|
|
78
|
+
fieldName: string,
|
|
79
|
+
item: string,
|
|
80
|
+
opts: GlobalOptions,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
const json = await withSession(opts, () =>
|
|
83
|
+
runBwOrThrow(["get", "item", item]),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const parsed = BwItemSchema.parse(JSON.parse(json));
|
|
87
|
+
const fields = parsed.fields ?? [];
|
|
88
|
+
const match = fields.find((f) => f.name === fieldName);
|
|
89
|
+
|
|
90
|
+
if (!match) {
|
|
91
|
+
const available = fields.map((f) => f.name).join(", ");
|
|
92
|
+
const msg = available
|
|
93
|
+
? `Field "${fieldName}" not found. Available: ${available}`
|
|
94
|
+
: `Field "${fieldName}" not found (item has no custom fields)`;
|
|
95
|
+
throw new CliError(msg, ExitCode.NotFound);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return match.value ?? "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function findMatches(query: string): Promise<ItemSummary[]> {
|
|
102
|
+
try {
|
|
103
|
+
const json = await runBwOrThrow(["list", "items", "--search", query]);
|
|
104
|
+
const items: Array<Record<string, unknown>> = JSON.parse(json);
|
|
105
|
+
return items.map(summarizeItem);
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatAmbiguousError(
|
|
112
|
+
query: string,
|
|
113
|
+
matches: ItemSummary[],
|
|
114
|
+
opts: GlobalOptions,
|
|
115
|
+
): string {
|
|
116
|
+
if (matches.length === 0) {
|
|
117
|
+
return `Multiple items match "${query}". Use a specific ID instead.`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (opts.json) {
|
|
121
|
+
return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const lines = [`Multiple items match "${query}":\n`];
|
|
125
|
+
for (const m of matches) {
|
|
126
|
+
const user = m.username ? ` (${m.username})` : "";
|
|
127
|
+
lines.push(
|
|
128
|
+
` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { formatDuration } from "../cli/duration.ts";
|
|
2
|
+
import { emitLog, emitWarn } from "../cli/io.ts";
|
|
3
|
+
import type { GlobalOptions } from "../cli/types.ts";
|
|
4
|
+
import { STATE_FILE } from "../config/paths.ts";
|
|
5
|
+
import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
|
|
6
|
+
import { getStatus } from "./status.ts";
|
|
7
|
+
import type { BwStatus } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `bw` serves reads from the local vault copy, so a vault that has not synced in
|
|
11
|
+
* days silently returns rotated-away secrets. Reads therefore check freshness —
|
|
12
|
+
* but `bw status` costs a full `bw` spawn (~2s), far too much to pay on every
|
|
13
|
+
* read, so the answer is cached here and only re-polled when it might matter.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Warn when the vault has not synced within this window. */
|
|
17
|
+
export const STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
|
|
18
|
+
|
|
19
|
+
/** While the vault looks stale, ask `bw status` again at most this often. */
|
|
20
|
+
const RECHECK_INTERVAL_MS = 5 * 60 * 1_000;
|
|
21
|
+
|
|
22
|
+
export interface FreshnessSnapshot {
|
|
23
|
+
/** `lastSync` as reported by `bw status`; null when the vault never synced. */
|
|
24
|
+
lastSync: string | null;
|
|
25
|
+
/** When bwx last asked `bw status` for that value. */
|
|
26
|
+
checkedAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type FreshnessPlan =
|
|
30
|
+
/** Known fresh — no `bw` call, no warning. */
|
|
31
|
+
| "skip"
|
|
32
|
+
/** Known stale recently enough to trust the cached answer. */
|
|
33
|
+
| "stale"
|
|
34
|
+
/** Cached answer is missing, unusable, or too old to trust. */
|
|
35
|
+
| "poll";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Decides what can be concluded from the cached snapshot alone. Pure so the
|
|
39
|
+
* caching policy stays testable without a vault.
|
|
40
|
+
*/
|
|
41
|
+
export function planFreshnessCheck(
|
|
42
|
+
snapshot: FreshnessSnapshot | null,
|
|
43
|
+
thresholdMs: number,
|
|
44
|
+
now: number,
|
|
45
|
+
): FreshnessPlan {
|
|
46
|
+
if (!snapshot) return "poll";
|
|
47
|
+
|
|
48
|
+
if (isFresh(snapshot, thresholdMs, now)) return "skip";
|
|
49
|
+
|
|
50
|
+
const checkedAgeMs = now - Date.parse(snapshot.checkedAt);
|
|
51
|
+
const checkedRecently =
|
|
52
|
+
Number.isFinite(checkedAgeMs) &&
|
|
53
|
+
checkedAgeMs >= 0 &&
|
|
54
|
+
checkedAgeMs < RECHECK_INTERVAL_MS;
|
|
55
|
+
|
|
56
|
+
// lastSync only ever moves forward, so a stale reading stays stale unless
|
|
57
|
+
// another client synced — worth re-checking, just not on every read.
|
|
58
|
+
return checkedRecently ? "stale" : "poll";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Warns (or syncs, with `--sync-if-older-than`) when the local vault is stale.
|
|
63
|
+
* Advisory only: any failure to determine freshness is ignored rather than
|
|
64
|
+
* failing the operation the caller actually asked for.
|
|
65
|
+
*/
|
|
66
|
+
export async function checkVaultFreshness(
|
|
67
|
+
opts: GlobalOptions,
|
|
68
|
+
sync: () => Promise<unknown>,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const syncOnStale = opts.syncIfOlderThanMs !== undefined;
|
|
71
|
+
const thresholdMs = opts.syncIfOlderThanMs ?? STALE_AFTER_MS;
|
|
72
|
+
|
|
73
|
+
let snapshot = loadSnapshot();
|
|
74
|
+
const plan = planFreshnessCheck(snapshot, thresholdMs, Date.now());
|
|
75
|
+
if (plan === "skip") return;
|
|
76
|
+
|
|
77
|
+
if (plan === "poll") {
|
|
78
|
+
try {
|
|
79
|
+
snapshot = recordStatus(await getStatus());
|
|
80
|
+
} catch {
|
|
81
|
+
return; // Freshness is advisory — never block the real operation.
|
|
82
|
+
}
|
|
83
|
+
if (isFresh(snapshot, thresholdMs, Date.now())) return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ageMs = snapshotAgeMs(snapshot, Date.now());
|
|
87
|
+
const age = ageMs === null ? "never" : `${formatDuration(ageMs)} ago`;
|
|
88
|
+
|
|
89
|
+
if (!syncOnStale) {
|
|
90
|
+
emitWarn(
|
|
91
|
+
`Vault last synced ${age} — reads may be stale. Run 'bwx sync' or pass --sync-if-older-than <duration>.`,
|
|
92
|
+
opts,
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
emitLog(`Vault last synced ${age} — syncing...`, opts);
|
|
98
|
+
await sync();
|
|
99
|
+
recordSyncNow();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Caches what `bw status` just reported. */
|
|
103
|
+
export function recordStatus(status: BwStatus): FreshnessSnapshot {
|
|
104
|
+
return saveSnapshot({
|
|
105
|
+
lastSync: status.lastSync,
|
|
106
|
+
checkedAt: new Date().toISOString(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Records a sync that just completed. The stored `lastSync` is our own clock
|
|
112
|
+
* rather than the server's, which is close enough to keep later reads quiet.
|
|
113
|
+
*/
|
|
114
|
+
export function recordSyncNow(): void {
|
|
115
|
+
const now = new Date().toISOString();
|
|
116
|
+
saveSnapshot({ lastSync: now, checkedAt: now });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isFresh(
|
|
120
|
+
snapshot: FreshnessSnapshot | null,
|
|
121
|
+
thresholdMs: number,
|
|
122
|
+
now: number,
|
|
123
|
+
): boolean {
|
|
124
|
+
const ageMs = snapshotAgeMs(snapshot, now);
|
|
125
|
+
return ageMs !== null && ageMs < thresholdMs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function snapshotAgeMs(
|
|
129
|
+
snapshot: FreshnessSnapshot | null,
|
|
130
|
+
now: number,
|
|
131
|
+
): number | null {
|
|
132
|
+
if (!snapshot?.lastSync) return null;
|
|
133
|
+
const syncedAt = Date.parse(snapshot.lastSync);
|
|
134
|
+
if (!Number.isFinite(syncedAt)) return null;
|
|
135
|
+
return Math.max(0, now - syncedAt);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function loadSnapshot(): FreshnessSnapshot | null {
|
|
139
|
+
const read = readPrivateFile(STATE_FILE);
|
|
140
|
+
if (read.kind !== "ok") return null;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(read.text) as Partial<FreshnessSnapshot>;
|
|
144
|
+
if (typeof parsed.checkedAt !== "string") return null;
|
|
145
|
+
return {
|
|
146
|
+
lastSync: typeof parsed.lastSync === "string" ? parsed.lastSync : null,
|
|
147
|
+
checkedAt: parsed.checkedAt,
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function saveSnapshot(snapshot: FreshnessSnapshot): FreshnessSnapshot {
|
|
155
|
+
try {
|
|
156
|
+
writePrivateFile(STATE_FILE, JSON.stringify(snapshot) + "\n");
|
|
157
|
+
} catch {
|
|
158
|
+
// A read-only config dir must not break vault access.
|
|
159
|
+
}
|
|
160
|
+
return snapshot;
|
|
161
|
+
}
|
package/src/bw/items.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BW_TYPE_LABELS } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The only shape of a vault item bwx prints when the caller did not ask for a
|
|
5
|
+
* specific secret. Enough to identify an item and follow up with a narrow `get`,
|
|
6
|
+
* with no room for passwords, notes, custom fields, or URIs to leak into logs.
|
|
7
|
+
*/
|
|
8
|
+
export interface ItemSummary {
|
|
9
|
+
id: string;
|
|
10
|
+
type: string;
|
|
11
|
+
name: string;
|
|
12
|
+
username: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function summarizeItem(item: Record<string, unknown>): ItemSummary {
|
|
16
|
+
const login = item.login as Record<string, unknown> | null | undefined;
|
|
17
|
+
const username = login?.username;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
id: String(item.id ?? ""),
|
|
21
|
+
type: BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`,
|
|
22
|
+
name: String(item.name ?? ""),
|
|
23
|
+
username: typeof username === "string" && username ? username : null,
|
|
24
|
+
};
|
|
25
|
+
}
|