bwx 0.2.1 → 0.3.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 +46 -4
- package/package.json +1 -1
- package/src/bw/attachments.ts +58 -0
- package/src/cli/commands/attach.ts +112 -0
- package/src/cli/commands/config.ts +72 -2
- package/src/cli/commands/create.ts +59 -50
- package/src/cli/commands/edit.ts +52 -24
- package/src/cli/commands/get.ts +10 -0
- package/src/cli/input.ts +248 -0
- package/src/cli/program.ts +3 -1
- package/src/config/store.ts +4 -0
- package/src/index.ts +32 -13
package/README.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# bwx
|
|
2
2
|
|
|
3
|
-
Bitwarden CLI wrapper for AI agents. Auto-unlock, session caching, structured output.
|
|
3
|
+
Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, session caching, structured output.
|
|
4
|
+
|
|
5
|
+
> Currently macOS-only: `bwx` uses macOS Keychain for secure master password storage.
|
|
6
|
+
|
|
7
|
+
## Platform support
|
|
8
|
+
|
|
9
|
+
- ✅ macOS
|
|
10
|
+
- ❌ Linux (not supported yet)
|
|
11
|
+
- ❌ Windows (not supported yet)
|
|
12
|
+
|
|
13
|
+
`bwx` relies on macOS Keychain for secure master-password storage and retrieval.
|
|
4
14
|
|
|
5
15
|
## Why
|
|
6
16
|
|
|
@@ -55,13 +65,15 @@ bwx doctor
|
|
|
55
65
|
| `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
|
|
56
66
|
| `bwx field <item> <name>` | Get custom field by name |
|
|
57
67
|
| `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`) |
|
|
68
|
+
| `bwx attach list <item>` | List attachments for an item |
|
|
69
|
+
| `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
|
|
58
70
|
|
|
59
71
|
### Write
|
|
60
72
|
|
|
61
73
|
| Command | Description |
|
|
62
74
|
|---------|-------------|
|
|
63
|
-
| `bwx create` | Create item (`--type`, `--name`, `--username`, `--password`, `--
|
|
64
|
-
| `bwx edit <item>` | Edit item (`--name`, `--password`, `--add-field k=
|
|
75
|
+
| `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--field-file k=path`) |
|
|
76
|
+
| `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--add-field-file k=path`, `--rm-field name`) |
|
|
65
77
|
| `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
|
|
66
78
|
|
|
67
79
|
### Config
|
|
@@ -70,6 +82,36 @@ bwx doctor
|
|
|
70
82
|
|---------|-------------|
|
|
71
83
|
| `bwx config email [addr]` | Set account email |
|
|
72
84
|
| `bwx config master-password` | Store master password in Keychain |
|
|
85
|
+
| `bwx config list` / `bwx cfg ls` | Show config path + redacted content |
|
|
86
|
+
|
|
87
|
+
### Attachments
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
bwx attach list "Apple Developer"
|
|
91
|
+
bwx attach get "Apple Developer" "AuthKey.p8"
|
|
92
|
+
bwx attach get "Apple Developer" "AuthKey.p8" --output ./secrets/
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Aliases: `attach`, `attachment`, `attachments`; `list` also has `ls`.
|
|
96
|
+
|
|
97
|
+
### Safe input
|
|
98
|
+
|
|
99
|
+
Prefer stdin, files, or environment variables for secrets so values do not land in shell history.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
printf '%s' "$TOKEN" | bwx create --type login --name "GitHub" --username user --password-stdin
|
|
103
|
+
bwx edit "GitHub" --password-env GITHUB_TOKEN
|
|
104
|
+
bwx create --name "Deploy key" --notes-file ./deploy-key.txt
|
|
105
|
+
bwx create --name "Acme" --field-file '!API Key=./token.txt' --field-env account=ACME_ACCOUNT
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Supported source flags:
|
|
109
|
+
|
|
110
|
+
- Passwords: `--password-stdin`, `--password-file <path>`, `--password-env <name>`
|
|
111
|
+
- Notes: `--notes-file <path>`
|
|
112
|
+
- Create fields: `--field-file k=path`, `--field-env k=ENV`
|
|
113
|
+
- Edit fields: `--add-field-file k=path`, `--add-field-env k=ENV`
|
|
114
|
+
- Use `-` as the file path to read that source from stdin.
|
|
73
115
|
|
|
74
116
|
### Global flags
|
|
75
117
|
|
|
@@ -82,7 +124,7 @@ bwx doctor
|
|
|
82
124
|
|
|
83
125
|
## Custom fields
|
|
84
126
|
|
|
85
|
-
Field syntax for `--field
|
|
127
|
+
Field syntax for `--field`, `--field-file`, `--field-env`, and edit variants:
|
|
86
128
|
|
|
87
129
|
- `key=value` — text field
|
|
88
130
|
- `!key=value` — hidden field
|
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
2
|
+
|
|
3
|
+
export interface AttachmentInfo {
|
|
4
|
+
id: string;
|
|
5
|
+
fileName: string;
|
|
6
|
+
size?: number;
|
|
7
|
+
sizeName?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function listAttachments(item: Record<string, unknown>): AttachmentInfo[] {
|
|
11
|
+
const raw = item.attachments;
|
|
12
|
+
if (!Array.isArray(raw)) return [];
|
|
13
|
+
|
|
14
|
+
return raw
|
|
15
|
+
.map((attachment) => normalizeAttachment(attachment))
|
|
16
|
+
.filter((attachment): attachment is AttachmentInfo => attachment !== null);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveAttachment(
|
|
20
|
+
attachments: AttachmentInfo[],
|
|
21
|
+
query: string,
|
|
22
|
+
): AttachmentInfo {
|
|
23
|
+
const matches = attachments.filter(
|
|
24
|
+
(attachment) => attachment.id === query || attachment.fileName === query,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (matches.length === 1) return matches[0]!;
|
|
28
|
+
|
|
29
|
+
if (matches.length > 1) {
|
|
30
|
+
throw new CliError(
|
|
31
|
+
`Multiple attachments match "${query}". Use an attachment ID instead.`,
|
|
32
|
+
ExitCode.BadArgs,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const available = attachments.map((attachment) => attachment.fileName).join(", ");
|
|
37
|
+
const suffix = available ? ` Available: ${available}` : "";
|
|
38
|
+
throw new CliError(`Attachment "${query}" not found.${suffix}`, ExitCode.NotFound);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeAttachment(value: unknown): AttachmentInfo | null {
|
|
42
|
+
if (!value || typeof value !== "object") return null;
|
|
43
|
+
|
|
44
|
+
const record = value as Record<string, unknown>;
|
|
45
|
+
if (typeof record.id !== "string" || typeof record.fileName !== "string") {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const attachment: AttachmentInfo = {
|
|
50
|
+
id: record.id,
|
|
51
|
+
fileName: record.fileName,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (typeof record.size === "number") attachment.size = record.size;
|
|
55
|
+
if (typeof record.sizeName === "string") attachment.sizeName = record.sizeName;
|
|
56
|
+
|
|
57
|
+
return attachment;
|
|
58
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { listAttachments, resolveAttachment } from "../../bw/attachments.ts";
|
|
4
|
+
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
5
|
+
import { withSession } from "../../bw/session.ts";
|
|
6
|
+
import { emitData, emitSuccess } from "../io.ts";
|
|
7
|
+
import { getGlobalOpts } from "../program.ts";
|
|
8
|
+
|
|
9
|
+
export function registerAttach(program: Command): void {
|
|
10
|
+
const attach = program
|
|
11
|
+
.command("attach")
|
|
12
|
+
.aliases(["attachment", "attachments"])
|
|
13
|
+
.description("List and download item attachments");
|
|
14
|
+
|
|
15
|
+
attach
|
|
16
|
+
.command("list")
|
|
17
|
+
.alias("ls")
|
|
18
|
+
.description("List attachments for an item")
|
|
19
|
+
.argument("<item>", "Item name or ID")
|
|
20
|
+
.action(async function (this: Command, item: string) {
|
|
21
|
+
const opts = getGlobalOpts(this);
|
|
22
|
+
const current = await getItem(item, opts);
|
|
23
|
+
const attachments = listAttachments(current);
|
|
24
|
+
|
|
25
|
+
if (opts.json) {
|
|
26
|
+
emitData(attachments, opts);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (opts.plain) {
|
|
31
|
+
for (const attachment of attachments) {
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
[
|
|
34
|
+
attachment.id,
|
|
35
|
+
attachment.fileName,
|
|
36
|
+
attachment.sizeName ?? attachment.size ?? "-",
|
|
37
|
+
].join("\t") + "\n",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (attachments.length === 0) {
|
|
44
|
+
const itemName = typeof current.name === "string" ? current.name : item;
|
|
45
|
+
process.stdout.write(`No attachments on "${itemName}"\n`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const attachment of attachments) {
|
|
50
|
+
const size = attachment.sizeName ?? attachment.size;
|
|
51
|
+
const sizePart = size === undefined ? "" : pc.dim(` (${size})`);
|
|
52
|
+
process.stdout.write(
|
|
53
|
+
`${pc.dim(attachment.id)} ${attachment.fileName}${sizePart}\n`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
attach
|
|
59
|
+
.command("get")
|
|
60
|
+
.description("Download an attachment from an item")
|
|
61
|
+
.argument("<item>", "Item name or ID")
|
|
62
|
+
.argument("<attachment>", "Attachment ID or filename")
|
|
63
|
+
.option("-o, --output <path>", "Output file or directory")
|
|
64
|
+
.action(async function (
|
|
65
|
+
this: Command,
|
|
66
|
+
item: string,
|
|
67
|
+
attachmentQuery: string,
|
|
68
|
+
localOpts: { output?: string },
|
|
69
|
+
) {
|
|
70
|
+
const opts = getGlobalOpts(this);
|
|
71
|
+
const current = await getItem(item, opts);
|
|
72
|
+
const attachments = listAttachments(current);
|
|
73
|
+
const attachment = resolveAttachment(attachments, attachmentQuery);
|
|
74
|
+
const itemId = String(current.id);
|
|
75
|
+
const output = localOpts.output ?? attachment.fileName;
|
|
76
|
+
|
|
77
|
+
await withSession(opts, () =>
|
|
78
|
+
runBwOrThrow([
|
|
79
|
+
"get",
|
|
80
|
+
"attachment",
|
|
81
|
+
attachment.id,
|
|
82
|
+
"--itemid",
|
|
83
|
+
itemId,
|
|
84
|
+
"--output",
|
|
85
|
+
output,
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const result = {
|
|
90
|
+
itemId,
|
|
91
|
+
attachmentId: attachment.id,
|
|
92
|
+
fileName: attachment.fileName,
|
|
93
|
+
output,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
emitData(result, opts);
|
|
98
|
+
} else if (opts.plain) {
|
|
99
|
+
emitData(output, opts);
|
|
100
|
+
} else {
|
|
101
|
+
emitSuccess(`Saved "${attachment.fileName}" to ${output}`, opts);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function getItem(
|
|
107
|
+
item: string,
|
|
108
|
+
opts: ReturnType<typeof getGlobalOpts>,
|
|
109
|
+
): Promise<Record<string, unknown>> {
|
|
110
|
+
const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
|
|
111
|
+
return JSON.parse(json) as Record<string, unknown>;
|
|
112
|
+
}
|
|
@@ -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> {
|
|
@@ -4,11 +4,29 @@ 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 { buildNewItem,
|
|
7
|
+
import { buildNewItem, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
import {
|
|
9
|
+
assertSingleStdinSource,
|
|
10
|
+
collect,
|
|
11
|
+
listExplicitStdinInputs,
|
|
12
|
+
readOptionalStdin,
|
|
13
|
+
resolveFieldSources,
|
|
14
|
+
resolveItemTextInputs,
|
|
15
|
+
StdinReader,
|
|
16
|
+
type ItemTextInputs,
|
|
17
|
+
} from "../input.ts";
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
interface CreateOptions extends ItemTextInputs {
|
|
20
|
+
type: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
username?: string;
|
|
23
|
+
uri: string[];
|
|
24
|
+
field: string[];
|
|
25
|
+
fieldFile: string[];
|
|
26
|
+
fieldEnv: string[];
|
|
27
|
+
folder?: string;
|
|
28
|
+
favorite?: boolean;
|
|
29
|
+
fromJson?: boolean;
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
export function registerCreate(program: Command): void {
|
|
@@ -18,44 +36,40 @@ export function registerCreate(program: Command): void {
|
|
|
18
36
|
.option("--type <type>", "Item type: login|note", "note")
|
|
19
37
|
.option("--name <name>", "Item name")
|
|
20
38
|
.option("--notes <text>", "Notes content")
|
|
39
|
+
.option("--notes-file <path>", "Read notes from file (use - for stdin)")
|
|
21
40
|
.option("--username <user>", "Username (login type)")
|
|
22
41
|
.option("--password <pass>", "Password (login type)")
|
|
42
|
+
.option("--password-stdin", "Read password from stdin")
|
|
43
|
+
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
44
|
+
.option("--password-env <name>", "Read password from environment variable")
|
|
23
45
|
.option("--uri <url>", "URI (repeatable)", collect, [])
|
|
24
46
|
.option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
|
|
47
|
+
.option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
|
|
48
|
+
.option("--field-env <kv>", "Custom field value from env var k=ENV (repeatable)", collect, [])
|
|
25
49
|
.option("--folder <id>", "Folder ID")
|
|
26
50
|
.option("--favorite", "Mark as favorite")
|
|
27
51
|
.option("--from-json", "Read full item JSON from stdin")
|
|
28
52
|
.action(async function (
|
|
29
53
|
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
|
-
},
|
|
54
|
+
localOpts: CreateOptions,
|
|
42
55
|
) {
|
|
43
56
|
const opts = getGlobalOpts(this);
|
|
57
|
+
const stdin = new StdinReader();
|
|
44
58
|
|
|
45
59
|
let encoded: string;
|
|
46
60
|
|
|
47
61
|
if (localOpts.fromJson) {
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
62
|
+
const stdinText = await stdin.read();
|
|
63
|
+
if (!stdinText) {
|
|
50
64
|
throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
|
|
51
65
|
}
|
|
52
66
|
// Validate it's JSON
|
|
53
67
|
try {
|
|
54
|
-
JSON.parse(
|
|
68
|
+
JSON.parse(stdinText);
|
|
55
69
|
} catch {
|
|
56
70
|
throw new CliError("Invalid JSON on stdin", ExitCode.BadArgs);
|
|
57
71
|
}
|
|
58
|
-
encoded = bwEncode(
|
|
72
|
+
encoded = bwEncode(stdinText);
|
|
59
73
|
} else {
|
|
60
74
|
if (!localOpts.name) {
|
|
61
75
|
throw new CliError("--name is required", ExitCode.BadArgs);
|
|
@@ -68,33 +82,41 @@ export function registerCreate(program: Command): void {
|
|
|
68
82
|
);
|
|
69
83
|
}
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
const explicitStdinSources = listExplicitStdinInputs({
|
|
86
|
+
...localOpts,
|
|
87
|
+
fieldFileFlag: "--field-file",
|
|
88
|
+
fieldFiles: localOpts.fieldFile,
|
|
89
|
+
});
|
|
90
|
+
assertSingleStdinSource(explicitStdinSources);
|
|
91
|
+
|
|
92
|
+
const textInputs = await resolveItemTextInputs(localOpts, stdin);
|
|
93
|
+
let notes = textInputs.notes;
|
|
77
94
|
|
|
78
|
-
//
|
|
79
|
-
let password = localOpts.password ?? null;
|
|
95
|
+
// Backward compatibility: piped create input becomes note content.
|
|
80
96
|
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
notes === undefined &&
|
|
98
|
+
textInputs.password === undefined &&
|
|
99
|
+
explicitStdinSources.length === 0 &&
|
|
100
|
+
!process.stdin.isTTY
|
|
85
101
|
) {
|
|
86
|
-
|
|
87
|
-
if (stdinText) password = stdinText;
|
|
102
|
+
notes = (await readOptionalStdin(stdin)) ?? undefined;
|
|
88
103
|
}
|
|
89
104
|
|
|
90
|
-
const fields =
|
|
105
|
+
const fields = await resolveFieldSources({
|
|
106
|
+
inline: localOpts.field,
|
|
107
|
+
file: localOpts.fieldFile,
|
|
108
|
+
env: localOpts.fieldEnv,
|
|
109
|
+
fileFlag: "--field-file",
|
|
110
|
+
envFlag: "--field-env",
|
|
111
|
+
stdin,
|
|
112
|
+
});
|
|
91
113
|
|
|
92
114
|
const item = buildNewItem({
|
|
93
115
|
type: localOpts.type,
|
|
94
116
|
name: localOpts.name,
|
|
95
|
-
notes,
|
|
117
|
+
notes: notes ?? null,
|
|
96
118
|
username: localOpts.username ?? null,
|
|
97
|
-
password,
|
|
119
|
+
password: textInputs.password ?? null,
|
|
98
120
|
uris: localOpts.uri,
|
|
99
121
|
fields,
|
|
100
122
|
folderId: localOpts.folder ?? null,
|
|
@@ -118,16 +140,3 @@ export function registerCreate(program: Command): void {
|
|
|
118
140
|
}
|
|
119
141
|
});
|
|
120
142
|
}
|
|
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
|
-
}
|
package/src/cli/commands/edit.ts
CHANGED
|
@@ -4,11 +4,28 @@ 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 { patchItem,
|
|
7
|
+
import { patchItem, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
import {
|
|
9
|
+
assertSingleStdinSource,
|
|
10
|
+
collect,
|
|
11
|
+
listExplicitStdinInputs,
|
|
12
|
+
resolveFieldSources,
|
|
13
|
+
resolveItemTextInputs,
|
|
14
|
+
StdinReader,
|
|
15
|
+
type ItemTextInputs,
|
|
16
|
+
} from "../input.ts";
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
interface EditOptions extends ItemTextInputs {
|
|
19
|
+
name?: string;
|
|
20
|
+
username?: string;
|
|
21
|
+
uri: string[];
|
|
22
|
+
addField: string[];
|
|
23
|
+
addFieldFile: string[];
|
|
24
|
+
addFieldEnv: string[];
|
|
25
|
+
rmField: string[];
|
|
26
|
+
folder?: string;
|
|
27
|
+
favorite?: boolean;
|
|
28
|
+
fromJson?: boolean;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
export function registerEdit(program: Command): void {
|
|
@@ -18,10 +35,16 @@ export function registerEdit(program: Command): void {
|
|
|
18
35
|
.argument("<item>", "Item name or ID")
|
|
19
36
|
.option("--name <name>", "New name")
|
|
20
37
|
.option("--notes <text>", "New notes")
|
|
38
|
+
.option("--notes-file <path>", "Read notes from file (use - for stdin)")
|
|
21
39
|
.option("--username <user>", "New username")
|
|
22
40
|
.option("--password <pass>", "New password")
|
|
41
|
+
.option("--password-stdin", "Read password from stdin")
|
|
42
|
+
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
43
|
+
.option("--password-env <name>", "Read password from environment variable")
|
|
23
44
|
.option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
|
|
24
45
|
.option("--add-field <kv>", "Add/update field k=v", collect, [])
|
|
46
|
+
.option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
|
|
47
|
+
.option("--add-field-env <kv>", "Add/update field value from env var k=ENV", collect, [])
|
|
25
48
|
.option("--rm-field <name>", "Remove field by name", collect, [])
|
|
26
49
|
.option("--folder <id>", "Move to folder")
|
|
27
50
|
.option("--favorite", "Set favorite")
|
|
@@ -30,20 +53,10 @@ export function registerEdit(program: Command): void {
|
|
|
30
53
|
.action(async function (
|
|
31
54
|
this: Command,
|
|
32
55
|
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
|
-
},
|
|
56
|
+
localOpts: EditOptions,
|
|
45
57
|
) {
|
|
46
58
|
const opts = getGlobalOpts(this);
|
|
59
|
+
const stdin = new StdinReader();
|
|
47
60
|
|
|
48
61
|
const result = await withSession(opts, async () => {
|
|
49
62
|
// Fetch current item
|
|
@@ -54,23 +67,38 @@ export function registerEdit(program: Command): void {
|
|
|
54
67
|
let encoded: string;
|
|
55
68
|
|
|
56
69
|
if (localOpts.fromJson) {
|
|
57
|
-
const
|
|
58
|
-
for await (const chunk of Bun.stdin.stream()) {
|
|
59
|
-
chunks.push(chunk);
|
|
60
|
-
}
|
|
61
|
-
const stdinText = Buffer.concat(chunks).toString().trim();
|
|
70
|
+
const stdinText = await stdin.read();
|
|
62
71
|
if (!stdinText) {
|
|
63
72
|
throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
|
|
64
73
|
}
|
|
65
74
|
encoded = bwEncode(stdinText);
|
|
66
75
|
} else {
|
|
76
|
+
assertSingleStdinSource(
|
|
77
|
+
listExplicitStdinInputs({
|
|
78
|
+
...localOpts,
|
|
79
|
+
fieldFileFlag: "--add-field-file",
|
|
80
|
+
fieldFiles: localOpts.addFieldFile,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const textInputs = await resolveItemTextInputs(localOpts, stdin);
|
|
85
|
+
|
|
86
|
+
const addFields = await resolveFieldSources({
|
|
87
|
+
inline: localOpts.addField,
|
|
88
|
+
file: localOpts.addFieldFile,
|
|
89
|
+
env: localOpts.addFieldEnv,
|
|
90
|
+
fileFlag: "--add-field-file",
|
|
91
|
+
envFlag: "--add-field-env",
|
|
92
|
+
stdin,
|
|
93
|
+
});
|
|
94
|
+
|
|
67
95
|
const patched = patchItem(current, {
|
|
68
96
|
name: localOpts.name,
|
|
69
|
-
notes:
|
|
97
|
+
notes: textInputs.notes,
|
|
70
98
|
username: localOpts.username,
|
|
71
|
-
password:
|
|
99
|
+
password: textInputs.password,
|
|
72
100
|
uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
|
|
73
|
-
addFields
|
|
101
|
+
addFields,
|
|
74
102
|
rmFields: localOpts.rmField,
|
|
75
103
|
folderId: localOpts.folder,
|
|
76
104
|
favorite: localOpts.favorite,
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -24,6 +24,16 @@ export function registerGet(program: Command): void {
|
|
|
24
24
|
await getCustomField(field, item, opts);
|
|
25
25
|
}
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command("field")
|
|
30
|
+
.description("Get a custom field from a vault item")
|
|
31
|
+
.argument("<item>", "Item name or ID")
|
|
32
|
+
.argument("<name>", "Custom field name")
|
|
33
|
+
.action(async function (this: Command, item: string, name: string) {
|
|
34
|
+
const opts = getGlobalOpts(this);
|
|
35
|
+
await getCustomField(name, item, opts);
|
|
36
|
+
});
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
async function getBuiltinField(
|
package/src/cli/input.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
|
|
2
|
+
import { CliError, ExitCode } from "./errors.ts";
|
|
3
|
+
|
|
4
|
+
export function collect(val: string, prev: string[]): string[] {
|
|
5
|
+
prev.push(val);
|
|
6
|
+
return prev;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lazily reads stdin once. Commands validate that only one explicit stdin source
|
|
11
|
+
* is selected, but caching also keeps implicit legacy stdin reads predictable.
|
|
12
|
+
*/
|
|
13
|
+
export class StdinReader {
|
|
14
|
+
private textPromise: Promise<string> | null = null;
|
|
15
|
+
|
|
16
|
+
read(): Promise<string> {
|
|
17
|
+
this.textPromise ??= readStdinText();
|
|
18
|
+
return this.textPromise;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TextSource {
|
|
23
|
+
label: string;
|
|
24
|
+
inline?: string;
|
|
25
|
+
stdin?: boolean;
|
|
26
|
+
file?: string;
|
|
27
|
+
env?: string;
|
|
28
|
+
flagNames: string[];
|
|
29
|
+
allowEmpty?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FieldSources {
|
|
33
|
+
inline: string[];
|
|
34
|
+
file: string[];
|
|
35
|
+
env: string[];
|
|
36
|
+
fileFlag: string;
|
|
37
|
+
envFlag: string;
|
|
38
|
+
stdin: StdinReader;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ItemTextInputs {
|
|
42
|
+
notes?: string;
|
|
43
|
+
notesFile?: string;
|
|
44
|
+
password?: string;
|
|
45
|
+
passwordStdin?: boolean;
|
|
46
|
+
passwordFile?: string;
|
|
47
|
+
passwordEnv?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ResolvedItemTextInputs {
|
|
51
|
+
notes?: string;
|
|
52
|
+
password?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ExplicitStdinInputs extends ItemTextInputs {
|
|
56
|
+
fieldFileFlag?: string;
|
|
57
|
+
fieldFiles?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NOTE_SOURCE_FLAGS = ["--notes", "--notes-file"];
|
|
61
|
+
const PASSWORD_SOURCE_FLAGS = [
|
|
62
|
+
"--password",
|
|
63
|
+
"--password-stdin",
|
|
64
|
+
"--password-file",
|
|
65
|
+
"--password-env",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export async function resolveItemTextInputs(
|
|
69
|
+
inputs: ItemTextInputs,
|
|
70
|
+
stdin: StdinReader,
|
|
71
|
+
): Promise<ResolvedItemTextInputs> {
|
|
72
|
+
assertSingleStdinSource(listExplicitStdinInputs(inputs));
|
|
73
|
+
|
|
74
|
+
const [notes, password] = await Promise.all([
|
|
75
|
+
resolveOptionalTextSource(
|
|
76
|
+
{
|
|
77
|
+
label: "notes",
|
|
78
|
+
inline: inputs.notes,
|
|
79
|
+
file: inputs.notesFile,
|
|
80
|
+
flagNames: NOTE_SOURCE_FLAGS,
|
|
81
|
+
allowEmpty: true,
|
|
82
|
+
},
|
|
83
|
+
stdin,
|
|
84
|
+
),
|
|
85
|
+
resolveOptionalTextSource(
|
|
86
|
+
{
|
|
87
|
+
label: "password",
|
|
88
|
+
inline: inputs.password,
|
|
89
|
+
stdin: inputs.passwordStdin,
|
|
90
|
+
file: inputs.passwordFile,
|
|
91
|
+
env: inputs.passwordEnv,
|
|
92
|
+
flagNames: PASSWORD_SOURCE_FLAGS,
|
|
93
|
+
},
|
|
94
|
+
stdin,
|
|
95
|
+
),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
return { notes, password };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
|
|
102
|
+
return [
|
|
103
|
+
inputs.notesFile === "-" ? "--notes-file -" : null,
|
|
104
|
+
inputs.passwordStdin ? "--password-stdin" : null,
|
|
105
|
+
inputs.passwordFile === "-" ? "--password-file -" : null,
|
|
106
|
+
...(inputs.fieldFileFlag && inputs.fieldFiles
|
|
107
|
+
? stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles)
|
|
108
|
+
: []),
|
|
109
|
+
].filter((value): value is string => value !== null);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function resolveOptionalTextSource(
|
|
113
|
+
source: TextSource,
|
|
114
|
+
stdin: StdinReader,
|
|
115
|
+
): Promise<string | undefined> {
|
|
116
|
+
const selected = [
|
|
117
|
+
source.inline !== undefined ? "inline" : null,
|
|
118
|
+
source.stdin ? "stdin" : null,
|
|
119
|
+
source.file !== undefined ? "file" : null,
|
|
120
|
+
source.env !== undefined ? "env" : null,
|
|
121
|
+
].filter((value): value is string => value !== null);
|
|
122
|
+
|
|
123
|
+
if (selected.length > 1) {
|
|
124
|
+
throw new CliError(
|
|
125
|
+
`Choose only one ${source.label} source: ${source.flagNames.join(", ")}`,
|
|
126
|
+
ExitCode.BadArgs,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (selected.length === 0) return undefined;
|
|
131
|
+
|
|
132
|
+
let value: string;
|
|
133
|
+
if (source.inline !== undefined) {
|
|
134
|
+
value = source.inline;
|
|
135
|
+
} else if (source.stdin) {
|
|
136
|
+
value = await stdin.read();
|
|
137
|
+
} else if (source.file !== undefined) {
|
|
138
|
+
value = await readFileOrStdin(source.file, stdin, source.label);
|
|
139
|
+
} else {
|
|
140
|
+
value = readEnvValue(source.env!, source.label);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!source.allowEmpty && value.length === 0) {
|
|
144
|
+
throw new CliError(`${capitalize(source.label)} cannot be empty`, ExitCode.BadArgs);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function resolveFieldSources(
|
|
151
|
+
sources: FieldSources,
|
|
152
|
+
): Promise<ParsedField[]> {
|
|
153
|
+
const fields = sources.inline.map(parseFieldFlag);
|
|
154
|
+
|
|
155
|
+
for (const raw of sources.file) {
|
|
156
|
+
const field = parseRequiredSourceField(raw, sources.fileFlag);
|
|
157
|
+
fields.push({
|
|
158
|
+
...field,
|
|
159
|
+
value: await readFileOrStdin(field.value, sources.stdin, field.name),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const raw of sources.env) {
|
|
164
|
+
const field = parseRequiredSourceField(raw, sources.envFlag);
|
|
165
|
+
fields.push({
|
|
166
|
+
...field,
|
|
167
|
+
value: readEnvValue(field.value, field.name),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return fields;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function assertSingleStdinSource(sources: string[]): void {
|
|
175
|
+
if (sources.length <= 1) return;
|
|
176
|
+
throw new CliError(
|
|
177
|
+
`Only one stdin input source can be used at a time: ${sources.join(", ")}`,
|
|
178
|
+
ExitCode.BadArgs,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function stdinSourcesForFieldFiles(flag: string, raws: string[]): string[] {
|
|
183
|
+
return raws
|
|
184
|
+
.filter((raw) => parseRequiredSourceField(raw, flag).value === "-")
|
|
185
|
+
.map((raw) => `${flag} ${raw}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function readOptionalStdin(stdin: StdinReader): Promise<string | null> {
|
|
189
|
+
const text = await stdin.read();
|
|
190
|
+
return text.length > 0 ? text : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseRequiredSourceField(raw: string, flag: string): ParsedField {
|
|
194
|
+
if (!raw.includes("=")) {
|
|
195
|
+
throw new CliError(`Expected ${flag} k=value`, ExitCode.BadArgs);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const field = parseFieldFlag(raw);
|
|
199
|
+
if (field.value.length === 0) {
|
|
200
|
+
throw new CliError(`Expected ${flag} ${field.name}=value`, ExitCode.BadArgs);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return field;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function readFileOrStdin(
|
|
207
|
+
path: string,
|
|
208
|
+
stdin: StdinReader,
|
|
209
|
+
label: string,
|
|
210
|
+
): Promise<string> {
|
|
211
|
+
if (path === "-") return stdin.read();
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
return stripFinalNewline(await Bun.file(path).text());
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
217
|
+
throw new CliError(
|
|
218
|
+
`Failed to read ${label} from "${path}": ${message}`,
|
|
219
|
+
ExitCode.BadArgs,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readEnvValue(name: string, label: string): string {
|
|
225
|
+
if (!Object.prototype.hasOwnProperty.call(process.env, name)) {
|
|
226
|
+
throw new CliError(
|
|
227
|
+
`Environment variable ${name} is not set for ${label}`,
|
|
228
|
+
ExitCode.BadArgs,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return process.env[name] ?? "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function readStdinText(): Promise<string> {
|
|
235
|
+
const chunks: Uint8Array[] = [];
|
|
236
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
237
|
+
chunks.push(chunk);
|
|
238
|
+
}
|
|
239
|
+
return stripFinalNewline(Buffer.concat(chunks).toString());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function stripFinalNewline(text: string): string {
|
|
243
|
+
return text.replace(/\r?\n$/, "");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function capitalize(value: string): string {
|
|
247
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
248
|
+
}
|
package/src/cli/program.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { registerLock } from "./commands/lock.ts";
|
|
|
12
12
|
import { registerConfig } from "./commands/config.ts";
|
|
13
13
|
import { registerDoctor } from "./commands/doctor.ts";
|
|
14
14
|
import { registerList } from "./commands/list.ts";
|
|
15
|
+
import { registerAttach } from "./commands/attach.ts";
|
|
15
16
|
|
|
16
17
|
export function buildProgram(): Command {
|
|
17
18
|
const program = new Command();
|
|
@@ -19,7 +20,7 @@ export function buildProgram(): Command {
|
|
|
19
20
|
program
|
|
20
21
|
.name("bwx")
|
|
21
22
|
.description("Bitwarden Extended CLI")
|
|
22
|
-
.version("0.
|
|
23
|
+
.version("0.3.0")
|
|
23
24
|
.option("--json", "JSON output")
|
|
24
25
|
.option("--plain", "Plain parseable output")
|
|
25
26
|
.option("-q, --quiet", "Suppress log output")
|
|
@@ -39,6 +40,7 @@ export function buildProgram(): Command {
|
|
|
39
40
|
registerCreate(program);
|
|
40
41
|
registerEdit(program);
|
|
41
42
|
registerDelete(program);
|
|
43
|
+
registerAttach(program);
|
|
42
44
|
registerList(program);
|
|
43
45
|
registerConfig(program);
|
|
44
46
|
registerDoctor(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,25 +7,35 @@ 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
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const rawOpts = program.opts();
|
|
15
|
-
opts = {
|
|
16
|
-
json: rawOpts.json ?? false,
|
|
17
|
-
plain: rawOpts.plain ?? false,
|
|
18
|
-
quiet: rawOpts.quiet ?? false,
|
|
19
|
-
verbose: rawOpts.verbose ?? false,
|
|
20
|
-
};
|
|
21
|
-
} catch {
|
|
22
|
-
// Fall through — full parse below will handle
|
|
10
|
+
// Bare invocation should show top-level help.
|
|
11
|
+
if (argv.length <= 2) {
|
|
12
|
+
program.outputHelp();
|
|
13
|
+
return;
|
|
23
14
|
}
|
|
24
15
|
|
|
16
|
+
// Parse global opts early for error formatting
|
|
17
|
+
const opts = parseGlobalOpts(argv.slice(2));
|
|
18
|
+
|
|
25
19
|
try {
|
|
26
20
|
await program.parseAsync(argv);
|
|
27
21
|
} catch (err) {
|
|
28
22
|
if (err instanceof CommanderError) {
|
|
23
|
+
if (err.code === "commander.help") {
|
|
24
|
+
const helpArgv = [...argv, "--help"];
|
|
25
|
+
try {
|
|
26
|
+
await program.parseAsync(helpArgv);
|
|
27
|
+
} catch (helpErr) {
|
|
28
|
+
if (
|
|
29
|
+
!(
|
|
30
|
+
helpErr instanceof CommanderError &&
|
|
31
|
+
helpErr.code === "commander.helpDisplayed"
|
|
32
|
+
)
|
|
33
|
+
) {
|
|
34
|
+
throw helpErr;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
process.exit(err.exitCode);
|
|
38
|
+
}
|
|
29
39
|
if (err.exitCode === 0) process.exit(0); // --help, --version
|
|
30
40
|
// Commander prefixes messages with "error: " — strip it
|
|
31
41
|
const msg = err.message.replace(/^error:\s*/i, "");
|
|
@@ -34,3 +44,12 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
34
44
|
handleFatalError(err, opts);
|
|
35
45
|
}
|
|
36
46
|
}
|
|
47
|
+
|
|
48
|
+
function parseGlobalOpts(args: string[]): GlobalOptions {
|
|
49
|
+
return {
|
|
50
|
+
json: args.includes("--json"),
|
|
51
|
+
plain: args.includes("--plain"),
|
|
52
|
+
quiet: args.includes("-q") || args.includes("--quiet"),
|
|
53
|
+
verbose: args.includes("-v") || args.includes("--verbose"),
|
|
54
|
+
};
|
|
55
|
+
}
|