bwx 0.1.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 +101 -0
- package/bin/bwx +3 -0
- package/package.json +28 -0
- package/src/bw/encoding.ts +152 -0
- package/src/bw/runner.ts +60 -0
- package/src/bw/session.ts +187 -0
- package/src/bw/types.ts +112 -0
- package/src/cli/commands/config.ts +103 -0
- package/src/cli/commands/create.ts +133 -0
- package/src/cli/commands/delete.ts +67 -0
- package/src/cli/commands/edit.ts +93 -0
- package/src/cli/commands/field.ts +36 -0
- package/src/cli/commands/get.ts +47 -0
- package/src/cli/commands/lock.ts +15 -0
- package/src/cli/commands/search.ts +85 -0
- package/src/cli/commands/status.ts +36 -0
- package/src/cli/commands/sync.ts +16 -0
- package/src/cli/commands/unlock.ts +15 -0
- package/src/cli/errors.ts +40 -0
- package/src/cli/io.ts +72 -0
- package/src/cli/program.ts +55 -0
- package/src/cli/types.ts +10 -0
- package/src/config/schema.ts +7 -0
- package/src/config/store.ts +21 -0
- package/src/index.ts +36 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitSuccess } from "../io.ts";
|
|
4
|
+
import { CliError, ExitCode } from "../errors.ts";
|
|
5
|
+
import { loadConfig, saveConfig } from "../../config/store.ts";
|
|
6
|
+
|
|
7
|
+
export function registerConfig(program: Command): void {
|
|
8
|
+
const configCmd = program
|
|
9
|
+
.command("config")
|
|
10
|
+
.description("Manage bwx configuration");
|
|
11
|
+
|
|
12
|
+
configCmd
|
|
13
|
+
.command("email")
|
|
14
|
+
.description("Set Bitwarden account email")
|
|
15
|
+
.argument("[email]", "Account email address")
|
|
16
|
+
.action(async function (this: Command, email?: string) {
|
|
17
|
+
const opts = getGlobalOpts(this);
|
|
18
|
+
|
|
19
|
+
if (!email) {
|
|
20
|
+
if (!process.stdin.isTTY) {
|
|
21
|
+
throw new CliError(
|
|
22
|
+
"Pass email as argument when piping",
|
|
23
|
+
ExitCode.BadArgs,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
process.stderr.write("Email: ");
|
|
27
|
+
email = await readLine();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!email) {
|
|
31
|
+
throw new CliError("Email is required", ExitCode.BadArgs);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = await loadConfig();
|
|
35
|
+
config.email = email;
|
|
36
|
+
await saveConfig(config);
|
|
37
|
+
|
|
38
|
+
emitSuccess("Email saved", opts);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
configCmd
|
|
42
|
+
.command("master-password")
|
|
43
|
+
.description("Set master password in macOS Keychain")
|
|
44
|
+
.action(async function (this: Command) {
|
|
45
|
+
const opts = getGlobalOpts(this);
|
|
46
|
+
|
|
47
|
+
let password: string;
|
|
48
|
+
|
|
49
|
+
if (process.stdin.isTTY) {
|
|
50
|
+
// Interactive prompt
|
|
51
|
+
process.stderr.write("Master password: ");
|
|
52
|
+
password = await readLine();
|
|
53
|
+
} else {
|
|
54
|
+
// Piped input
|
|
55
|
+
const chunks: Uint8Array[] = [];
|
|
56
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
57
|
+
chunks.push(chunk);
|
|
58
|
+
}
|
|
59
|
+
password = Buffer.concat(chunks).toString().trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!password) {
|
|
63
|
+
throw new CliError("No password provided", ExitCode.BadArgs);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const proc = Bun.spawn(
|
|
67
|
+
[
|
|
68
|
+
"security",
|
|
69
|
+
"add-generic-password",
|
|
70
|
+
"-a",
|
|
71
|
+
"bitwarden",
|
|
72
|
+
"-s",
|
|
73
|
+
"bitwarden-master",
|
|
74
|
+
"-U",
|
|
75
|
+
"-w",
|
|
76
|
+
password,
|
|
77
|
+
],
|
|
78
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const stderr = await new Response(proc.stderr).text();
|
|
82
|
+
const exitCode = await proc.exited;
|
|
83
|
+
|
|
84
|
+
if (exitCode !== 0) {
|
|
85
|
+
throw new CliError(
|
|
86
|
+
`Failed to save to Keychain: ${stderr.trim()}`,
|
|
87
|
+
ExitCode.Config,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
emitSuccess("Master password saved to Keychain", opts);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readLine(): Promise<string> {
|
|
96
|
+
const chunks: Uint8Array[] = [];
|
|
97
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
const text = Buffer.concat(chunks).toString();
|
|
100
|
+
if (text.includes("\n")) return text.split("\n")[0]!.trim();
|
|
101
|
+
}
|
|
102
|
+
return Buffer.concat(chunks).toString().trim();
|
|
103
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitData, emitSuccess } 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 { buildNewItem, parseFieldFlag, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
|
|
9
|
+
function collect(val: string, prev: string[]): string[] {
|
|
10
|
+
prev.push(val);
|
|
11
|
+
return prev;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerCreate(program: Command): void {
|
|
15
|
+
program
|
|
16
|
+
.command("create")
|
|
17
|
+
.description("Create a new vault item")
|
|
18
|
+
.option("--type <type>", "Item type: login|note", "note")
|
|
19
|
+
.option("--name <name>", "Item name")
|
|
20
|
+
.option("--notes <text>", "Notes content")
|
|
21
|
+
.option("--username <user>", "Username (login type)")
|
|
22
|
+
.option("--password <pass>", "Password (login type)")
|
|
23
|
+
.option("--uri <url>", "URI (repeatable)", collect, [])
|
|
24
|
+
.option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
|
|
25
|
+
.option("--folder <id>", "Folder ID")
|
|
26
|
+
.option("--favorite", "Mark as favorite")
|
|
27
|
+
.option("--from-json", "Read full item JSON from stdin")
|
|
28
|
+
.action(async function (
|
|
29
|
+
this: Command,
|
|
30
|
+
localOpts: {
|
|
31
|
+
type: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
notes?: string;
|
|
34
|
+
username?: string;
|
|
35
|
+
password?: string;
|
|
36
|
+
uri: string[];
|
|
37
|
+
field: string[];
|
|
38
|
+
folder?: string;
|
|
39
|
+
favorite?: boolean;
|
|
40
|
+
fromJson?: boolean;
|
|
41
|
+
},
|
|
42
|
+
) {
|
|
43
|
+
const opts = getGlobalOpts(this);
|
|
44
|
+
|
|
45
|
+
let encoded: string;
|
|
46
|
+
|
|
47
|
+
if (localOpts.fromJson) {
|
|
48
|
+
const stdin = await readStdin();
|
|
49
|
+
if (!stdin) {
|
|
50
|
+
throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
|
|
51
|
+
}
|
|
52
|
+
// Validate it's JSON
|
|
53
|
+
try {
|
|
54
|
+
JSON.parse(stdin);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new CliError("Invalid JSON on stdin", ExitCode.BadArgs);
|
|
57
|
+
}
|
|
58
|
+
encoded = bwEncode(stdin);
|
|
59
|
+
} else {
|
|
60
|
+
if (!localOpts.name) {
|
|
61
|
+
throw new CliError("--name is required", ExitCode.BadArgs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (localOpts.type !== "login" && localOpts.type !== "note") {
|
|
65
|
+
throw new CliError(
|
|
66
|
+
`Invalid type "${localOpts.type}". Valid: login, note`,
|
|
67
|
+
ExitCode.BadArgs,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Read notes from stdin if not provided and not a TTY
|
|
72
|
+
let notes = localOpts.notes ?? null;
|
|
73
|
+
if (notes === null && !process.stdin.isTTY) {
|
|
74
|
+
const stdinText = await readStdin();
|
|
75
|
+
if (stdinText) notes = stdinText;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Read password from stdin if not provided, login type, and not a TTY
|
|
79
|
+
let password = localOpts.password ?? null;
|
|
80
|
+
if (
|
|
81
|
+
password === null &&
|
|
82
|
+
localOpts.type === "login" &&
|
|
83
|
+
!process.stdin.isTTY &&
|
|
84
|
+
notes === null
|
|
85
|
+
) {
|
|
86
|
+
const stdinText = await readStdin();
|
|
87
|
+
if (stdinText) password = stdinText;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fields = localOpts.field.map(parseFieldFlag);
|
|
91
|
+
|
|
92
|
+
const item = buildNewItem({
|
|
93
|
+
type: localOpts.type,
|
|
94
|
+
name: localOpts.name,
|
|
95
|
+
notes,
|
|
96
|
+
username: localOpts.username ?? null,
|
|
97
|
+
password,
|
|
98
|
+
uris: localOpts.uri,
|
|
99
|
+
fields,
|
|
100
|
+
folderId: localOpts.folder ?? null,
|
|
101
|
+
favorite: localOpts.favorite ?? false,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
encoded = bwEncode(JSON.stringify(item));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = await withSession(opts, () =>
|
|
108
|
+
runBwOrThrow(["create", "item", encoded]),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const created = JSON.parse(result);
|
|
112
|
+
if (opts.json) {
|
|
113
|
+
emitData(created, opts);
|
|
114
|
+
} else if (opts.plain) {
|
|
115
|
+
emitData(created.id, opts);
|
|
116
|
+
} else {
|
|
117
|
+
emitSuccess(`Created "${created.name}" (${created.id})`, opts);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readStdin(): Promise<string | null> {
|
|
123
|
+
try {
|
|
124
|
+
const chunks: Uint8Array[] = [];
|
|
125
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
126
|
+
chunks.push(chunk);
|
|
127
|
+
}
|
|
128
|
+
const text = Buffer.concat(chunks).toString().trim();
|
|
129
|
+
return text || null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitData, emitSuccess } 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 pc from "picocolors";
|
|
8
|
+
|
|
9
|
+
export function registerDelete(program: Command): void {
|
|
10
|
+
program
|
|
11
|
+
.command("delete")
|
|
12
|
+
.description("Delete a vault item")
|
|
13
|
+
.argument("<item>", "Item name or ID")
|
|
14
|
+
.option("--permanent", "Permanently delete (no trash)")
|
|
15
|
+
.option("--force", "Skip confirmation prompt")
|
|
16
|
+
.action(async function (
|
|
17
|
+
this: Command,
|
|
18
|
+
item: string,
|
|
19
|
+
localOpts: { permanent?: boolean; force?: boolean },
|
|
20
|
+
) {
|
|
21
|
+
const opts = getGlobalOpts(this);
|
|
22
|
+
|
|
23
|
+
// Resolve item to get ID and name
|
|
24
|
+
const json = await withSession(opts, () =>
|
|
25
|
+
runBwOrThrow(["get", "item", item]),
|
|
26
|
+
);
|
|
27
|
+
const current = JSON.parse(json);
|
|
28
|
+
const itemId: string = current.id;
|
|
29
|
+
const itemName: string = current.name;
|
|
30
|
+
|
|
31
|
+
// Confirm unless --force, --json, or --plain
|
|
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
|
+
}
|
|
42
|
+
|
|
43
|
+
const args = ["delete", "item", itemId];
|
|
44
|
+
if (localOpts.permanent) args.push("--permanent");
|
|
45
|
+
|
|
46
|
+
await withSession(opts, () => runBwOrThrow(args));
|
|
47
|
+
|
|
48
|
+
if (opts.json) {
|
|
49
|
+
emitData({ id: itemId, name: itemName, deleted: true }, opts);
|
|
50
|
+
} else if (opts.plain) {
|
|
51
|
+
emitData(itemId, opts);
|
|
52
|
+
} else {
|
|
53
|
+
const label = localOpts.permanent ? "Permanently deleted" : "Deleted";
|
|
54
|
+
emitSuccess(`${label} "${itemName}" (${itemId})`, opts);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readLine(): Promise<string> {
|
|
60
|
+
const chunks: Uint8Array[] = [];
|
|
61
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
62
|
+
chunks.push(chunk);
|
|
63
|
+
const text = Buffer.concat(chunks).toString();
|
|
64
|
+
if (text.includes("\n")) return text.split("\n")[0]!.trim();
|
|
65
|
+
}
|
|
66
|
+
return Buffer.concat(chunks).toString().trim();
|
|
67
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitData, emitSuccess } 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 { patchItem, parseFieldFlag, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
|
|
9
|
+
function collect(val: string, prev: string[]): string[] {
|
|
10
|
+
prev.push(val);
|
|
11
|
+
return prev;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerEdit(program: Command): void {
|
|
15
|
+
program
|
|
16
|
+
.command("edit")
|
|
17
|
+
.description("Edit an existing vault item")
|
|
18
|
+
.argument("<item>", "Item name or ID")
|
|
19
|
+
.option("--name <name>", "New name")
|
|
20
|
+
.option("--notes <text>", "New notes")
|
|
21
|
+
.option("--username <user>", "New username")
|
|
22
|
+
.option("--password <pass>", "New password")
|
|
23
|
+
.option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
|
|
24
|
+
.option("--add-field <kv>", "Add/update field k=v", collect, [])
|
|
25
|
+
.option("--rm-field <name>", "Remove field by name", collect, [])
|
|
26
|
+
.option("--folder <id>", "Move to folder")
|
|
27
|
+
.option("--favorite", "Set favorite")
|
|
28
|
+
.option("--no-favorite", "Unset favorite")
|
|
29
|
+
.option("--from-json", "Read full item JSON from stdin (replaces item)")
|
|
30
|
+
.action(async function (
|
|
31
|
+
this: Command,
|
|
32
|
+
item: string,
|
|
33
|
+
localOpts: {
|
|
34
|
+
name?: string;
|
|
35
|
+
notes?: string;
|
|
36
|
+
username?: string;
|
|
37
|
+
password?: string;
|
|
38
|
+
uri: string[];
|
|
39
|
+
addField: string[];
|
|
40
|
+
rmField: string[];
|
|
41
|
+
folder?: string;
|
|
42
|
+
favorite?: boolean;
|
|
43
|
+
fromJson?: boolean;
|
|
44
|
+
},
|
|
45
|
+
) {
|
|
46
|
+
const opts = getGlobalOpts(this);
|
|
47
|
+
|
|
48
|
+
const result = await withSession(opts, async () => {
|
|
49
|
+
// Fetch current item
|
|
50
|
+
const json = await runBwOrThrow(["get", "item", item]);
|
|
51
|
+
const current = JSON.parse(json);
|
|
52
|
+
const itemId = current.id;
|
|
53
|
+
|
|
54
|
+
let encoded: string;
|
|
55
|
+
|
|
56
|
+
if (localOpts.fromJson) {
|
|
57
|
+
const chunks: Uint8Array[] = [];
|
|
58
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
}
|
|
61
|
+
const stdinText = Buffer.concat(chunks).toString().trim();
|
|
62
|
+
if (!stdinText) {
|
|
63
|
+
throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
|
|
64
|
+
}
|
|
65
|
+
encoded = bwEncode(stdinText);
|
|
66
|
+
} else {
|
|
67
|
+
const patched = patchItem(current, {
|
|
68
|
+
name: localOpts.name,
|
|
69
|
+
notes: localOpts.notes,
|
|
70
|
+
username: localOpts.username,
|
|
71
|
+
password: localOpts.password,
|
|
72
|
+
uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
|
|
73
|
+
addFields: localOpts.addField.map(parseFieldFlag),
|
|
74
|
+
rmFields: localOpts.rmField,
|
|
75
|
+
folderId: localOpts.folder,
|
|
76
|
+
favorite: localOpts.favorite,
|
|
77
|
+
});
|
|
78
|
+
encoded = bwEncode(JSON.stringify(patched));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return runBwOrThrow(["edit", "item", itemId, encoded]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const updated = JSON.parse(result);
|
|
85
|
+
if (opts.json) {
|
|
86
|
+
emitData(updated, opts);
|
|
87
|
+
} else if (opts.plain) {
|
|
88
|
+
emitData(updated.id, opts);
|
|
89
|
+
} else {
|
|
90
|
+
emitSuccess(`Updated "${updated.name}" (${updated.id})`, opts);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitData } from "../io.ts";
|
|
4
|
+
import { handleFatalError } from "../io.ts";
|
|
5
|
+
import { CliError, ExitCode } from "../errors.ts";
|
|
6
|
+
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
7
|
+
import { withSession } from "../../bw/session.ts";
|
|
8
|
+
|
|
9
|
+
const VALID_FIELDS = ["password", "username", "totp", "notes", "uri", "item"];
|
|
10
|
+
|
|
11
|
+
export function registerGet(program: Command): void {
|
|
12
|
+
program
|
|
13
|
+
.command("get")
|
|
14
|
+
.description("Get a field from a vault item")
|
|
15
|
+
.argument("<field>", `Field: ${VALID_FIELDS.join(" | ")}`)
|
|
16
|
+
.argument("<item>", "Item name or ID")
|
|
17
|
+
.action(async function (this: Command, field: string, item: string) {
|
|
18
|
+
const opts = getGlobalOpts(this);
|
|
19
|
+
|
|
20
|
+
if (!VALID_FIELDS.includes(field)) {
|
|
21
|
+
throw new CliError(
|
|
22
|
+
`Invalid field "${field}". Valid: ${VALID_FIELDS.join(", ")}`,
|
|
23
|
+
ExitCode.BadArgs,
|
|
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
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (field === "item") {
|
|
42
|
+
emitData(JSON.parse(result), opts);
|
|
43
|
+
} else {
|
|
44
|
+
emitData(result, opts);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitSuccess } from "../io.ts";
|
|
4
|
+
import { lockVault } from "../../bw/session.ts";
|
|
5
|
+
|
|
6
|
+
export function registerLock(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command("lock")
|
|
9
|
+
.description("Lock vault and clear cached session")
|
|
10
|
+
.action(async function (this: Command) {
|
|
11
|
+
const opts = getGlobalOpts(this);
|
|
12
|
+
await lockVault();
|
|
13
|
+
emitSuccess("Vault locked", opts);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { BW_TYPE_LABELS, BW_TYPE_FROM_NAME } from "../../bw/types.ts";
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
|
|
10
|
+
export function registerSearch(program: Command): void {
|
|
11
|
+
program
|
|
12
|
+
.command("search")
|
|
13
|
+
.description("Search vault items")
|
|
14
|
+
.argument("<query>", "Search query")
|
|
15
|
+
.option("--type <type>", "Filter by type: login|note|card|identity")
|
|
16
|
+
.option("--folder <id>", "Filter by folder ID")
|
|
17
|
+
.option("--limit <n>", "Limit results", parseInt)
|
|
18
|
+
.action(async function (
|
|
19
|
+
this: Command,
|
|
20
|
+
query: string,
|
|
21
|
+
localOpts: { type?: string; folder?: string; limit?: number },
|
|
22
|
+
) {
|
|
23
|
+
const opts = getGlobalOpts(this);
|
|
24
|
+
|
|
25
|
+
const args = ["list", "items", "--search", query];
|
|
26
|
+
if (localOpts.folder) {
|
|
27
|
+
args.push("--folderid", localOpts.folder);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const json = await withSession(opts, () => runBwOrThrow(args));
|
|
31
|
+
let items: Array<Record<string, unknown>> = JSON.parse(json);
|
|
32
|
+
|
|
33
|
+
// Filter by type
|
|
34
|
+
if (localOpts.type) {
|
|
35
|
+
const typeVal = BW_TYPE_FROM_NAME[localOpts.type];
|
|
36
|
+
if (typeVal === undefined) {
|
|
37
|
+
throw new CliError(
|
|
38
|
+
`Invalid type "${localOpts.type}". Valid: login, note, card, identity`,
|
|
39
|
+
ExitCode.BadArgs,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
items = items.filter((i) => i.type === typeVal);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Limit
|
|
46
|
+
if (localOpts.limit && localOpts.limit > 0) {
|
|
47
|
+
items = items.slice(0, localOpts.limit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (items.length === 0) {
|
|
51
|
+
throw new CliError(
|
|
52
|
+
`No items matching "${query}"`,
|
|
53
|
+
ExitCode.NotFound,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (opts.json) {
|
|
58
|
+
emitData(items, opts);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (opts.plain) {
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const type = BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`;
|
|
65
|
+
const login = item.login as Record<string, unknown> | null;
|
|
66
|
+
const username = login?.username ?? "-";
|
|
67
|
+
process.stdout.write(
|
|
68
|
+
`${item.id}\t${type}\t${item.name}\t${username}\n`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Human-friendly table
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
const type = BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`;
|
|
77
|
+
const login = item.login as Record<string, unknown> | null;
|
|
78
|
+
const username = login?.username ?? "";
|
|
79
|
+
const userPart = username ? pc.dim(` (${username})`) : "";
|
|
80
|
+
process.stdout.write(
|
|
81
|
+
`${pc.dim(item.id as string)} ${pc.cyan(type)} ${item.name}${userPart}\n`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitData } from "../io.ts";
|
|
4
|
+
import { getStatus } from "../../bw/session.ts";
|
|
5
|
+
import { BW_TYPE_LABELS } from "../../bw/types.ts";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
|
|
8
|
+
export function registerStatus(program: Command): void {
|
|
9
|
+
program
|
|
10
|
+
.command("status")
|
|
11
|
+
.description("Show vault status (no auto-unlock)")
|
|
12
|
+
.action(async function (this: Command) {
|
|
13
|
+
const opts = getGlobalOpts(this);
|
|
14
|
+
const status = await getStatus();
|
|
15
|
+
|
|
16
|
+
if (opts.json || opts.plain) {
|
|
17
|
+
emitData(status, opts);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const statusColor =
|
|
22
|
+
status.status === "unlocked"
|
|
23
|
+
? pc.green
|
|
24
|
+
: status.status === "locked"
|
|
25
|
+
? pc.yellow
|
|
26
|
+
: pc.red;
|
|
27
|
+
|
|
28
|
+
const lines = [
|
|
29
|
+
`${pc.dim("Status:")} ${statusColor(status.status)}`,
|
|
30
|
+
`${pc.dim("Email:")} ${status.userEmail ?? "-"}`,
|
|
31
|
+
`${pc.dim("Server:")} ${status.serverUrl ?? "-"}`,
|
|
32
|
+
`${pc.dim("Synced:")} ${status.lastSync ?? "never"}`,
|
|
33
|
+
];
|
|
34
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitSuccess } from "../io.ts";
|
|
4
|
+
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
5
|
+
import { withSession } from "../../bw/session.ts";
|
|
6
|
+
|
|
7
|
+
export function registerSync(program: Command): void {
|
|
8
|
+
program
|
|
9
|
+
.command("sync")
|
|
10
|
+
.description("Sync vault (auto-unlocks)")
|
|
11
|
+
.action(async function (this: Command) {
|
|
12
|
+
const opts = getGlobalOpts(this);
|
|
13
|
+
await withSession(opts, () => runBwOrThrow(["sync"]));
|
|
14
|
+
emitSuccess("Vault synced", opts);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getGlobalOpts } from "../program.ts";
|
|
3
|
+
import { emitSuccess } from "../io.ts";
|
|
4
|
+
import { ensureUnlocked } from "../../bw/session.ts";
|
|
5
|
+
|
|
6
|
+
export function registerUnlock(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command("unlock")
|
|
9
|
+
.description("Unlock vault (caches session)")
|
|
10
|
+
.action(async function (this: Command) {
|
|
11
|
+
const opts = getGlobalOpts(this);
|
|
12
|
+
await ensureUnlocked(opts);
|
|
13
|
+
emitSuccess("Vault unlocked", opts);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const ExitCode = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
Unknown: 1,
|
|
4
|
+
BadArgs: 2,
|
|
5
|
+
AuthFailed: 3,
|
|
6
|
+
NotFound: 4,
|
|
7
|
+
BwError: 5,
|
|
8
|
+
Network: 6,
|
|
9
|
+
Config: 7,
|
|
10
|
+
Timeout: 8,
|
|
11
|
+
UserCancelled: 9,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
|
|
15
|
+
|
|
16
|
+
export class CliError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
public readonly exitCode: ExitCode = ExitCode.Unknown,
|
|
20
|
+
) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "CliError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const BW_STDERR_PATTERNS: Array<[RegExp, ExitCode]> = [
|
|
27
|
+
[/not logged in|unauthenticated/i, ExitCode.AuthFailed],
|
|
28
|
+
[/vault is locked/i, ExitCode.AuthFailed],
|
|
29
|
+
[/not found/i, ExitCode.NotFound],
|
|
30
|
+
[/more than one result/i, ExitCode.BadArgs],
|
|
31
|
+
[/timed? ?out/i, ExitCode.Timeout],
|
|
32
|
+
[/network|ECONNREFUSED|ENOTFOUND/i, ExitCode.Network],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function classifyBwError(stderr: string): ExitCode {
|
|
36
|
+
for (const [pattern, code] of BW_STDERR_PATTERNS) {
|
|
37
|
+
if (pattern.test(stderr)) return code;
|
|
38
|
+
}
|
|
39
|
+
return ExitCode.BwError;
|
|
40
|
+
}
|