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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
3
|
+
import type { GlobalOptions } from "../cli/types.ts";
|
|
4
|
+
import { runBwOrThrow } from "./runner.ts";
|
|
5
|
+
import { withSession } from "./session.ts";
|
|
6
|
+
|
|
7
|
+
export interface BwFolder {
|
|
8
|
+
/** `null` is bw's "No Folder" bucket, which every unfiled item belongs to. */
|
|
9
|
+
id: string | null;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const UUID_RE =
|
|
14
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
|
|
16
|
+
/** The spelling bw expects on `--folderid` for items that are in no folder. */
|
|
17
|
+
export const NO_FOLDER = "null";
|
|
18
|
+
|
|
19
|
+
export async function listFolders(opts: GlobalOptions): Promise<BwFolder[]> {
|
|
20
|
+
const json = await withSession(opts, () => runBwOrThrow(["list", "folders"]));
|
|
21
|
+
const raw = JSON.parse(json) as Array<Record<string, unknown>>;
|
|
22
|
+
return raw.map((folder) => ({
|
|
23
|
+
id: typeof folder.id === "string" ? folder.id : null,
|
|
24
|
+
name: String(folder.name ?? ""),
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Turns whatever the caller wrote in `--folder` into an id. IDs pass straight
|
|
30
|
+
* through, so this costs nothing for callers that already have one; names are
|
|
31
|
+
* resolved against the vault because a folder id is not something a human — or
|
|
32
|
+
* an agent reading `bwx list` output — can otherwise discover.
|
|
33
|
+
*
|
|
34
|
+
* Returns `null` for the "no folder" bucket (`--folder none`).
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveFolderRef(
|
|
37
|
+
ref: string,
|
|
38
|
+
opts: GlobalOptions,
|
|
39
|
+
): Promise<string | null> {
|
|
40
|
+
if (UUID_RE.test(ref)) return ref;
|
|
41
|
+
if (ref === NO_FOLDER || ref.toLowerCase() === "none") return null;
|
|
42
|
+
|
|
43
|
+
const folders = await listFolders(opts);
|
|
44
|
+
const named = folders.filter((folder) => folder.id !== null);
|
|
45
|
+
|
|
46
|
+
const exact = named.filter(
|
|
47
|
+
(folder) => folder.name.toLowerCase() === ref.toLowerCase(),
|
|
48
|
+
);
|
|
49
|
+
const matches =
|
|
50
|
+
exact.length > 0
|
|
51
|
+
? exact
|
|
52
|
+
: named.filter((folder) =>
|
|
53
|
+
folder.name.toLowerCase().includes(ref.toLowerCase()),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (matches.length === 0) {
|
|
57
|
+
throw new CliError(
|
|
58
|
+
`No folder matching "${ref}". Run 'bwx folders' to list them.`,
|
|
59
|
+
ExitCode.NotFound,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (matches.length > 1) {
|
|
64
|
+
throw new CliError(
|
|
65
|
+
formatAmbiguousFolders(ref, matches, opts),
|
|
66
|
+
ExitCode.BadArgs,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return matches[0]!.id;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolves `--folder` for read commands, where "no folder" is a real filter. */
|
|
74
|
+
export async function resolveFolderFilter(
|
|
75
|
+
ref: string,
|
|
76
|
+
opts: GlobalOptions,
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
return (await resolveFolderRef(ref, opts)) ?? NO_FOLDER;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatAmbiguousFolders(
|
|
82
|
+
ref: string,
|
|
83
|
+
matches: BwFolder[],
|
|
84
|
+
opts: GlobalOptions,
|
|
85
|
+
): string {
|
|
86
|
+
if (opts.json) {
|
|
87
|
+
return `Multiple folders match "${ref}": ${JSON.stringify(matches)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lines = [`Multiple folders match "${ref}":\n`];
|
|
91
|
+
for (const folder of matches) {
|
|
92
|
+
lines.push(` ${pc.dim(folder.id ?? "-")} ${folder.name}`);
|
|
93
|
+
}
|
|
94
|
+
lines.push(`\nUse the full name or its ID.`);
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
2
|
+
import { runBwOrThrow } from "./runner.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `bw generate` is local — it needs no session and no unlocked vault, so a
|
|
6
|
+
* generated secret costs one fast spawn and never touches the network.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface GenerateOptions {
|
|
10
|
+
length?: number;
|
|
11
|
+
passphrase?: boolean;
|
|
12
|
+
words?: number;
|
|
13
|
+
separator?: string;
|
|
14
|
+
capitalize?: boolean;
|
|
15
|
+
includeNumber?: boolean;
|
|
16
|
+
uppercase?: boolean;
|
|
17
|
+
lowercase?: boolean;
|
|
18
|
+
numbers?: boolean;
|
|
19
|
+
special?: boolean;
|
|
20
|
+
ambiguous?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** bw's own defaults, restated here so `--no-numbers` can turn one off without
|
|
24
|
+
* silently disabling the rest: bw needs every wanted character set named. */
|
|
25
|
+
const CHARSET_DEFAULTS = {
|
|
26
|
+
uppercase: true,
|
|
27
|
+
lowercase: true,
|
|
28
|
+
numbers: true,
|
|
29
|
+
special: false,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export function buildGenerateArgs(options: GenerateOptions): string[] {
|
|
33
|
+
const args = ["generate"];
|
|
34
|
+
|
|
35
|
+
if (options.passphrase) {
|
|
36
|
+
args.push("--passphrase");
|
|
37
|
+
if (options.words !== undefined) args.push("--words", String(options.words));
|
|
38
|
+
if (options.separator !== undefined) {
|
|
39
|
+
args.push("--separator", options.separator);
|
|
40
|
+
}
|
|
41
|
+
if (options.capitalize) args.push("--capitalize");
|
|
42
|
+
if (options.includeNumber) args.push("--includeNumber");
|
|
43
|
+
return args;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const charsets = {
|
|
47
|
+
uppercase: options.uppercase ?? CHARSET_DEFAULTS.uppercase,
|
|
48
|
+
lowercase: options.lowercase ?? CHARSET_DEFAULTS.lowercase,
|
|
49
|
+
numbers: options.numbers ?? CHARSET_DEFAULTS.numbers,
|
|
50
|
+
special: options.special ?? CHARSET_DEFAULTS.special,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (!Object.values(charsets).some(Boolean)) {
|
|
54
|
+
throw new CliError(
|
|
55
|
+
"At least one character set must stay enabled.",
|
|
56
|
+
ExitCode.BadArgs,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (options.length !== undefined) {
|
|
61
|
+
if (!Number.isInteger(options.length) || options.length < 5) {
|
|
62
|
+
throw new CliError(
|
|
63
|
+
`Invalid --length "${options.length}". Use an integer of at least 5.`,
|
|
64
|
+
ExitCode.BadArgs,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
args.push("--length", String(options.length));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (charsets.uppercase) args.push("--uppercase");
|
|
71
|
+
if (charsets.lowercase) args.push("--lowercase");
|
|
72
|
+
if (charsets.numbers) args.push("--number");
|
|
73
|
+
if (charsets.special) args.push("--special");
|
|
74
|
+
if (options.ambiguous) args.push("--ambiguous");
|
|
75
|
+
|
|
76
|
+
return args;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function generateSecret(
|
|
80
|
+
options: GenerateOptions,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
const value = await runBwOrThrow(buildGenerateArgs(options), {
|
|
83
|
+
session: null,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!value) {
|
|
87
|
+
throw new CliError("bw generate returned nothing", ExitCode.BwError);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return value;
|
|
91
|
+
}
|
package/src/bw/session.ts
CHANGED
|
@@ -60,21 +60,47 @@ async function getMasterPassword(): Promise<string> {
|
|
|
60
60
|
return pw;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Whether reads will work right now without an unlock. `bw status` answers for
|
|
65
|
+
* the vault, not for us: it reports "locked" whenever the vault has no session
|
|
66
|
+
* of its own, even when bwx holds a cached one that works perfectly. Reporting
|
|
67
|
+
* that raw answer sends callers off to unlock a vault they can already read.
|
|
68
|
+
*/
|
|
69
|
+
export type SessionState =
|
|
70
|
+
/** Cached session works — reads succeed with no unlock. */
|
|
71
|
+
| "valid"
|
|
72
|
+
/** A session was cached but the vault no longer accepts it. */
|
|
73
|
+
| "stale"
|
|
74
|
+
/** Nothing cached; the next read unlocks. */
|
|
75
|
+
| "none";
|
|
76
|
+
|
|
77
|
+
/** Confirms a token by asking the vault, which is the only authority on it. */
|
|
78
|
+
async function sessionWorks(token: string): Promise<boolean> {
|
|
79
|
+
const check = await runBw(["status"], { session: token });
|
|
80
|
+
if (check.exitCode !== 0) return false;
|
|
81
|
+
try {
|
|
82
|
+
return (
|
|
83
|
+
BwStatusSchema.parse(JSON.parse(check.stdout)).status === "unlocked"
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function probeSessionState(
|
|
91
|
+
opts: GlobalOptions,
|
|
92
|
+
): Promise<SessionState> {
|
|
93
|
+
const cached = getSession() || loadCachedSession(opts);
|
|
94
|
+
if (!cached) return "none";
|
|
95
|
+
return (await sessionWorks(cached)) ? "valid" : "stale";
|
|
96
|
+
}
|
|
97
|
+
|
|
63
98
|
export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
|
|
64
99
|
// Try cached session first
|
|
65
100
|
const cached = getSession() || loadCachedSession(opts);
|
|
66
101
|
if (cached) {
|
|
67
102
|
setSession(cached);
|
|
68
|
-
|
|
69
|
-
const check = await runBw(["status"]);
|
|
70
|
-
if (check.exitCode === 0) {
|
|
71
|
-
try {
|
|
72
|
-
const status = BwStatusSchema.parse(JSON.parse(check.stdout));
|
|
73
|
-
if (status.status === "unlocked") return;
|
|
74
|
-
} catch {
|
|
75
|
-
// Fall through
|
|
76
|
-
}
|
|
77
|
-
}
|
|
103
|
+
if (await sessionWorks(cached)) return;
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
const status = await getStatus();
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
loadConfig,
|
|
8
8
|
saveConfig,
|
|
9
9
|
} from "../../config/store.ts";
|
|
10
|
+
import { readLine } from "../input.ts";
|
|
10
11
|
|
|
11
12
|
export function registerConfig(program: Command): void {
|
|
12
13
|
const configCmd = program
|
|
@@ -161,13 +162,3 @@ function redactSensitive(value: unknown): unknown {
|
|
|
161
162
|
function isSensitiveKey(key: string): boolean {
|
|
162
163
|
return SENSITIVE_KEY_RE.some((pattern) => pattern.test(key));
|
|
163
164
|
}
|
|
164
|
-
|
|
165
|
-
async function readLine(): Promise<string> {
|
|
166
|
-
const chunks: Uint8Array[] = [];
|
|
167
|
-
for await (const chunk of Bun.stdin.stream()) {
|
|
168
|
-
chunks.push(chunk);
|
|
169
|
-
const text = Buffer.concat(chunks).toString();
|
|
170
|
-
if (text.includes("\n")) return text.split("\n")[0]!.trim();
|
|
171
|
-
}
|
|
172
|
-
return Buffer.concat(chunks).toString().trim();
|
|
173
|
-
}
|
|
@@ -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
|
+
}
|