bwx 0.4.0 → 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 +3 -2
- package/skill/bwx/SKILL.md +196 -0
- 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
|
@@ -5,6 +5,8 @@ import { CliError, ExitCode } from "../errors.ts";
|
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
7
|
import { buildNewItem, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
import { generateSecret } from "../../bw/generate.ts";
|
|
9
|
+
import { resolveFolderRef } from "../../bw/folders.ts";
|
|
8
10
|
import {
|
|
9
11
|
assertSingleStdinSource,
|
|
10
12
|
collect,
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
StdinReader,
|
|
16
18
|
type ItemTextInputs,
|
|
17
19
|
} from "../input.ts";
|
|
20
|
+
import { redactGeneratedPassword } from "./generate.ts";
|
|
18
21
|
|
|
19
22
|
interface CreateOptions extends ItemTextInputs {
|
|
20
23
|
type: string;
|
|
@@ -27,8 +30,19 @@ interface CreateOptions extends ItemTextInputs {
|
|
|
27
30
|
folder?: string;
|
|
28
31
|
favorite?: boolean;
|
|
29
32
|
fromJson?: boolean;
|
|
33
|
+
generateLength?: number;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/** Flags a secure note has nowhere to store — see {@link assertLoginFlags}. */
|
|
37
|
+
const LOGIN_ONLY_FLAGS: Array<[keyof CreateOptions, string]> = [
|
|
38
|
+
["username", "--username"],
|
|
39
|
+
["password", "--password"],
|
|
40
|
+
["passwordStdin", "--password-stdin"],
|
|
41
|
+
["passwordFile", "--password-file"],
|
|
42
|
+
["passwordEnv", "--password-env"],
|
|
43
|
+
["passwordGenerate", "--password-generate"],
|
|
44
|
+
];
|
|
45
|
+
|
|
32
46
|
export function registerCreate(program: Command): void {
|
|
33
47
|
program
|
|
34
48
|
.command("create").alias("add")
|
|
@@ -42,11 +56,13 @@ export function registerCreate(program: Command): void {
|
|
|
42
56
|
.option("--password-stdin", "Read password from stdin")
|
|
43
57
|
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
44
58
|
.option("--password-env <name>", "Read password from environment variable")
|
|
59
|
+
.option("--password-generate", "Generate the password; it is never printed")
|
|
60
|
+
.option("--generate-length <n>", "Length for --password-generate", parseInt)
|
|
45
61
|
.option("--uri <url>", "URI (repeatable)", collect, [])
|
|
46
62
|
.option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
|
|
47
63
|
.option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
|
|
48
64
|
.option("--field-env <kv>", "Custom field value from env var k=ENV (repeatable)", collect, [])
|
|
49
|
-
.option("--folder <id>", "Folder ID")
|
|
65
|
+
.option("--folder <name|id>", "Folder name or ID")
|
|
50
66
|
.option("--favorite", "Mark as favorite")
|
|
51
67
|
.option("--from-json", "Read full item JSON from stdin")
|
|
52
68
|
.action(async function (
|
|
@@ -57,6 +73,7 @@ export function registerCreate(program: Command): void {
|
|
|
57
73
|
const stdin = new StdinReader();
|
|
58
74
|
|
|
59
75
|
let encoded: string;
|
|
76
|
+
let generated = false;
|
|
60
77
|
|
|
61
78
|
if (localOpts.fromJson) {
|
|
62
79
|
const stdinText = await stdin.read();
|
|
@@ -82,6 +99,8 @@ export function registerCreate(program: Command): void {
|
|
|
82
99
|
);
|
|
83
100
|
}
|
|
84
101
|
|
|
102
|
+
assertLoginFlags(localOpts);
|
|
103
|
+
|
|
85
104
|
const explicitStdinSources = listExplicitStdinInputs({
|
|
86
105
|
...localOpts,
|
|
87
106
|
fieldFileFlag: "--field-file",
|
|
@@ -89,7 +108,10 @@ export function registerCreate(program: Command): void {
|
|
|
89
108
|
});
|
|
90
109
|
assertSingleStdinSource(explicitStdinSources);
|
|
91
110
|
|
|
92
|
-
const textInputs = await resolveItemTextInputs(localOpts, stdin)
|
|
111
|
+
const textInputs = await resolveItemTextInputs(localOpts, stdin, () =>
|
|
112
|
+
generateSecret({ length: localOpts.generateLength }),
|
|
113
|
+
);
|
|
114
|
+
generated = textInputs.passwordGenerated ?? false;
|
|
93
115
|
let notes = textInputs.notes;
|
|
94
116
|
|
|
95
117
|
// Backward compatibility: piped create input becomes note content.
|
|
@@ -119,7 +141,9 @@ export function registerCreate(program: Command): void {
|
|
|
119
141
|
password: textInputs.password ?? null,
|
|
120
142
|
uris: localOpts.uri,
|
|
121
143
|
fields,
|
|
122
|
-
folderId: localOpts.folder
|
|
144
|
+
folderId: localOpts.folder
|
|
145
|
+
? await resolveFolderRef(localOpts.folder, opts)
|
|
146
|
+
: null,
|
|
123
147
|
favorite: localOpts.favorite ?? false,
|
|
124
148
|
});
|
|
125
149
|
|
|
@@ -132,11 +156,33 @@ export function registerCreate(program: Command): void {
|
|
|
132
156
|
|
|
133
157
|
const created = JSON.parse(result);
|
|
134
158
|
if (opts.json) {
|
|
135
|
-
emitData(created, opts);
|
|
159
|
+
emitData(redactGeneratedPassword(created, generated), opts);
|
|
136
160
|
} else if (opts.plain) {
|
|
137
161
|
emitData(created.id, opts);
|
|
138
162
|
} else {
|
|
139
|
-
|
|
163
|
+
const note = generated ? " — password generated (not printed)" : "";
|
|
164
|
+
emitSuccess(`Created "${created.name}" (${created.id})${note}`, opts);
|
|
140
165
|
}
|
|
141
166
|
});
|
|
142
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A secure note has no login object, so `buildNewItem` drops usernames,
|
|
171
|
+
* passwords, and URIs on the floor. Accepting them silently means a caller can
|
|
172
|
+
* "store" a credential, get exit 0, and end up with an item that never held it.
|
|
173
|
+
*/
|
|
174
|
+
function assertLoginFlags(localOpts: CreateOptions): void {
|
|
175
|
+
if (localOpts.type === "login") return;
|
|
176
|
+
|
|
177
|
+
const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
|
|
178
|
+
([, flag]) => flag,
|
|
179
|
+
);
|
|
180
|
+
if (localOpts.uri.length > 0) used.push("--uri");
|
|
181
|
+
|
|
182
|
+
if (used.length > 0) {
|
|
183
|
+
throw new CliError(
|
|
184
|
+
`${used.join(", ")} ${used.length > 1 ? "need" : "needs"} --type login (a note cannot store them).`,
|
|
185
|
+
ExitCode.BadArgs,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -4,6 +4,8 @@ import { emitData, emitSuccess } from "../io.ts";
|
|
|
4
4
|
import { CliError, ExitCode } from "../errors.ts";
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
|
+
import { readLine } from "../input.ts";
|
|
8
|
+
import type { GlobalOptions } from "../types.ts";
|
|
7
9
|
import pc from "picocolors";
|
|
8
10
|
|
|
9
11
|
export function registerDelete(program: Command): void {
|
|
@@ -12,7 +14,7 @@ export function registerDelete(program: Command): void {
|
|
|
12
14
|
.description("Delete a vault item")
|
|
13
15
|
.argument("<item>", "Item name or ID")
|
|
14
16
|
.option("--permanent", "Permanently delete (no trash)")
|
|
15
|
-
.option("--force", "Skip confirmation prompt")
|
|
17
|
+
.option("--force", "Skip confirmation prompt (required when not interactive)")
|
|
16
18
|
.action(async function (
|
|
17
19
|
this: Command,
|
|
18
20
|
item: string,
|
|
@@ -28,17 +30,7 @@ export function registerDelete(program: Command): void {
|
|
|
28
30
|
const itemId: string = current.id;
|
|
29
31
|
const itemName: string = current.name;
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
if (!localOpts.force && !opts.json && !opts.plain && process.stdin.isTTY) {
|
|
33
|
-
const label = localOpts.permanent ? "permanently delete" : "delete";
|
|
34
|
-
process.stderr.write(
|
|
35
|
-
`${pc.yellow("?")} ${label} "${itemName}" (${itemId})? [y/N] `,
|
|
36
|
-
);
|
|
37
|
-
const answer = await readLine();
|
|
38
|
-
if (answer.toLowerCase() !== "y") {
|
|
39
|
-
throw new CliError("Cancelled", ExitCode.UserCancelled);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
33
|
+
await confirmDelete(itemName, itemId, localOpts, opts);
|
|
42
34
|
|
|
43
35
|
const args = ["delete", "item", itemId];
|
|
44
36
|
if (localOpts.permanent) args.push("--permanent");
|
|
@@ -46,22 +38,57 @@ export function registerDelete(program: Command): void {
|
|
|
46
38
|
await withSession(opts, () => runBwOrThrow(args));
|
|
47
39
|
|
|
48
40
|
if (opts.json) {
|
|
49
|
-
emitData(
|
|
41
|
+
emitData(
|
|
42
|
+
{
|
|
43
|
+
id: itemId,
|
|
44
|
+
name: itemName,
|
|
45
|
+
deleted: true,
|
|
46
|
+
permanent: localOpts.permanent ?? false,
|
|
47
|
+
},
|
|
48
|
+
opts,
|
|
49
|
+
);
|
|
50
50
|
} else if (opts.plain) {
|
|
51
51
|
emitData(itemId, opts);
|
|
52
52
|
} else {
|
|
53
53
|
const label = localOpts.permanent ? "Permanently deleted" : "Deleted";
|
|
54
|
-
|
|
54
|
+
const undo = localOpts.permanent
|
|
55
|
+
? ""
|
|
56
|
+
: ` — undo with ${pc.dim(`bwx restore ${itemId}`)}`;
|
|
57
|
+
emitSuccess(`${label} "${itemName}" (${itemId})${undo}`, opts);
|
|
55
58
|
}
|
|
56
59
|
});
|
|
60
|
+
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* A prompt only guards a human at a terminal. The common caller here is a script
|
|
65
|
+
* or an agent, where the prompt never renders — so anything non-interactive must
|
|
66
|
+
* say `--force` out loud instead of being deleted on the strength of a flag that
|
|
67
|
+
* silently did nothing.
|
|
68
|
+
*/
|
|
69
|
+
async function confirmDelete(
|
|
70
|
+
itemName: string,
|
|
71
|
+
itemId: string,
|
|
72
|
+
localOpts: { permanent?: boolean; force?: boolean },
|
|
73
|
+
opts: GlobalOptions,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
if (localOpts.force) return;
|
|
76
|
+
|
|
77
|
+
const interactive = process.stdin.isTTY && !opts.json && !opts.plain;
|
|
78
|
+
const label = localOpts.permanent ? "permanently delete" : "delete";
|
|
79
|
+
|
|
80
|
+
if (!interactive) {
|
|
81
|
+
throw new CliError(
|
|
82
|
+
`Refusing to ${label} "${itemName}" (${itemId}) without confirmation. Pass --force.`,
|
|
83
|
+
ExitCode.BadArgs,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
`${pc.yellow("?")} ${label} "${itemName}" (${itemId})? [y/N] `,
|
|
89
|
+
);
|
|
90
|
+
const answer = await readLine();
|
|
91
|
+
if (answer.toLowerCase() !== "y") {
|
|
92
|
+
throw new CliError("Cancelled", ExitCode.UserCancelled);
|
|
65
93
|
}
|
|
66
|
-
return Buffer.concat(chunks).toString().trim();
|
|
67
94
|
}
|
package/src/cli/commands/edit.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { CliError, ExitCode } from "../errors.ts";
|
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
7
|
import { patchItem, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
import { generateSecret } from "../../bw/generate.ts";
|
|
9
|
+
import { resolveFolderRef } from "../../bw/folders.ts";
|
|
10
|
+
import { BwItemType } from "../../bw/types.ts";
|
|
8
11
|
import {
|
|
9
12
|
assertSingleStdinSource,
|
|
10
13
|
collect,
|
|
@@ -14,6 +17,7 @@ import {
|
|
|
14
17
|
StdinReader,
|
|
15
18
|
type ItemTextInputs,
|
|
16
19
|
} from "../input.ts";
|
|
20
|
+
import { redactGeneratedPassword } from "./generate.ts";
|
|
17
21
|
|
|
18
22
|
interface EditOptions extends ItemTextInputs {
|
|
19
23
|
name?: string;
|
|
@@ -26,8 +30,18 @@ interface EditOptions extends ItemTextInputs {
|
|
|
26
30
|
folder?: string;
|
|
27
31
|
favorite?: boolean;
|
|
28
32
|
fromJson?: boolean;
|
|
33
|
+
generateLength?: number;
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
const LOGIN_ONLY_FLAGS: Array<[keyof EditOptions, string]> = [
|
|
37
|
+
["username", "--username"],
|
|
38
|
+
["password", "--password"],
|
|
39
|
+
["passwordStdin", "--password-stdin"],
|
|
40
|
+
["passwordFile", "--password-file"],
|
|
41
|
+
["passwordEnv", "--password-env"],
|
|
42
|
+
["passwordGenerate", "--password-generate"],
|
|
43
|
+
];
|
|
44
|
+
|
|
31
45
|
export function registerEdit(program: Command): void {
|
|
32
46
|
program
|
|
33
47
|
.command("edit")
|
|
@@ -41,12 +55,14 @@ export function registerEdit(program: Command): void {
|
|
|
41
55
|
.option("--password-stdin", "Read password from stdin")
|
|
42
56
|
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
43
57
|
.option("--password-env <name>", "Read password from environment variable")
|
|
58
|
+
.option("--password-generate", "Generate the password; it is never printed")
|
|
59
|
+
.option("--generate-length <n>", "Length for --password-generate", parseInt)
|
|
44
60
|
.option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
|
|
45
61
|
.option("--add-field <kv>", "Add/update field k=v", collect, [])
|
|
46
62
|
.option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
|
|
47
63
|
.option("--add-field-env <kv>", "Add/update field value from env var k=ENV", collect, [])
|
|
48
64
|
.option("--rm-field <name>", "Remove field by name", collect, [])
|
|
49
|
-
.option("--folder <id>", "Move to folder")
|
|
65
|
+
.option("--folder <name|id>", "Move to folder ('none' to unfile)")
|
|
50
66
|
.option("--favorite", "Set favorite")
|
|
51
67
|
.option("--no-favorite", "Unset favorite")
|
|
52
68
|
.option("--from-json", "Read full item JSON from stdin (replaces item)")
|
|
@@ -57,6 +73,7 @@ export function registerEdit(program: Command): void {
|
|
|
57
73
|
) {
|
|
58
74
|
const opts = getGlobalOpts(this);
|
|
59
75
|
const stdin = new StdinReader();
|
|
76
|
+
let generated = false;
|
|
60
77
|
|
|
61
78
|
const result = await withSession(opts, async () => {
|
|
62
79
|
// Fetch current item
|
|
@@ -73,6 +90,8 @@ export function registerEdit(program: Command): void {
|
|
|
73
90
|
}
|
|
74
91
|
encoded = bwEncode(stdinText);
|
|
75
92
|
} else {
|
|
93
|
+
assertLoginFlags(localOpts, current);
|
|
94
|
+
|
|
76
95
|
assertSingleStdinSource(
|
|
77
96
|
listExplicitStdinInputs({
|
|
78
97
|
...localOpts,
|
|
@@ -81,7 +100,10 @@ export function registerEdit(program: Command): void {
|
|
|
81
100
|
}),
|
|
82
101
|
);
|
|
83
102
|
|
|
84
|
-
const textInputs = await resolveItemTextInputs(localOpts, stdin)
|
|
103
|
+
const textInputs = await resolveItemTextInputs(localOpts, stdin, () =>
|
|
104
|
+
generateSecret({ length: localOpts.generateLength }),
|
|
105
|
+
);
|
|
106
|
+
generated = textInputs.passwordGenerated ?? false;
|
|
85
107
|
|
|
86
108
|
const addFields = await resolveFieldSources({
|
|
87
109
|
inline: localOpts.addField,
|
|
@@ -100,7 +122,9 @@ export function registerEdit(program: Command): void {
|
|
|
100
122
|
uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
|
|
101
123
|
addFields,
|
|
102
124
|
rmFields: localOpts.rmField,
|
|
103
|
-
folderId: localOpts.folder
|
|
125
|
+
folderId: localOpts.folder
|
|
126
|
+
? await resolveFolderRef(localOpts.folder, opts)
|
|
127
|
+
: undefined,
|
|
104
128
|
favorite: localOpts.favorite,
|
|
105
129
|
});
|
|
106
130
|
encoded = bwEncode(JSON.stringify(patched));
|
|
@@ -111,11 +135,35 @@ export function registerEdit(program: Command): void {
|
|
|
111
135
|
|
|
112
136
|
const updated = JSON.parse(result);
|
|
113
137
|
if (opts.json) {
|
|
114
|
-
emitData(updated, opts);
|
|
138
|
+
emitData(redactGeneratedPassword(updated, generated), opts);
|
|
115
139
|
} else if (opts.plain) {
|
|
116
140
|
emitData(updated.id, opts);
|
|
117
141
|
} else {
|
|
118
|
-
|
|
142
|
+
const note = generated ? " — password generated (not printed)" : "";
|
|
143
|
+
emitSuccess(`Updated "${updated.name}" (${updated.id})${note}`, opts);
|
|
119
144
|
}
|
|
120
145
|
});
|
|
121
146
|
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Same trap as `create`: only a login item has somewhere to put a password, so
|
|
150
|
+
* setting one on a note would be accepted and then quietly lost.
|
|
151
|
+
*/
|
|
152
|
+
function assertLoginFlags(
|
|
153
|
+
localOpts: EditOptions,
|
|
154
|
+
current: Record<string, unknown>,
|
|
155
|
+
): void {
|
|
156
|
+
if (current.type === BwItemType.Login) return;
|
|
157
|
+
|
|
158
|
+
const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
|
|
159
|
+
([, flag]) => flag,
|
|
160
|
+
);
|
|
161
|
+
if (localOpts.uri.length > 0) used.push("--uri");
|
|
162
|
+
|
|
163
|
+
if (used.length === 0) return;
|
|
164
|
+
|
|
165
|
+
throw new CliError(
|
|
166
|
+
`${used.join(", ")} ${used.length > 1 ? "need" : "needs"} a login item, but "${String(current.name)}" is not one.`,
|
|
167
|
+
ExitCode.BadArgs,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { listFolders } from "../../bw/folders.ts";
|
|
4
|
+
import { emitData } from "../io.ts";
|
|
5
|
+
import { getGlobalOpts } from "../program.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Folder IDs are the only handle `--folder` used to accept, and nothing else in
|
|
9
|
+
* bwx ever printed one — so this is what makes that flag usable at all.
|
|
10
|
+
*/
|
|
11
|
+
export function registerFolders(program: Command): void {
|
|
12
|
+
program
|
|
13
|
+
.command("folders")
|
|
14
|
+
.alias("folder")
|
|
15
|
+
.description("List vault folders")
|
|
16
|
+
.action(async function (this: Command) {
|
|
17
|
+
const opts = getGlobalOpts(this);
|
|
18
|
+
const folders = await listFolders(opts);
|
|
19
|
+
|
|
20
|
+
if (opts.json) {
|
|
21
|
+
emitData(folders, opts);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const folder of folders) {
|
|
26
|
+
const id = folder.id ?? "-";
|
|
27
|
+
process.stdout.write(
|
|
28
|
+
opts.plain
|
|
29
|
+
? `${id}\t${folder.name}\n`
|
|
30
|
+
: `${pc.dim(id)} ${folder.name}\n`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { generateSecret, type GenerateOptions } from "../../bw/generate.ts";
|
|
3
|
+
import { CliError, ExitCode } from "../errors.ts";
|
|
4
|
+
import { emitData } from "../io.ts";
|
|
5
|
+
import { getGlobalOpts } from "../program.ts";
|
|
6
|
+
|
|
7
|
+
export function registerGenerate(program: Command): void {
|
|
8
|
+
program
|
|
9
|
+
.command("generate").alias("gen")
|
|
10
|
+
.description("Generate a password or passphrase (local, no vault access)")
|
|
11
|
+
.option("--length <n>", "Password length", parsePositiveInt("--length"))
|
|
12
|
+
.option("--no-uppercase", "Exclude uppercase letters")
|
|
13
|
+
.option("--no-lowercase", "Exclude lowercase letters")
|
|
14
|
+
.option("--no-numbers", "Exclude digits")
|
|
15
|
+
.option("-s, --special", "Include special characters")
|
|
16
|
+
.option("--ambiguous", "Allow ambiguous characters")
|
|
17
|
+
.option("--passphrase", "Generate a passphrase instead")
|
|
18
|
+
.option("--words <n>", "Words in the passphrase", parsePositiveInt("--words"))
|
|
19
|
+
.option("--separator <char>", "Passphrase word separator")
|
|
20
|
+
.option("--capitalize", "Capitalize passphrase words")
|
|
21
|
+
.option("--include-number", "Include a number in the passphrase")
|
|
22
|
+
.action(async function (this: Command, localOpts: GenerateOptions) {
|
|
23
|
+
const opts = getGlobalOpts(this);
|
|
24
|
+
emitData(await generateSecret(localOpts), opts);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strips a password bwx generated out of the echoed item. The entire point of
|
|
30
|
+
* `--password-generate` is that the value goes to the vault without passing
|
|
31
|
+
* through the caller's terminal, transcript, or logs — echoing it back in the
|
|
32
|
+
* `--json` result would hand it straight back.
|
|
33
|
+
*/
|
|
34
|
+
export function redactGeneratedPassword(
|
|
35
|
+
item: Record<string, unknown>,
|
|
36
|
+
generated: boolean,
|
|
37
|
+
): Record<string, unknown> {
|
|
38
|
+
if (!generated) return item;
|
|
39
|
+
|
|
40
|
+
const login = item.login as Record<string, unknown> | null | undefined;
|
|
41
|
+
if (!login) return item;
|
|
42
|
+
|
|
43
|
+
return { ...item, login: { ...login, password: "***GENERATED***" } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parsePositiveInt(flag: string): (raw: string) => number {
|
|
47
|
+
return (raw: string) => {
|
|
48
|
+
const value = Number(raw);
|
|
49
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
50
|
+
throw new CliError(
|
|
51
|
+
`Invalid ${flag} "${raw}". Use a positive integer.`,
|
|
52
|
+
ExitCode.BadArgs,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { emitData } from "../io.ts";
|
|
4
4
|
import { readItemField } from "../../bw/fields.ts";
|
|
5
|
+
import { CliError } from "../errors.ts";
|
|
5
6
|
|
|
6
7
|
export function registerGet(program: Command): void {
|
|
7
8
|
program
|
|
@@ -18,11 +19,34 @@ export function registerGet(program: Command): void {
|
|
|
18
19
|
|
|
19
20
|
program
|
|
20
21
|
.command("field")
|
|
21
|
-
.description("Get a custom field from a vault item")
|
|
22
|
-
|
|
22
|
+
.description("Get a custom field from a vault item (never a built-in)")
|
|
23
|
+
// Same order as `get`, which is the whole point of this command existing
|
|
24
|
+
// alongside it — the two used to take their arguments back to front.
|
|
23
25
|
.argument("<name>", "Custom field name")
|
|
24
|
-
.
|
|
26
|
+
.argument("<item>", "Item name or ID")
|
|
27
|
+
.action(async function (this: Command, name: string, item: string) {
|
|
25
28
|
const opts = getGlobalOpts(this);
|
|
26
|
-
|
|
29
|
+
try {
|
|
30
|
+
emitData(
|
|
31
|
+
await readItemField(name, item, opts, { customOnly: true }),
|
|
32
|
+
opts,
|
|
33
|
+
);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw withOrderHint(err);
|
|
36
|
+
}
|
|
27
37
|
});
|
|
28
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `bwx field` took `<item> <name>` until 0.5.0. A caller using the old order
|
|
42
|
+
* fails somewhere unhelpful — a missing item, or a missing field on the wrong
|
|
43
|
+
* item — so say what changed rather than leaving them to guess.
|
|
44
|
+
*/
|
|
45
|
+
function withOrderHint(err: unknown): unknown {
|
|
46
|
+
if (!(err instanceof CliError)) return err;
|
|
47
|
+
|
|
48
|
+
return new CliError(
|
|
49
|
+
`${err.message}\n\nNote: 'bwx field' takes <name> <item> as of 0.5.0 (it was <item> <name>).`,
|
|
50
|
+
err.exitCode,
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/cli/commands/list.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { CliError, ExitCode } from "../errors.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
filterItems,
|
|
6
|
+
parseLimit,
|
|
7
|
+
writeItems,
|
|
8
|
+
type ItemListOptions,
|
|
9
|
+
} from "../items.ts";
|
|
5
10
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
11
|
import { withSession } from "../../bw/session.ts";
|
|
12
|
+
import { resolveFolderFilter } from "../../bw/folders.ts";
|
|
7
13
|
|
|
8
14
|
export function registerList(program: Command): void {
|
|
9
15
|
program
|
|
@@ -11,28 +17,39 @@ export function registerList(program: Command): void {
|
|
|
11
17
|
.alias("ls")
|
|
12
18
|
.description("List all vault items")
|
|
13
19
|
.argument("[type]", "Filter by type: logins|notes|cards|identities")
|
|
14
|
-
.option("--
|
|
15
|
-
.option("--
|
|
20
|
+
.option("--type <type>", "Filter by type (same as the positional form)")
|
|
21
|
+
.option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
|
|
22
|
+
.option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
|
|
16
23
|
.option("--full-items", "Emit complete item objects (includes secrets)")
|
|
17
24
|
.action(async function (
|
|
18
25
|
this: Command,
|
|
19
|
-
|
|
26
|
+
positionalType: string | undefined,
|
|
20
27
|
localOpts: ItemListOptions & { folder?: string },
|
|
21
28
|
) {
|
|
22
29
|
const opts = getGlobalOpts(this);
|
|
23
30
|
|
|
31
|
+
if (positionalType && localOpts.type && positionalType !== localOpts.type) {
|
|
32
|
+
throw new CliError(
|
|
33
|
+
`Conflicting types: "${positionalType}" and --type ${localOpts.type}`,
|
|
34
|
+
ExitCode.BadArgs,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
const args = ["list", "items"];
|
|
25
39
|
if (localOpts.folder) {
|
|
26
|
-
args.push("--folderid", localOpts.folder);
|
|
40
|
+
args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
const json = await withSession(opts, () => runBwOrThrow(args));
|
|
30
|
-
const
|
|
44
|
+
const filtered = filterItems(JSON.parse(json), {
|
|
45
|
+
...localOpts,
|
|
46
|
+
type: positionalType ?? localOpts.type,
|
|
47
|
+
});
|
|
31
48
|
|
|
32
|
-
if (
|
|
49
|
+
if (filtered.total === 0) {
|
|
33
50
|
throw new CliError("No items found", ExitCode.NotFound);
|
|
34
51
|
}
|
|
35
52
|
|
|
36
|
-
writeItems(
|
|
53
|
+
writeItems(filtered, opts, localOpts);
|
|
37
54
|
});
|
|
38
55
|
}
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { constants } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { readFields } from "../../bw/fields.ts";
|
|
4
4
|
import { CliError, ExitCode } from "../errors.ts";
|
|
5
5
|
import { collect } from "../input.ts";
|
|
6
6
|
import { emitLog } from "../io.ts";
|
|
@@ -95,18 +95,23 @@ export function parseEnvSpec(raw: string): EnvSpec {
|
|
|
95
95
|
return { name, field: ref.slice(0, colon), item: ref.slice(colon + 1) };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Resolves every secret, paying one `bw` spawn per distinct *item* rather than
|
|
100
|
+
* per variable — pulling a username and password from the same item is a single
|
|
101
|
+
* vault read.
|
|
102
|
+
*/
|
|
99
103
|
async function resolveSecrets(
|
|
100
104
|
specs: EnvSpec[],
|
|
101
105
|
opts: GlobalOptions,
|
|
102
106
|
): Promise<Record<string, string>> {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
const values = await readFields(
|
|
108
|
+
specs.map((spec) => ({ field: spec.field, item: spec.item })),
|
|
109
|
+
opts,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return Object.fromEntries(
|
|
113
|
+
specs.map((spec, index) => [spec.name, values[index]!]),
|
|
114
|
+
);
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
/**
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { CliError, ExitCode } from "../errors.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
filterItems,
|
|
6
|
+
parseLimit,
|
|
7
|
+
writeItems,
|
|
8
|
+
type ItemListOptions,
|
|
9
|
+
} from "../items.ts";
|
|
5
10
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
11
|
import { withSession } from "../../bw/session.ts";
|
|
12
|
+
import { resolveFolderFilter } from "../../bw/folders.ts";
|
|
7
13
|
|
|
8
14
|
export function registerSearch(program: Command): void {
|
|
9
15
|
program
|
|
10
|
-
.command("search")
|
|
16
|
+
.command("search")
|
|
17
|
+
.alias("find")
|
|
11
18
|
.description("Search vault items")
|
|
12
19
|
.argument("<query>", "Search query")
|
|
13
20
|
.option("--type <type>", "Filter by type: login|note|card|identity")
|
|
14
|
-
.option("--folder <id>", "Filter by folder ID")
|
|
15
|
-
.option("--limit <n>", "Limit results",
|
|
21
|
+
.option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
|
|
22
|
+
.option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
|
|
16
23
|
.option("--full-items", "Emit complete item objects (includes secrets)")
|
|
17
24
|
.action(async function (
|
|
18
25
|
this: Command,
|
|
@@ -23,19 +30,16 @@ export function registerSearch(program: Command): void {
|
|
|
23
30
|
|
|
24
31
|
const args = ["list", "items", "--search", query];
|
|
25
32
|
if (localOpts.folder) {
|
|
26
|
-
args.push("--folderid", localOpts.folder);
|
|
33
|
+
args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
const json = await withSession(opts, () => runBwOrThrow(args));
|
|
30
|
-
const
|
|
37
|
+
const filtered = filterItems(JSON.parse(json), localOpts);
|
|
31
38
|
|
|
32
|
-
if (
|
|
33
|
-
throw new CliError(
|
|
34
|
-
`No items matching "${query}"`,
|
|
35
|
-
ExitCode.NotFound,
|
|
36
|
-
);
|
|
39
|
+
if (filtered.total === 0) {
|
|
40
|
+
throw new CliError(`No items matching "${query}"`, ExitCode.NotFound);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
writeItems(
|
|
43
|
+
writeItems(filtered, opts, localOpts);
|
|
40
44
|
});
|
|
41
45
|
}
|