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,57 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { generateSecret, type GenerateOptions } from "../../bw/generate.ts";
|
|
3
|
+
import { CliError, ExitCode } from "../errors.ts";
|
|
4
|
+
import { emitData } from "../io.ts";
|
|
5
|
+
import { getGlobalOpts } from "../program.ts";
|
|
6
|
+
|
|
7
|
+
export function registerGenerate(program: Command): void {
|
|
8
|
+
program
|
|
9
|
+
.command("generate").alias("gen")
|
|
10
|
+
.description("Generate a password or passphrase (local, no vault access)")
|
|
11
|
+
.option("--length <n>", "Password length", parsePositiveInt("--length"))
|
|
12
|
+
.option("--no-uppercase", "Exclude uppercase letters")
|
|
13
|
+
.option("--no-lowercase", "Exclude lowercase letters")
|
|
14
|
+
.option("--no-numbers", "Exclude digits")
|
|
15
|
+
.option("-s, --special", "Include special characters")
|
|
16
|
+
.option("--ambiguous", "Allow ambiguous characters")
|
|
17
|
+
.option("--passphrase", "Generate a passphrase instead")
|
|
18
|
+
.option("--words <n>", "Words in the passphrase", parsePositiveInt("--words"))
|
|
19
|
+
.option("--separator <char>", "Passphrase word separator")
|
|
20
|
+
.option("--capitalize", "Capitalize passphrase words")
|
|
21
|
+
.option("--include-number", "Include a number in the passphrase")
|
|
22
|
+
.action(async function (this: Command, localOpts: GenerateOptions) {
|
|
23
|
+
const opts = getGlobalOpts(this);
|
|
24
|
+
emitData(await generateSecret(localOpts), opts);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strips a password bwx generated out of the echoed item. The entire point of
|
|
30
|
+
* `--password-generate` is that the value goes to the vault without passing
|
|
31
|
+
* through the caller's terminal, transcript, or logs — echoing it back in the
|
|
32
|
+
* `--json` result would hand it straight back.
|
|
33
|
+
*/
|
|
34
|
+
export function redactGeneratedPassword(
|
|
35
|
+
item: Record<string, unknown>,
|
|
36
|
+
generated: boolean,
|
|
37
|
+
): Record<string, unknown> {
|
|
38
|
+
if (!generated) return item;
|
|
39
|
+
|
|
40
|
+
const login = item.login as Record<string, unknown> | null | undefined;
|
|
41
|
+
if (!login) return item;
|
|
42
|
+
|
|
43
|
+
return { ...item, login: { ...login, password: "***GENERATED***" } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parsePositiveInt(flag: string): (raw: string) => number {
|
|
47
|
+
return (raw: string) => {
|
|
48
|
+
const value = Number(raw);
|
|
49
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
50
|
+
throw new CliError(
|
|
51
|
+
`Invalid ${flag} "${raw}". Use a positive integer.`,
|
|
52
|
+
ExitCode.BadArgs,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { emitData } from "../io.ts";
|
|
4
4
|
import { readItemField } from "../../bw/fields.ts";
|
|
5
|
+
import { CliError } from "../errors.ts";
|
|
5
6
|
|
|
6
7
|
export function registerGet(program: Command): void {
|
|
7
8
|
program
|
|
@@ -18,11 +19,34 @@ export function registerGet(program: Command): void {
|
|
|
18
19
|
|
|
19
20
|
program
|
|
20
21
|
.command("field")
|
|
21
|
-
.description("Get a custom field from a vault item")
|
|
22
|
-
|
|
22
|
+
.description("Get a custom field from a vault item (never a built-in)")
|
|
23
|
+
// Same order as `get`, which is the whole point of this command existing
|
|
24
|
+
// alongside it — the two used to take their arguments back to front.
|
|
23
25
|
.argument("<name>", "Custom field name")
|
|
24
|
-
.
|
|
26
|
+
.argument("<item>", "Item name or ID")
|
|
27
|
+
.action(async function (this: Command, name: string, item: string) {
|
|
25
28
|
const opts = getGlobalOpts(this);
|
|
26
|
-
|
|
29
|
+
try {
|
|
30
|
+
emitData(
|
|
31
|
+
await readItemField(name, item, opts, { customOnly: true }),
|
|
32
|
+
opts,
|
|
33
|
+
);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw withOrderHint(err);
|
|
36
|
+
}
|
|
27
37
|
});
|
|
28
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `bwx field` took `<item> <name>` until 0.5.0. A caller using the old order
|
|
42
|
+
* fails somewhere unhelpful — a missing item, or a missing field on the wrong
|
|
43
|
+
* item — so say what changed rather than leaving them to guess.
|
|
44
|
+
*/
|
|
45
|
+
function withOrderHint(err: unknown): unknown {
|
|
46
|
+
if (!(err instanceof CliError)) return err;
|
|
47
|
+
|
|
48
|
+
return new CliError(
|
|
49
|
+
`${err.message}\n\nNote: 'bwx field' takes <name> <item> as of 0.5.0 (it was <item> <name>).`,
|
|
50
|
+
err.exitCode,
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/cli/commands/list.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { CliError, ExitCode } from "../errors.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
filterItems,
|
|
6
|
+
parseLimit,
|
|
7
|
+
writeItems,
|
|
8
|
+
type ItemListOptions,
|
|
9
|
+
} from "../items.ts";
|
|
5
10
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
11
|
import { withSession } from "../../bw/session.ts";
|
|
12
|
+
import { resolveFolderFilter } from "../../bw/folders.ts";
|
|
7
13
|
|
|
8
14
|
export function registerList(program: Command): void {
|
|
9
15
|
program
|
|
@@ -11,28 +17,39 @@ export function registerList(program: Command): void {
|
|
|
11
17
|
.alias("ls")
|
|
12
18
|
.description("List all vault items")
|
|
13
19
|
.argument("[type]", "Filter by type: logins|notes|cards|identities")
|
|
14
|
-
.option("--
|
|
15
|
-
.option("--
|
|
20
|
+
.option("--type <type>", "Filter by type (same as the positional form)")
|
|
21
|
+
.option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
|
|
22
|
+
.option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
|
|
16
23
|
.option("--full-items", "Emit complete item objects (includes secrets)")
|
|
17
24
|
.action(async function (
|
|
18
25
|
this: Command,
|
|
19
|
-
|
|
26
|
+
positionalType: string | undefined,
|
|
20
27
|
localOpts: ItemListOptions & { folder?: string },
|
|
21
28
|
) {
|
|
22
29
|
const opts = getGlobalOpts(this);
|
|
23
30
|
|
|
31
|
+
if (positionalType && localOpts.type && positionalType !== localOpts.type) {
|
|
32
|
+
throw new CliError(
|
|
33
|
+
`Conflicting types: "${positionalType}" and --type ${localOpts.type}`,
|
|
34
|
+
ExitCode.BadArgs,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
const args = ["list", "items"];
|
|
25
39
|
if (localOpts.folder) {
|
|
26
|
-
args.push("--folderid", localOpts.folder);
|
|
40
|
+
args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
const json = await withSession(opts, () => runBwOrThrow(args));
|
|
30
|
-
const
|
|
44
|
+
const filtered = filterItems(JSON.parse(json), {
|
|
45
|
+
...localOpts,
|
|
46
|
+
type: positionalType ?? localOpts.type,
|
|
47
|
+
});
|
|
31
48
|
|
|
32
|
-
if (
|
|
49
|
+
if (filtered.total === 0) {
|
|
33
50
|
throw new CliError("No items found", ExitCode.NotFound);
|
|
34
51
|
}
|
|
35
52
|
|
|
36
|
-
writeItems(
|
|
53
|
+
writeItems(filtered, opts, localOpts);
|
|
37
54
|
});
|
|
38
55
|
}
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { constants } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { readFields } from "../../bw/fields.ts";
|
|
4
4
|
import { CliError, ExitCode } from "../errors.ts";
|
|
5
5
|
import { collect } from "../input.ts";
|
|
6
6
|
import { emitLog } from "../io.ts";
|
|
@@ -95,18 +95,23 @@ export function parseEnvSpec(raw: string): EnvSpec {
|
|
|
95
95
|
return { name, field: ref.slice(0, colon), item: ref.slice(colon + 1) };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Resolves every secret, paying one `bw` spawn per distinct *item* rather than
|
|
100
|
+
* per variable — pulling a username and password from the same item is a single
|
|
101
|
+
* vault read.
|
|
102
|
+
*/
|
|
99
103
|
async function resolveSecrets(
|
|
100
104
|
specs: EnvSpec[],
|
|
101
105
|
opts: GlobalOptions,
|
|
102
106
|
): Promise<Record<string, string>> {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
const values = await readFields(
|
|
108
|
+
specs.map((spec) => ({ field: spec.field, item: spec.item })),
|
|
109
|
+
opts,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return Object.fromEntries(
|
|
113
|
+
specs.map((spec, index) => [spec.name, values[index]!]),
|
|
114
|
+
);
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
/**
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { CliError, ExitCode } from "../errors.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
filterItems,
|
|
6
|
+
parseLimit,
|
|
7
|
+
writeItems,
|
|
8
|
+
type ItemListOptions,
|
|
9
|
+
} from "../items.ts";
|
|
5
10
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
11
|
import { withSession } from "../../bw/session.ts";
|
|
12
|
+
import { resolveFolderFilter } from "../../bw/folders.ts";
|
|
7
13
|
|
|
8
14
|
export function registerSearch(program: Command): void {
|
|
9
15
|
program
|
|
10
|
-
.command("search")
|
|
16
|
+
.command("search")
|
|
17
|
+
.alias("find")
|
|
11
18
|
.description("Search vault items")
|
|
12
19
|
.argument("<query>", "Search query")
|
|
13
20
|
.option("--type <type>", "Filter by type: login|note|card|identity")
|
|
14
|
-
.option("--folder <id>", "Filter by folder ID")
|
|
15
|
-
.option("--limit <n>", "Limit results",
|
|
21
|
+
.option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
|
|
22
|
+
.option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
|
|
16
23
|
.option("--full-items", "Emit complete item objects (includes secrets)")
|
|
17
24
|
.action(async function (
|
|
18
25
|
this: Command,
|
|
@@ -23,19 +30,16 @@ export function registerSearch(program: Command): void {
|
|
|
23
30
|
|
|
24
31
|
const args = ["list", "items", "--search", query];
|
|
25
32
|
if (localOpts.folder) {
|
|
26
|
-
args.push("--folderid", localOpts.folder);
|
|
33
|
+
args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
const json = await withSession(opts, () => runBwOrThrow(args));
|
|
30
|
-
const
|
|
37
|
+
const filtered = filterItems(JSON.parse(json), localOpts);
|
|
31
38
|
|
|
32
|
-
if (
|
|
33
|
-
throw new CliError(
|
|
34
|
-
`No items matching "${query}"`,
|
|
35
|
-
ExitCode.NotFound,
|
|
36
|
-
);
|
|
39
|
+
if (filtered.total === 0) {
|
|
40
|
+
throw new CliError(`No items matching "${query}"`, ExitCode.NotFound);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
writeItems(
|
|
43
|
+
writeItems(filtered, opts, localOpts);
|
|
40
44
|
});
|
|
41
45
|
}
|
|
@@ -3,8 +3,15 @@ import { getGlobalOpts } from "../program.ts";
|
|
|
3
3
|
import { emitData } from "../io.ts";
|
|
4
4
|
import { recordStatus } from "../../bw/freshness.ts";
|
|
5
5
|
import { getStatus } from "../../bw/status.ts";
|
|
6
|
+
import { probeSessionState, type SessionState } from "../../bw/session.ts";
|
|
6
7
|
import pc from "picocolors";
|
|
7
8
|
|
|
9
|
+
const SESSION_HINTS: Record<SessionState, string> = {
|
|
10
|
+
valid: "reads work without unlocking",
|
|
11
|
+
stale: "cached session rejected — next read re-unlocks",
|
|
12
|
+
none: "no cached session — next read unlocks",
|
|
13
|
+
};
|
|
14
|
+
|
|
8
15
|
export function registerStatus(program: Command): void {
|
|
9
16
|
program
|
|
10
17
|
.command("status")
|
|
@@ -15,8 +22,11 @@ export function registerStatus(program: Command): void {
|
|
|
15
22
|
// Reads reuse this for their staleness check instead of paying for `bw status`.
|
|
16
23
|
recordStatus(status);
|
|
17
24
|
|
|
25
|
+
const session = await resolveSessionState(status.status, opts);
|
|
26
|
+
const payload = { ...status, session };
|
|
27
|
+
|
|
18
28
|
if (opts.json || opts.plain) {
|
|
19
|
-
emitData(
|
|
29
|
+
emitData(payload, opts);
|
|
20
30
|
return;
|
|
21
31
|
}
|
|
22
32
|
|
|
@@ -27,12 +37,29 @@ export function registerStatus(program: Command): void {
|
|
|
27
37
|
? pc.yellow
|
|
28
38
|
: pc.red;
|
|
29
39
|
|
|
40
|
+
const sessionColor = session === "valid" ? pc.green : pc.yellow;
|
|
41
|
+
|
|
30
42
|
const lines = [
|
|
31
|
-
`${pc.dim("Status:")}
|
|
32
|
-
`${pc.dim("
|
|
33
|
-
`${pc.dim("
|
|
34
|
-
`${pc.dim("
|
|
43
|
+
`${pc.dim("Status:")} ${statusColor(status.status)}`,
|
|
44
|
+
`${pc.dim("Session:")} ${sessionColor(session)} ${pc.dim(`(${SESSION_HINTS[session]})`)}`,
|
|
45
|
+
`${pc.dim("Email:")} ${status.userEmail ?? "-"}`,
|
|
46
|
+
`${pc.dim("Server:")} ${status.serverUrl ?? "-"}`,
|
|
47
|
+
`${pc.dim("Synced:")} ${status.lastSync ?? "never"}`,
|
|
35
48
|
];
|
|
36
49
|
process.stdout.write(lines.join("\n") + "\n");
|
|
37
50
|
});
|
|
38
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Only a locked vault leaves the question open — an unlocked one reads fine, and
|
|
55
|
+
* an unauthenticated one has nothing a session could unlock. Skipping the probe
|
|
56
|
+
* in those cases keeps `status` at a single `bw` call.
|
|
57
|
+
*/
|
|
58
|
+
async function resolveSessionState(
|
|
59
|
+
vaultStatus: string,
|
|
60
|
+
opts: ReturnType<typeof getGlobalOpts>,
|
|
61
|
+
): Promise<SessionState> {
|
|
62
|
+
if (vaultStatus === "unlocked") return "valid";
|
|
63
|
+
if (vaultStatus === "unauthenticated") return "none";
|
|
64
|
+
return probeSessionState(opts);
|
|
65
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
4
|
+
import { withSession } from "../../bw/session.ts";
|
|
5
|
+
import { CliError, ExitCode } from "../errors.ts";
|
|
6
|
+
import { emitData, emitSuccess } from "../io.ts";
|
|
7
|
+
import { filterItems, parseLimit, writeItems, type ItemListOptions } from "../items.ts";
|
|
8
|
+
import { getGlobalOpts } from "../program.ts";
|
|
9
|
+
import type { GlobalOptions } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `bwx delete` puts an item in the trash rather than destroying it, which is only
|
|
13
|
+
* an undo if the trash can be read and reversed from here.
|
|
14
|
+
*/
|
|
15
|
+
export function registerTrash(program: Command): void {
|
|
16
|
+
program
|
|
17
|
+
.command("trash")
|
|
18
|
+
.description("List items in the trash")
|
|
19
|
+
.option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
|
|
20
|
+
.option("--full-items", "Emit complete item objects (includes secrets)")
|
|
21
|
+
.action(async function (this: Command, localOpts: ItemListOptions) {
|
|
22
|
+
const opts = getGlobalOpts(this);
|
|
23
|
+
const filtered = filterItems(await listTrashed(opts), localOpts);
|
|
24
|
+
|
|
25
|
+
if (filtered.total === 0) {
|
|
26
|
+
throw new CliError("Trash is empty", ExitCode.NotFound);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
writeItems(filtered, opts, localOpts);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.command("restore")
|
|
34
|
+
.description("Restore a deleted item from the trash")
|
|
35
|
+
.argument("<item>", "Item name or ID, as shown by 'bwx trash'")
|
|
36
|
+
.action(async function (this: Command, item: string) {
|
|
37
|
+
const opts = getGlobalOpts(this);
|
|
38
|
+
const trashed = await findTrashed(item, opts);
|
|
39
|
+
const id = String(trashed.id);
|
|
40
|
+
const name = String(trashed.name);
|
|
41
|
+
|
|
42
|
+
await withSession(opts, () => runBwOrThrow(["restore", "item", id]));
|
|
43
|
+
|
|
44
|
+
if (opts.json) {
|
|
45
|
+
emitData({ id, name, restored: true }, opts);
|
|
46
|
+
} else if (opts.plain) {
|
|
47
|
+
emitData(id, opts);
|
|
48
|
+
} else {
|
|
49
|
+
emitSuccess(`Restored "${name}" (${id})`, opts);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function listTrashed(
|
|
55
|
+
opts: GlobalOptions,
|
|
56
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
57
|
+
const json = await withSession(opts, () =>
|
|
58
|
+
runBwOrThrow(["list", "items", "--trash"]),
|
|
59
|
+
);
|
|
60
|
+
return JSON.parse(json) as Array<Record<string, unknown>>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function findTrashed(
|
|
64
|
+
item: string,
|
|
65
|
+
opts: GlobalOptions,
|
|
66
|
+
): Promise<Record<string, unknown>> {
|
|
67
|
+
const trashed = await listTrashed(opts);
|
|
68
|
+
|
|
69
|
+
const matches = trashed.filter(
|
|
70
|
+
(entry) =>
|
|
71
|
+
entry.id === item ||
|
|
72
|
+
String(entry.name ?? "").toLowerCase() === item.toLowerCase(),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (matches.length === 0) {
|
|
76
|
+
throw new CliError(
|
|
77
|
+
`No trashed item matching "${item}". Run 'bwx trash' to list them.`,
|
|
78
|
+
ExitCode.NotFound,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (matches.length > 1) {
|
|
83
|
+
const lines = [`Multiple trashed items match "${item}":\n`];
|
|
84
|
+
for (const match of matches) {
|
|
85
|
+
lines.push(` ${pc.dim(String(match.id))} ${String(match.name)}`);
|
|
86
|
+
}
|
|
87
|
+
lines.push("\nUse a specific ID.");
|
|
88
|
+
throw new CliError(lines.join("\n"), ExitCode.BadArgs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return matches[0]!;
|
|
92
|
+
}
|
package/src/cli/input.ts
CHANGED
|
@@ -6,6 +6,17 @@ export function collect(val: string, prev: string[]): string[] {
|
|
|
6
6
|
return prev;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/** Reads a single line from stdin, for the few interactive prompts bwx has. */
|
|
10
|
+
export async function readLine(): Promise<string> {
|
|
11
|
+
const chunks: Uint8Array[] = [];
|
|
12
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
13
|
+
chunks.push(chunk);
|
|
14
|
+
const text = Buffer.concat(chunks).toString();
|
|
15
|
+
if (text.includes("\n")) return text.split("\n")[0]!.trim();
|
|
16
|
+
}
|
|
17
|
+
return Buffer.concat(chunks).toString().trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
/**
|
|
10
21
|
* Lazily reads stdin once. Commands validate that only one explicit stdin source
|
|
11
22
|
* is selected, but caching also keeps implicit legacy stdin reads predictable.
|
|
@@ -25,6 +36,8 @@ export interface TextSource {
|
|
|
25
36
|
stdin?: boolean;
|
|
26
37
|
file?: string;
|
|
27
38
|
env?: string;
|
|
39
|
+
/** Produces the value instead of reading it — see `--password-generate`. */
|
|
40
|
+
generate?: () => Promise<string>;
|
|
28
41
|
flagNames: string[];
|
|
29
42
|
allowEmpty?: boolean;
|
|
30
43
|
}
|
|
@@ -45,11 +58,14 @@ export interface ItemTextInputs {
|
|
|
45
58
|
passwordStdin?: boolean;
|
|
46
59
|
passwordFile?: string;
|
|
47
60
|
passwordEnv?: string;
|
|
61
|
+
passwordGenerate?: boolean;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
export interface ResolvedItemTextInputs {
|
|
51
65
|
notes?: string;
|
|
52
66
|
password?: string;
|
|
67
|
+
/** True when the password was generated, so callers can keep it unprinted. */
|
|
68
|
+
passwordGenerated?: boolean;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
export interface ExplicitStdinInputs extends ItemTextInputs {
|
|
@@ -63,11 +79,13 @@ const PASSWORD_SOURCE_FLAGS = [
|
|
|
63
79
|
"--password-stdin",
|
|
64
80
|
"--password-file",
|
|
65
81
|
"--password-env",
|
|
82
|
+
"--password-generate",
|
|
66
83
|
];
|
|
67
84
|
|
|
68
85
|
export async function resolveItemTextInputs(
|
|
69
86
|
inputs: ItemTextInputs,
|
|
70
87
|
stdin: StdinReader,
|
|
88
|
+
generatePassword?: () => Promise<string>,
|
|
71
89
|
): Promise<ResolvedItemTextInputs> {
|
|
72
90
|
assertSingleStdinSource(listExplicitStdinInputs(inputs));
|
|
73
91
|
|
|
@@ -89,13 +107,21 @@ export async function resolveItemTextInputs(
|
|
|
89
107
|
stdin: inputs.passwordStdin,
|
|
90
108
|
file: inputs.passwordFile,
|
|
91
109
|
env: inputs.passwordEnv,
|
|
110
|
+
generate:
|
|
111
|
+
inputs.passwordGenerate && generatePassword
|
|
112
|
+
? generatePassword
|
|
113
|
+
: undefined,
|
|
92
114
|
flagNames: PASSWORD_SOURCE_FLAGS,
|
|
93
115
|
},
|
|
94
116
|
stdin,
|
|
95
117
|
),
|
|
96
118
|
]);
|
|
97
119
|
|
|
98
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
notes,
|
|
122
|
+
password,
|
|
123
|
+
passwordGenerated: inputs.passwordGenerate ?? false,
|
|
124
|
+
};
|
|
99
125
|
}
|
|
100
126
|
|
|
101
127
|
export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
|
|
@@ -118,6 +144,7 @@ export async function resolveOptionalTextSource(
|
|
|
118
144
|
source.stdin ? "stdin" : null,
|
|
119
145
|
source.file !== undefined ? "file" : null,
|
|
120
146
|
source.env !== undefined ? "env" : null,
|
|
147
|
+
source.generate !== undefined ? "generate" : null,
|
|
121
148
|
].filter((value): value is string => value !== null);
|
|
122
149
|
|
|
123
150
|
if (selected.length > 1) {
|
|
@@ -136,6 +163,8 @@ export async function resolveOptionalTextSource(
|
|
|
136
163
|
value = await stdin.read();
|
|
137
164
|
} else if (source.file !== undefined) {
|
|
138
165
|
value = await readFileOrStdin(source.file, stdin, source.label);
|
|
166
|
+
} else if (source.generate !== undefined) {
|
|
167
|
+
value = await source.generate();
|
|
139
168
|
} else {
|
|
140
169
|
value = readEnvValue(source.env!, source.label);
|
|
141
170
|
}
|
package/src/cli/io.ts
CHANGED
|
@@ -2,9 +2,19 @@ import pc from "picocolors";
|
|
|
2
2
|
import { CliError, ExitCode } from "./errors.ts";
|
|
3
3
|
import type { GlobalOptions } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `meta` rides along in the JSON envelope for facts about the response itself
|
|
7
|
+
* (truncation, counts) that a machine caller cannot recover from `data` alone.
|
|
8
|
+
* It is omitted entirely when absent, so `{ data }` stays the stable shape.
|
|
9
|
+
*/
|
|
10
|
+
export function emitData(
|
|
11
|
+
value: unknown,
|
|
12
|
+
opts: GlobalOptions,
|
|
13
|
+
meta?: Record<string, unknown>,
|
|
14
|
+
): void {
|
|
6
15
|
if (opts.json) {
|
|
7
|
-
|
|
16
|
+
const payload = meta ? { data: value, meta } : { data: value };
|
|
17
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
8
18
|
} else if (opts.plain) {
|
|
9
19
|
if (typeof value === "string") {
|
|
10
20
|
process.stdout.write(value + "\n");
|
package/src/cli/items.ts
CHANGED
|
@@ -2,9 +2,17 @@ import pc from "picocolors";
|
|
|
2
2
|
import { summarizeItem, type ItemSummary } from "../bw/items.ts";
|
|
3
3
|
import { BW_TYPE_FROM_NAME } from "../bw/types.ts";
|
|
4
4
|
import { CliError, ExitCode } from "./errors.ts";
|
|
5
|
-
import { emitData } from "./io.ts";
|
|
5
|
+
import { emitData, emitWarn } from "./io.ts";
|
|
6
6
|
import type { GlobalOptions } from "./types.ts";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Listings are capped by default: an uncapped `bwx list` returns the whole vault,
|
|
10
|
+
* which floods an agent's context and, with `--full-items`, dumps every secret in
|
|
11
|
+
* one call. `--limit 0` is the explicit opt-out, and truncation is always
|
|
12
|
+
* reported so a capped listing can never be mistaken for a complete one.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_LIMIT = 50;
|
|
15
|
+
|
|
8
16
|
export interface ItemListOptions {
|
|
9
17
|
type?: string;
|
|
10
18
|
limit?: number;
|
|
@@ -12,10 +20,29 @@ export interface ItemListOptions {
|
|
|
12
20
|
fullItems?: boolean;
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
export interface FilteredItems {
|
|
24
|
+
items: Array<Record<string, unknown>>;
|
|
25
|
+
/** Items matching the filters, before the limit was applied. */
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Commander option parser: rejects garbage instead of silently ignoring it. */
|
|
30
|
+
export function parseLimit(raw: string): number {
|
|
31
|
+
// `Number("")` and `Number(" ")` are 0, which would read as "no limit".
|
|
32
|
+
const value = raw.trim() === "" ? Number.NaN : Number(raw);
|
|
33
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
34
|
+
throw new CliError(
|
|
35
|
+
`Invalid --limit "${raw}". Use a non-negative integer (0 for no limit).`,
|
|
36
|
+
ExitCode.BadArgs,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
export function filterItems(
|
|
16
43
|
items: Array<Record<string, unknown>>,
|
|
17
44
|
options: ItemListOptions,
|
|
18
|
-
):
|
|
45
|
+
): FilteredItems {
|
|
19
46
|
let result = items;
|
|
20
47
|
|
|
21
48
|
if (options.type) {
|
|
@@ -29,11 +56,10 @@ export function filterItems(
|
|
|
29
56
|
result = result.filter((item) => item.type === typeVal);
|
|
30
57
|
}
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
59
|
+
const total = result.length;
|
|
60
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
35
61
|
|
|
36
|
-
return result;
|
|
62
|
+
return { items: limit > 0 ? result.slice(0, limit) : result, total };
|
|
37
63
|
}
|
|
38
64
|
|
|
39
65
|
/**
|
|
@@ -41,14 +67,32 @@ export function filterItems(
|
|
|
41
67
|
* the same reduced projection, so asking for machine-readable output is a
|
|
42
68
|
* serialization choice and never widens data access. `--full-items` is the
|
|
43
69
|
* explicit opt-in for callers that genuinely need the complete objects.
|
|
70
|
+
*
|
|
71
|
+
* A truncated listing reports what it dropped through every channel — a stderr
|
|
72
|
+
* warning that survives `--json`, plus `meta` in the JSON envelope — because a
|
|
73
|
+
* silently capped list reads exactly like a complete one.
|
|
44
74
|
*/
|
|
45
75
|
export function writeItems(
|
|
46
|
-
|
|
76
|
+
filtered: FilteredItems,
|
|
47
77
|
opts: GlobalOptions,
|
|
48
78
|
options: ItemListOptions = {},
|
|
49
79
|
): void {
|
|
80
|
+
const { items, total } = filtered;
|
|
81
|
+
const omitted = total - items.length;
|
|
82
|
+
|
|
83
|
+
if (omitted > 0) {
|
|
84
|
+
emitWarn(
|
|
85
|
+
`Showing ${items.length} of ${total} items — pass --limit 0 for all.`,
|
|
86
|
+
opts,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
if (opts.json) {
|
|
51
|
-
emitData(
|
|
91
|
+
emitData(
|
|
92
|
+
options.fullItems ? items : items.map(summarizeItem),
|
|
93
|
+
opts,
|
|
94
|
+
{ total, shown: items.length, truncated: omitted > 0 },
|
|
95
|
+
);
|
|
52
96
|
return;
|
|
53
97
|
}
|
|
54
98
|
|
package/src/cli/program.ts
CHANGED
|
@@ -17,6 +17,9 @@ import { registerDoctor } from "./commands/doctor.ts";
|
|
|
17
17
|
import { registerList } from "./commands/list.ts";
|
|
18
18
|
import { registerAttach } from "./commands/attach.ts";
|
|
19
19
|
import { registerRun } from "./commands/run.ts";
|
|
20
|
+
import { registerGenerate } from "./commands/generate.ts";
|
|
21
|
+
import { registerFolders } from "./commands/folders.ts";
|
|
22
|
+
import { registerTrash } from "./commands/trash.ts";
|
|
20
23
|
|
|
21
24
|
export function buildProgram(): Command {
|
|
22
25
|
const program = new Command();
|
|
@@ -59,8 +62,11 @@ export function buildProgram(): Command {
|
|
|
59
62
|
registerCreate(program);
|
|
60
63
|
registerEdit(program);
|
|
61
64
|
registerDelete(program);
|
|
65
|
+
registerTrash(program);
|
|
62
66
|
registerAttach(program);
|
|
63
67
|
registerList(program);
|
|
68
|
+
registerFolders(program);
|
|
69
|
+
registerGenerate(program);
|
|
64
70
|
registerConfig(program);
|
|
65
71
|
registerDoctor(program);
|
|
66
72
|
|