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.
- package/README.md +136 -10
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +107 -14
- package/src/bw/encoding.ts +16 -4
- package/src/bw/fields.ts +326 -27
- package/src/bw/folders.ts +96 -0
- package/src/bw/generate.ts +91 -0
- package/src/bw/items.ts +8 -0
- package/src/bw/session.ts +36 -10
- package/src/bw/totp.ts +246 -0
- package/src/cli/commands/config.ts +1 -10
- package/src/cli/commands/create.ts +60 -5
- package/src/cli/commands/delete.ts +48 -21
- package/src/cli/commands/edit.ts +88 -5
- package/src/cli/commands/folders.ts +34 -0
- package/src/cli/commands/generate.ts +57 -0
- package/src/cli/commands/get.ts +130 -7
- 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 +55 -2
- package/src/cli/io.ts +12 -2
- package/src/cli/items.ts +56 -10
- package/src/cli/program.ts +6 -0
package/src/cli/input.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
|
|
2
|
+
import { normalizeTotpInput } from "../bw/totp.ts";
|
|
2
3
|
import { CliError, ExitCode } from "./errors.ts";
|
|
3
4
|
|
|
4
5
|
export function collect(val: string, prev: string[]): string[] {
|
|
@@ -6,6 +7,17 @@ export function collect(val: string, prev: string[]): string[] {
|
|
|
6
7
|
return prev;
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
/** Reads a single line from stdin, for the few interactive prompts bwx has. */
|
|
11
|
+
export async function readLine(): Promise<string> {
|
|
12
|
+
const chunks: Uint8Array[] = [];
|
|
13
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
14
|
+
chunks.push(chunk);
|
|
15
|
+
const text = Buffer.concat(chunks).toString();
|
|
16
|
+
if (text.includes("\n")) return text.split("\n")[0]!.trim();
|
|
17
|
+
}
|
|
18
|
+
return Buffer.concat(chunks).toString().trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
/**
|
|
10
22
|
* Lazily reads stdin once. Commands validate that only one explicit stdin source
|
|
11
23
|
* is selected, but caching also keeps implicit legacy stdin reads predictable.
|
|
@@ -25,6 +37,8 @@ export interface TextSource {
|
|
|
25
37
|
stdin?: boolean;
|
|
26
38
|
file?: string;
|
|
27
39
|
env?: string;
|
|
40
|
+
/** Produces the value instead of reading it — see `--password-generate`. */
|
|
41
|
+
generate?: () => Promise<string>;
|
|
28
42
|
flagNames: string[];
|
|
29
43
|
allowEmpty?: boolean;
|
|
30
44
|
}
|
|
@@ -45,11 +59,20 @@ export interface ItemTextInputs {
|
|
|
45
59
|
passwordStdin?: boolean;
|
|
46
60
|
passwordFile?: string;
|
|
47
61
|
passwordEnv?: string;
|
|
62
|
+
passwordGenerate?: boolean;
|
|
63
|
+
totp?: string;
|
|
64
|
+
totpStdin?: boolean;
|
|
65
|
+
totpFile?: string;
|
|
66
|
+
totpEnv?: string;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
export interface ResolvedItemTextInputs {
|
|
51
70
|
notes?: string;
|
|
52
71
|
password?: string;
|
|
72
|
+
/** True when the password was generated, so callers can keep it unprinted. */
|
|
73
|
+
passwordGenerated?: boolean;
|
|
74
|
+
/** Normalized and validated by {@link normalizeTotpInput} before it is stored. */
|
|
75
|
+
totp?: string;
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
export interface ExplicitStdinInputs extends ItemTextInputs {
|
|
@@ -63,15 +86,18 @@ const PASSWORD_SOURCE_FLAGS = [
|
|
|
63
86
|
"--password-stdin",
|
|
64
87
|
"--password-file",
|
|
65
88
|
"--password-env",
|
|
89
|
+
"--password-generate",
|
|
66
90
|
];
|
|
91
|
+
const TOTP_SOURCE_FLAGS = ["--totp", "--totp-stdin", "--totp-file", "--totp-env"];
|
|
67
92
|
|
|
68
93
|
export async function resolveItemTextInputs(
|
|
69
94
|
inputs: ItemTextInputs,
|
|
70
95
|
stdin: StdinReader,
|
|
96
|
+
generatePassword?: () => Promise<string>,
|
|
71
97
|
): Promise<ResolvedItemTextInputs> {
|
|
72
98
|
assertSingleStdinSource(listExplicitStdinInputs(inputs));
|
|
73
99
|
|
|
74
|
-
const [notes, password] = await Promise.all([
|
|
100
|
+
const [notes, password, totp] = await Promise.all([
|
|
75
101
|
resolveOptionalTextSource(
|
|
76
102
|
{
|
|
77
103
|
label: "notes",
|
|
@@ -89,13 +115,35 @@ export async function resolveItemTextInputs(
|
|
|
89
115
|
stdin: inputs.passwordStdin,
|
|
90
116
|
file: inputs.passwordFile,
|
|
91
117
|
env: inputs.passwordEnv,
|
|
118
|
+
generate:
|
|
119
|
+
inputs.passwordGenerate && generatePassword
|
|
120
|
+
? generatePassword
|
|
121
|
+
: undefined,
|
|
92
122
|
flagNames: PASSWORD_SOURCE_FLAGS,
|
|
93
123
|
},
|
|
94
124
|
stdin,
|
|
95
125
|
),
|
|
126
|
+
resolveOptionalTextSource(
|
|
127
|
+
{
|
|
128
|
+
label: "TOTP secret",
|
|
129
|
+
inline: inputs.totp,
|
|
130
|
+
stdin: inputs.totpStdin,
|
|
131
|
+
file: inputs.totpFile,
|
|
132
|
+
env: inputs.totpEnv,
|
|
133
|
+
flagNames: TOTP_SOURCE_FLAGS,
|
|
134
|
+
},
|
|
135
|
+
stdin,
|
|
136
|
+
),
|
|
96
137
|
]);
|
|
97
138
|
|
|
98
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
notes,
|
|
141
|
+
password,
|
|
142
|
+
passwordGenerated: inputs.passwordGenerate ?? false,
|
|
143
|
+
// Validated here rather than at the call sites so every write path gets
|
|
144
|
+
// the check: a malformed seed is only ever noticed at the login it breaks.
|
|
145
|
+
totp: totp === undefined ? undefined : normalizeTotpInput(totp),
|
|
146
|
+
};
|
|
99
147
|
}
|
|
100
148
|
|
|
101
149
|
export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
|
|
@@ -103,6 +151,8 @@ export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
|
|
|
103
151
|
inputs.notesFile === "-" ? "--notes-file -" : null,
|
|
104
152
|
inputs.passwordStdin ? "--password-stdin" : null,
|
|
105
153
|
inputs.passwordFile === "-" ? "--password-file -" : null,
|
|
154
|
+
inputs.totpStdin ? "--totp-stdin" : null,
|
|
155
|
+
inputs.totpFile === "-" ? "--totp-file -" : null,
|
|
106
156
|
...(inputs.fieldFileFlag && inputs.fieldFiles
|
|
107
157
|
? stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles)
|
|
108
158
|
: []),
|
|
@@ -118,6 +168,7 @@ export async function resolveOptionalTextSource(
|
|
|
118
168
|
source.stdin ? "stdin" : null,
|
|
119
169
|
source.file !== undefined ? "file" : null,
|
|
120
170
|
source.env !== undefined ? "env" : null,
|
|
171
|
+
source.generate !== undefined ? "generate" : null,
|
|
121
172
|
].filter((value): value is string => value !== null);
|
|
122
173
|
|
|
123
174
|
if (selected.length > 1) {
|
|
@@ -136,6 +187,8 @@ export async function resolveOptionalTextSource(
|
|
|
136
187
|
value = await stdin.read();
|
|
137
188
|
} else if (source.file !== undefined) {
|
|
138
189
|
value = await readFileOrStdin(source.file, stdin, source.label);
|
|
190
|
+
} else if (source.generate !== undefined) {
|
|
191
|
+
value = await source.generate();
|
|
139
192
|
} else {
|
|
140
193
|
value = readEnvValue(source.env!, source.label);
|
|
141
194
|
}
|
package/src/cli/io.ts
CHANGED
|
@@ -2,9 +2,19 @@ import pc from "picocolors";
|
|
|
2
2
|
import { CliError, ExitCode } from "./errors.ts";
|
|
3
3
|
import type { GlobalOptions } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `meta` rides along in the JSON envelope for facts about the response itself
|
|
7
|
+
* (truncation, counts) that a machine caller cannot recover from `data` alone.
|
|
8
|
+
* It is omitted entirely when absent, so `{ data }` stays the stable shape.
|
|
9
|
+
*/
|
|
10
|
+
export function emitData(
|
|
11
|
+
value: unknown,
|
|
12
|
+
opts: GlobalOptions,
|
|
13
|
+
meta?: Record<string, unknown>,
|
|
14
|
+
): void {
|
|
6
15
|
if (opts.json) {
|
|
7
|
-
|
|
16
|
+
const payload = meta ? { data: value, meta } : { data: value };
|
|
17
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
8
18
|
} else if (opts.plain) {
|
|
9
19
|
if (typeof value === "string") {
|
|
10
20
|
process.stdout.write(value + "\n");
|
package/src/cli/items.ts
CHANGED
|
@@ -2,9 +2,17 @@ import pc from "picocolors";
|
|
|
2
2
|
import { summarizeItem, type ItemSummary } from "../bw/items.ts";
|
|
3
3
|
import { BW_TYPE_FROM_NAME } from "../bw/types.ts";
|
|
4
4
|
import { CliError, ExitCode } from "./errors.ts";
|
|
5
|
-
import { emitData } from "./io.ts";
|
|
5
|
+
import { emitData, emitWarn } from "./io.ts";
|
|
6
6
|
import type { GlobalOptions } from "./types.ts";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Listings are capped by default: an uncapped `bwx list` returns the whole vault,
|
|
10
|
+
* which floods an agent's context and, with `--full-items`, dumps every secret in
|
|
11
|
+
* one call. `--limit 0` is the explicit opt-out, and truncation is always
|
|
12
|
+
* reported so a capped listing can never be mistaken for a complete one.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_LIMIT = 50;
|
|
15
|
+
|
|
8
16
|
export interface ItemListOptions {
|
|
9
17
|
type?: string;
|
|
10
18
|
limit?: number;
|
|
@@ -12,10 +20,29 @@ export interface ItemListOptions {
|
|
|
12
20
|
fullItems?: boolean;
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
export interface FilteredItems {
|
|
24
|
+
items: Array<Record<string, unknown>>;
|
|
25
|
+
/** Items matching the filters, before the limit was applied. */
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Commander option parser: rejects garbage instead of silently ignoring it. */
|
|
30
|
+
export function parseLimit(raw: string): number {
|
|
31
|
+
// `Number("")` and `Number(" ")` are 0, which would read as "no limit".
|
|
32
|
+
const value = raw.trim() === "" ? Number.NaN : Number(raw);
|
|
33
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
34
|
+
throw new CliError(
|
|
35
|
+
`Invalid --limit "${raw}". Use a non-negative integer (0 for no limit).`,
|
|
36
|
+
ExitCode.BadArgs,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
export function filterItems(
|
|
16
43
|
items: Array<Record<string, unknown>>,
|
|
17
44
|
options: ItemListOptions,
|
|
18
|
-
):
|
|
45
|
+
): FilteredItems {
|
|
19
46
|
let result = items;
|
|
20
47
|
|
|
21
48
|
if (options.type) {
|
|
@@ -29,11 +56,10 @@ export function filterItems(
|
|
|
29
56
|
result = result.filter((item) => item.type === typeVal);
|
|
30
57
|
}
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
59
|
+
const total = result.length;
|
|
60
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
35
61
|
|
|
36
|
-
return result;
|
|
62
|
+
return { items: limit > 0 ? result.slice(0, limit) : result, total };
|
|
37
63
|
}
|
|
38
64
|
|
|
39
65
|
/**
|
|
@@ -41,14 +67,32 @@ export function filterItems(
|
|
|
41
67
|
* the same reduced projection, so asking for machine-readable output is a
|
|
42
68
|
* serialization choice and never widens data access. `--full-items` is the
|
|
43
69
|
* explicit opt-in for callers that genuinely need the complete objects.
|
|
70
|
+
*
|
|
71
|
+
* A truncated listing reports what it dropped through every channel — a stderr
|
|
72
|
+
* warning that survives `--json`, plus `meta` in the JSON envelope — because a
|
|
73
|
+
* silently capped list reads exactly like a complete one.
|
|
44
74
|
*/
|
|
45
75
|
export function writeItems(
|
|
46
|
-
|
|
76
|
+
filtered: FilteredItems,
|
|
47
77
|
opts: GlobalOptions,
|
|
48
78
|
options: ItemListOptions = {},
|
|
49
79
|
): void {
|
|
80
|
+
const { items, total } = filtered;
|
|
81
|
+
const omitted = total - items.length;
|
|
82
|
+
|
|
83
|
+
if (omitted > 0) {
|
|
84
|
+
emitWarn(
|
|
85
|
+
`Showing ${items.length} of ${total} items — pass --limit 0 for all.`,
|
|
86
|
+
opts,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
if (opts.json) {
|
|
51
|
-
emitData(
|
|
91
|
+
emitData(
|
|
92
|
+
options.fullItems ? items : items.map(summarizeItem),
|
|
93
|
+
opts,
|
|
94
|
+
{ total, shown: items.length, truncated: omitted > 0 },
|
|
95
|
+
);
|
|
52
96
|
return;
|
|
53
97
|
}
|
|
54
98
|
|
|
@@ -63,12 +107,14 @@ export function writeItems(
|
|
|
63
107
|
}
|
|
64
108
|
|
|
65
109
|
function plainLine(summary: ItemSummary): string {
|
|
66
|
-
|
|
110
|
+
const totp = summary.hasTotp ? "totp" : "-";
|
|
111
|
+
return `${summary.id}\t${summary.type}\t${summary.name}\t${summary.username ?? "-"}\t${totp}`;
|
|
67
112
|
}
|
|
68
113
|
|
|
69
114
|
function humanLine(summary: ItemSummary): string {
|
|
70
115
|
const userPart = summary.username ? pc.dim(` (${summary.username})`) : "";
|
|
71
|
-
|
|
116
|
+
const totpPart = summary.hasTotp ? " " + pc.yellow("2fa") : "";
|
|
117
|
+
return `${pc.dim(summary.id)} ${pc.cyan(summary.type)} ${summary.name}${userPart}${totpPart}`;
|
|
72
118
|
}
|
|
73
119
|
|
|
74
120
|
function writeLines<T>(values: T[], format: (value: T) => string): void {
|
package/src/cli/program.ts
CHANGED
|
@@ -17,6 +17,9 @@ import { registerDoctor } from "./commands/doctor.ts";
|
|
|
17
17
|
import { registerList } from "./commands/list.ts";
|
|
18
18
|
import { registerAttach } from "./commands/attach.ts";
|
|
19
19
|
import { registerRun } from "./commands/run.ts";
|
|
20
|
+
import { registerGenerate } from "./commands/generate.ts";
|
|
21
|
+
import { registerFolders } from "./commands/folders.ts";
|
|
22
|
+
import { registerTrash } from "./commands/trash.ts";
|
|
20
23
|
|
|
21
24
|
export function buildProgram(): Command {
|
|
22
25
|
const program = new Command();
|
|
@@ -59,8 +62,11 @@ export function buildProgram(): Command {
|
|
|
59
62
|
registerCreate(program);
|
|
60
63
|
registerEdit(program);
|
|
61
64
|
registerDelete(program);
|
|
65
|
+
registerTrash(program);
|
|
62
66
|
registerAttach(program);
|
|
63
67
|
registerList(program);
|
|
68
|
+
registerFolders(program);
|
|
69
|
+
registerGenerate(program);
|
|
64
70
|
registerConfig(program);
|
|
65
71
|
registerDoctor(program);
|
|
66
72
|
|