bwx 0.2.0 → 0.2.2
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 +1 -0
- package/package.json +1 -1
- package/src/cli/commands/config.ts +72 -2
- package/src/cli/commands/get.ts +121 -26
- package/src/cli/program.ts +1 -3
- package/src/config/store.ts +4 -0
- package/src/index.ts +22 -0
- package/src/cli/commands/field.ts +0 -36
package/README.md
CHANGED
|
@@ -70,6 +70,7 @@ bwx doctor
|
|
|
70
70
|
|---------|-------------|
|
|
71
71
|
| `bwx config email [addr]` | Set account email |
|
|
72
72
|
| `bwx config master-password` | Store master password in Keychain |
|
|
73
|
+
| `bwx config list` / `bwx cfg ls` | Show config path + redacted content |
|
|
73
74
|
|
|
74
75
|
### Global flags
|
|
75
76
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
|
-
import { emitSuccess } from "../io.ts";
|
|
3
|
+
import { emitData, emitSuccess } from "../io.ts";
|
|
4
4
|
import { CliError, ExitCode } from "../errors.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getConfigPath,
|
|
7
|
+
loadConfig,
|
|
8
|
+
saveConfig,
|
|
9
|
+
} from "../../config/store.ts";
|
|
6
10
|
|
|
7
11
|
export function registerConfig(program: Command): void {
|
|
8
12
|
const configCmd = program
|
|
@@ -90,6 +94,72 @@ export function registerConfig(program: Command): void {
|
|
|
90
94
|
|
|
91
95
|
emitSuccess("Master password saved to Keychain", opts);
|
|
92
96
|
});
|
|
97
|
+
|
|
98
|
+
configCmd
|
|
99
|
+
.command("list").alias("ls")
|
|
100
|
+
.description("Show config file path and redacted content")
|
|
101
|
+
.action(async function (this: Command) {
|
|
102
|
+
const opts = getGlobalOpts(this);
|
|
103
|
+
const path = getConfigPath();
|
|
104
|
+
const file = Bun.file(path);
|
|
105
|
+
const exists = await file.exists();
|
|
106
|
+
let config: unknown = null;
|
|
107
|
+
|
|
108
|
+
if (exists) {
|
|
109
|
+
try {
|
|
110
|
+
config = JSON.parse(await file.text());
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new CliError(
|
|
113
|
+
`Config file is not valid JSON: ${
|
|
114
|
+
err instanceof Error ? err.message : String(err)
|
|
115
|
+
}`,
|
|
116
|
+
ExitCode.Config,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
emitData(
|
|
122
|
+
{
|
|
123
|
+
path,
|
|
124
|
+
exists,
|
|
125
|
+
config: redactSensitive(config),
|
|
126
|
+
},
|
|
127
|
+
opts,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SENSITIVE_KEY_RE = [
|
|
133
|
+
/pass(word)?/i,
|
|
134
|
+
/secret/i,
|
|
135
|
+
/token/i,
|
|
136
|
+
/api[-_]?key/i,
|
|
137
|
+
/private/i,
|
|
138
|
+
/credential/i,
|
|
139
|
+
/session/i,
|
|
140
|
+
/auth/i,
|
|
141
|
+
/totp/i,
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
function redactSensitive(value: unknown): unknown {
|
|
145
|
+
if (!value || typeof value !== "object") {
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
return value.map((item) => redactSensitive(item));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const out: Record<string, unknown> = {};
|
|
154
|
+
for (const [key, current] of Object.entries(value)) {
|
|
155
|
+
out[key] = isSensitiveKey(key) ? "***REDACTED***" : redactSensitive(current);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isSensitiveKey(key: string): boolean {
|
|
162
|
+
return SENSITIVE_KEY_RE.some((pattern) => pattern.test(key));
|
|
93
163
|
}
|
|
94
164
|
|
|
95
165
|
async function readLine(): Promise<string> {
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -1,47 +1,142 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { emitData } from "../io.ts";
|
|
4
|
-
import { handleFatalError } from "../io.ts";
|
|
5
4
|
import { CliError, ExitCode } from "../errors.ts";
|
|
6
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
7
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
|
+
import { BW_TYPE_LABELS, BwItemSchema } from "../../bw/types.ts";
|
|
8
|
+
import pc from "picocolors";
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
+
const BUILTIN_FIELDS = ["password", "username", "totp", "notes", "uri", "item"];
|
|
10
11
|
|
|
11
12
|
export function registerGet(program: Command): void {
|
|
12
13
|
program
|
|
13
14
|
.command("get")
|
|
14
|
-
.description("Get a field from a vault item")
|
|
15
|
-
.argument("<field>",
|
|
15
|
+
.description("Get a field from a vault item (built-in or custom)")
|
|
16
|
+
.argument("<field>", "Field: password | username | totp | notes | uri | item | <custom>")
|
|
16
17
|
.argument("<item>", "Item name or ID")
|
|
17
18
|
.action(async function (this: Command, field: string, item: string) {
|
|
18
19
|
const opts = getGlobalOpts(this);
|
|
19
20
|
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const result = await withSession(opts, async () => {
|
|
28
|
-
if (field === "item") {
|
|
29
|
-
return runBwOrThrow(["get", "item", item]);
|
|
30
|
-
}
|
|
31
|
-
return runBwOrThrow(["get", field, item]);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
if (!result) {
|
|
35
|
-
throw new CliError(
|
|
36
|
-
`No ${field} found for item: ${item}`,
|
|
37
|
-
ExitCode.NotFound,
|
|
38
|
-
);
|
|
21
|
+
if (BUILTIN_FIELDS.includes(field)) {
|
|
22
|
+
await getBuiltinField(field, item, opts);
|
|
23
|
+
} else {
|
|
24
|
+
await getCustomField(field, item, opts);
|
|
39
25
|
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
40
28
|
|
|
29
|
+
async function getBuiltinField(
|
|
30
|
+
field: string,
|
|
31
|
+
item: string,
|
|
32
|
+
opts: ReturnType<typeof getGlobalOpts>,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
const result = await withSession(opts, async () => {
|
|
41
36
|
if (field === "item") {
|
|
42
|
-
|
|
43
|
-
} else {
|
|
44
|
-
emitData(result, opts);
|
|
37
|
+
return runBwOrThrow(["get", "item", item]);
|
|
45
38
|
}
|
|
39
|
+
return runBwOrThrow(["get", field, item]);
|
|
46
40
|
});
|
|
41
|
+
|
|
42
|
+
if (!result) {
|
|
43
|
+
throw new CliError(
|
|
44
|
+
`No ${field} found for item: ${item}`,
|
|
45
|
+
ExitCode.NotFound,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (field === "item") {
|
|
50
|
+
emitData(JSON.parse(result), opts);
|
|
51
|
+
} else {
|
|
52
|
+
emitData(result, opts);
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (
|
|
56
|
+
err instanceof CliError &&
|
|
57
|
+
/more than one result/i.test(err.message)
|
|
58
|
+
) {
|
|
59
|
+
const matches = await resolveAmbiguous(item);
|
|
60
|
+
throw new CliError(
|
|
61
|
+
formatAmbiguousError(item, matches, opts),
|
|
62
|
+
ExitCode.BadArgs,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getCustomField(
|
|
70
|
+
fieldName: string,
|
|
71
|
+
item: string,
|
|
72
|
+
opts: ReturnType<typeof getGlobalOpts>,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const json = await withSession(opts, () =>
|
|
75
|
+
runBwOrThrow(["get", "item", item]),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const parsed = BwItemSchema.parse(JSON.parse(json));
|
|
79
|
+
const fields = parsed.fields ?? [];
|
|
80
|
+
const match = fields.find((f) => f.name === fieldName);
|
|
81
|
+
|
|
82
|
+
if (!match) {
|
|
83
|
+
const available = fields.map((f) => f.name).join(", ");
|
|
84
|
+
const msg = available
|
|
85
|
+
? `Field "${fieldName}" not found. Available: ${available}`
|
|
86
|
+
: `Field "${fieldName}" not found (item has no custom fields)`;
|
|
87
|
+
throw new CliError(msg, ExitCode.NotFound);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
emitData(match.value ?? "", opts);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface MatchInfo {
|
|
94
|
+
id: string;
|
|
95
|
+
name: string;
|
|
96
|
+
type: string;
|
|
97
|
+
username?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function resolveAmbiguous(query: string): Promise<MatchInfo[]> {
|
|
101
|
+
try {
|
|
102
|
+
const json = await runBwOrThrow([
|
|
103
|
+
"list",
|
|
104
|
+
"items",
|
|
105
|
+
"--search",
|
|
106
|
+
query,
|
|
107
|
+
]);
|
|
108
|
+
const items: Array<Record<string, unknown>> = JSON.parse(json);
|
|
109
|
+
return items.map((i) => ({
|
|
110
|
+
id: i.id as string,
|
|
111
|
+
name: i.name as string,
|
|
112
|
+
type: BW_TYPE_LABELS[i.type as number] ?? `type:${i.type}`,
|
|
113
|
+
username: (i.login as Record<string, unknown> | null)?.username as
|
|
114
|
+
| string
|
|
115
|
+
| undefined,
|
|
116
|
+
}));
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatAmbiguousError(
|
|
123
|
+
query: string,
|
|
124
|
+
matches: MatchInfo[],
|
|
125
|
+
opts: { json?: boolean },
|
|
126
|
+
): string {
|
|
127
|
+
if (matches.length === 0) {
|
|
128
|
+
return `Multiple items match "${query}". Use a specific ID instead.`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (opts.json) {
|
|
132
|
+
return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const lines = [`Multiple items match "${query}":\n`];
|
|
136
|
+
for (const m of matches) {
|
|
137
|
+
const user = m.username ? ` (${m.username})` : "";
|
|
138
|
+
lines.push(` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`);
|
|
139
|
+
}
|
|
140
|
+
lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
|
|
141
|
+
return lines.join("\n");
|
|
47
142
|
}
|
package/src/cli/program.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import type { GlobalOptions } from "./types.ts";
|
|
3
3
|
import { registerGet } from "./commands/get.ts";
|
|
4
|
-
import { registerField } from "./commands/field.ts";
|
|
5
4
|
import { registerSearch } from "./commands/search.ts";
|
|
6
5
|
import { registerCreate } from "./commands/create.ts";
|
|
7
6
|
import { registerEdit } from "./commands/edit.ts";
|
|
@@ -20,7 +19,7 @@ export function buildProgram(): Command {
|
|
|
20
19
|
program
|
|
21
20
|
.name("bwx")
|
|
22
21
|
.description("Bitwarden Extended CLI")
|
|
23
|
-
.version("0.2.
|
|
22
|
+
.version("0.2.2")
|
|
24
23
|
.option("--json", "JSON output")
|
|
25
24
|
.option("--plain", "Plain parseable output")
|
|
26
25
|
.option("-q, --quiet", "Suppress log output")
|
|
@@ -35,7 +34,6 @@ export function buildProgram(): Command {
|
|
|
35
34
|
registerUnlock(program);
|
|
36
35
|
registerLock(program);
|
|
37
36
|
registerGet(program);
|
|
38
|
-
registerField(program);
|
|
39
37
|
registerSearch(program);
|
|
40
38
|
registerSync(program);
|
|
41
39
|
registerCreate(program);
|
package/src/config/store.ts
CHANGED
|
@@ -4,6 +4,10 @@ const CONFIG_DIR =
|
|
|
4
4
|
process.env.XDG_CONFIG_HOME ?? `${process.env.HOME}/.config`;
|
|
5
5
|
const CONFIG_PATH = `${CONFIG_DIR}/bwx/config.json`;
|
|
6
6
|
|
|
7
|
+
export function getConfigPath(): string {
|
|
8
|
+
return CONFIG_PATH;
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
export async function loadConfig(): Promise<Config> {
|
|
8
12
|
try {
|
|
9
13
|
const text = await Bun.file(CONFIG_PATH).text();
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,12 @@ import type { GlobalOptions } from "./cli/types.ts";
|
|
|
7
7
|
export async function main(argv: string[]): Promise<void> {
|
|
8
8
|
const program = buildProgram();
|
|
9
9
|
|
|
10
|
+
// Bare invocation should show top-level help.
|
|
11
|
+
if (argv.length <= 2) {
|
|
12
|
+
program.outputHelp();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
// Parse global opts early for error formatting
|
|
11
17
|
let opts: GlobalOptions = {};
|
|
12
18
|
try {
|
|
@@ -26,6 +32,22 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
26
32
|
await program.parseAsync(argv);
|
|
27
33
|
} catch (err) {
|
|
28
34
|
if (err instanceof CommanderError) {
|
|
35
|
+
if (err.code === "commander.help") {
|
|
36
|
+
const helpArgv = [...argv, "--help"];
|
|
37
|
+
try {
|
|
38
|
+
await program.parseAsync(helpArgv);
|
|
39
|
+
} catch (helpErr) {
|
|
40
|
+
if (
|
|
41
|
+
!(
|
|
42
|
+
helpErr instanceof CommanderError &&
|
|
43
|
+
helpErr.code === "commander.helpDisplayed"
|
|
44
|
+
)
|
|
45
|
+
) {
|
|
46
|
+
throw helpErr;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
process.exit(err.exitCode);
|
|
50
|
+
}
|
|
29
51
|
if (err.exitCode === 0) process.exit(0); // --help, --version
|
|
30
52
|
// Commander prefixes messages with "error: " — strip it
|
|
31
53
|
const msg = err.message.replace(/^error:\s*/i, "");
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { Command } from "commander";
|
|
2
|
-
import { getGlobalOpts } from "../program.ts";
|
|
3
|
-
import { emitData } from "../io.ts";
|
|
4
|
-
import { CliError, ExitCode } from "../errors.ts";
|
|
5
|
-
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
|
-
import { withSession } from "../../bw/session.ts";
|
|
7
|
-
import { BwItemSchema } from "../../bw/types.ts";
|
|
8
|
-
|
|
9
|
-
export function registerField(program: Command): void {
|
|
10
|
-
program
|
|
11
|
-
.command("field")
|
|
12
|
-
.description("Get a custom field from a vault item")
|
|
13
|
-
.argument("<item>", "Item name or ID")
|
|
14
|
-
.argument("<field-name>", "Custom field name")
|
|
15
|
-
.action(async function (this: Command, item: string, fieldName: string) {
|
|
16
|
-
const opts = getGlobalOpts(this);
|
|
17
|
-
|
|
18
|
-
const json = await withSession(opts, () =>
|
|
19
|
-
runBwOrThrow(["get", "item", item]),
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
const parsed = BwItemSchema.parse(JSON.parse(json));
|
|
23
|
-
const fields = parsed.fields ?? [];
|
|
24
|
-
const match = fields.find((f) => f.name === fieldName);
|
|
25
|
-
|
|
26
|
-
if (!match) {
|
|
27
|
-
const available = fields.map((f) => f.name).join(", ");
|
|
28
|
-
const msg = available
|
|
29
|
-
? `Field "${fieldName}" not found. Available: ${available}`
|
|
30
|
-
: `Field "${fieldName}" not found (item has no custom fields)`;
|
|
31
|
-
throw new CliError(msg, ExitCode.NotFound);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
emitData(match.value ?? "", opts);
|
|
35
|
-
});
|
|
36
|
-
}
|