bwx 0.4.0 → 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 +3 -2
- package/skill/bwx/SKILL.md +196 -0
- 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
|
@@ -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
|
|