bwx 0.4.1 → 0.6.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 +136 -10
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +107 -14
- package/src/bw/encoding.ts +16 -4
- package/src/bw/fields.ts +326 -27
- package/src/bw/folders.ts +96 -0
- package/src/bw/generate.ts +91 -0
- package/src/bw/items.ts +8 -0
- package/src/bw/session.ts +36 -10
- package/src/bw/totp.ts +246 -0
- package/src/cli/commands/config.ts +1 -10
- package/src/cli/commands/create.ts +60 -5
- package/src/cli/commands/delete.ts +48 -21
- package/src/cli/commands/edit.ts +88 -5
- package/src/cli/commands/folders.ts +34 -0
- package/src/cli/commands/generate.ts +57 -0
- package/src/cli/commands/get.ts +130 -7
- 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 +55 -2
- package/src/cli/io.ts +12 -2
- package/src/cli/items.ts +56 -10
- package/src/cli/program.ts +6 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { listFolders } from "../../bw/folders.ts";
|
|
4
|
+
import { emitData } from "../io.ts";
|
|
5
|
+
import { getGlobalOpts } from "../program.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Folder IDs are the only handle `--folder` used to accept, and nothing else in
|
|
9
|
+
* bwx ever printed one — so this is what makes that flag usable at all.
|
|
10
|
+
*/
|
|
11
|
+
export function registerFolders(program: Command): void {
|
|
12
|
+
program
|
|
13
|
+
.command("folders")
|
|
14
|
+
.alias("folder")
|
|
15
|
+
.description("List vault folders")
|
|
16
|
+
.action(async function (this: Command) {
|
|
17
|
+
const opts = getGlobalOpts(this);
|
|
18
|
+
const folders = await listFolders(opts);
|
|
19
|
+
|
|
20
|
+
if (opts.json) {
|
|
21
|
+
emitData(folders, opts);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const folder of folders) {
|
|
26
|
+
const id = folder.id ?? "-";
|
|
27
|
+
process.stdout.write(
|
|
28
|
+
opts.plain
|
|
29
|
+
? `${id}\t${folder.name}\n`
|
|
30
|
+
: `${pc.dim(id)} ${folder.name}\n`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -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
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
|
-
import { emitData } from "../io.ts";
|
|
4
|
-
import { readItemField } from "../../bw/fields.ts";
|
|
3
|
+
import { emitData, emitLog } from "../io.ts";
|
|
4
|
+
import { readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
|
|
5
|
+
import { CliError, ExitCode } from "../errors.ts";
|
|
6
|
+
import type { GlobalOptions } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
/** Seconds left below which `--fresh` waits, when the flag is given bare. */
|
|
9
|
+
const DEFAULT_FRESH_SECONDS = 5;
|
|
5
10
|
|
|
6
11
|
export function registerGet(program: Command): void {
|
|
7
12
|
program
|
|
@@ -9,8 +14,26 @@ export function registerGet(program: Command): void {
|
|
|
9
14
|
.description("Get a field from a vault item (built-in or custom)")
|
|
10
15
|
.argument("<field>", "Field: password | username | totp | notes | uri | item | <custom>")
|
|
11
16
|
.argument("<item>", "Item name or ID")
|
|
12
|
-
.
|
|
17
|
+
.option(
|
|
18
|
+
"--fresh [seconds]",
|
|
19
|
+
`Wait for the next TOTP window if fewer than N seconds remain (default ${DEFAULT_FRESH_SECONDS})`,
|
|
20
|
+
)
|
|
21
|
+
.option("--seed", "Emit the stored TOTP secret instead of a code")
|
|
22
|
+
.action(async function (
|
|
23
|
+
this: Command,
|
|
24
|
+
field: string,
|
|
25
|
+
item: string,
|
|
26
|
+
localOpts: { fresh?: string | boolean; seed?: boolean },
|
|
27
|
+
) {
|
|
13
28
|
const opts = getGlobalOpts(this);
|
|
29
|
+
|
|
30
|
+
if (field === "totp") {
|
|
31
|
+
await emitTotp(item, localOpts, opts);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
assertTotpOnlyFlags(field, localOpts);
|
|
36
|
+
|
|
14
37
|
const value = await readItemField(field, item, opts);
|
|
15
38
|
// `item` is the one field that is itself JSON — emit it as structure.
|
|
16
39
|
emitData(field === "item" ? JSON.parse(value) : value, opts);
|
|
@@ -18,11 +41,111 @@ export function registerGet(program: Command): void {
|
|
|
18
41
|
|
|
19
42
|
program
|
|
20
43
|
.command("field")
|
|
21
|
-
.description("Get a custom field from a vault item")
|
|
22
|
-
|
|
44
|
+
.description("Get a custom field from a vault item (never a built-in)")
|
|
45
|
+
// Same order as `get`, which is the whole point of this command existing
|
|
46
|
+
// alongside it — the two used to take their arguments back to front.
|
|
23
47
|
.argument("<name>", "Custom field name")
|
|
24
|
-
.
|
|
48
|
+
.argument("<item>", "Item name or ID")
|
|
49
|
+
.action(async function (this: Command, name: string, item: string) {
|
|
25
50
|
const opts = getGlobalOpts(this);
|
|
26
|
-
|
|
51
|
+
try {
|
|
52
|
+
emitData(
|
|
53
|
+
await readItemField(name, item, opts, { customOnly: true }),
|
|
54
|
+
opts,
|
|
55
|
+
);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw withOrderHint(err);
|
|
58
|
+
}
|
|
27
59
|
});
|
|
28
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Emits a TOTP alongside how long it stays valid. The remainder rides in the
|
|
64
|
+
* `--json` envelope's `meta` rather than replacing `data`, so `data` is still
|
|
65
|
+
* the plain code every other field returns and existing parsers keep working.
|
|
66
|
+
*/
|
|
67
|
+
async function emitTotp(
|
|
68
|
+
item: string,
|
|
69
|
+
localOpts: { fresh?: string | boolean; seed?: boolean },
|
|
70
|
+
opts: GlobalOptions,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
if (localOpts.seed) {
|
|
73
|
+
if (localOpts.fresh !== undefined) {
|
|
74
|
+
throw new CliError(
|
|
75
|
+
"--seed and --fresh cannot be combined: a stored secret has no window.",
|
|
76
|
+
ExitCode.BadArgs,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
emitData(await readTotpSecret(item, opts), opts);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const threshold = parseFreshSeconds(localOpts.fresh);
|
|
84
|
+
let totp = await readTotp(item, opts);
|
|
85
|
+
|
|
86
|
+
if (threshold !== null && totp.secondsRemaining < threshold) {
|
|
87
|
+
const wait = totp.secondsRemaining;
|
|
88
|
+
emitLog(`Waiting ${wait}s for a fresh code…`, opts);
|
|
89
|
+
await Bun.sleep(wait * 1000 + 250);
|
|
90
|
+
totp = await readTotp(item, opts);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
emitLog(`Valid for ${totp.secondsRemaining}s`, opts);
|
|
94
|
+
emitData(totp.code, opts, {
|
|
95
|
+
secondsRemaining: totp.secondsRemaining,
|
|
96
|
+
period: totp.period,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `--fresh` is a bare flag by default and takes an explicit threshold when the
|
|
102
|
+
* caller needs longer than {@link DEFAULT_FRESH_SECONDS} to use the code.
|
|
103
|
+
*/
|
|
104
|
+
function parseFreshSeconds(raw: string | boolean | undefined): number | null {
|
|
105
|
+
if (raw === undefined || raw === false) return null;
|
|
106
|
+
if (raw === true) return DEFAULT_FRESH_SECONDS;
|
|
107
|
+
|
|
108
|
+
const value = Number(raw.trim());
|
|
109
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
110
|
+
throw new CliError(
|
|
111
|
+
`Invalid --fresh "${raw}". Use a non-negative integer of seconds.`,
|
|
112
|
+
ExitCode.BadArgs,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Accepting `--fresh` on a password would read as "give me a fresh password",
|
|
120
|
+
* which is not what it does. Rejecting it beats quietly ignoring it.
|
|
121
|
+
*/
|
|
122
|
+
function assertTotpOnlyFlags(
|
|
123
|
+
field: string,
|
|
124
|
+
localOpts: { fresh?: string | boolean; seed?: boolean },
|
|
125
|
+
): void {
|
|
126
|
+
const used = [
|
|
127
|
+
localOpts.fresh !== undefined ? "--fresh" : null,
|
|
128
|
+
localOpts.seed ? "--seed" : null,
|
|
129
|
+
].filter((flag): flag is string => flag !== null);
|
|
130
|
+
|
|
131
|
+
if (used.length > 0) {
|
|
132
|
+
throw new CliError(
|
|
133
|
+
`${used.join(", ")} ${used.length > 1 ? "apply" : "applies"} to 'get totp', not '${field}'.`,
|
|
134
|
+
ExitCode.BadArgs,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `bwx field` took `<item> <name>` until 0.5.0. A caller using the old order
|
|
141
|
+
* fails somewhere unhelpful — a missing item, or a missing field on the wrong
|
|
142
|
+
* item — so say what changed rather than leaving them to guess.
|
|
143
|
+
*/
|
|
144
|
+
function withOrderHint(err: unknown): unknown {
|
|
145
|
+
if (!(err instanceof CliError)) return err;
|
|
146
|
+
|
|
147
|
+
return new CliError(
|
|
148
|
+
`${err.message}\n\nNote: 'bwx field' takes <name> <item> as of 0.5.0 (it was <item> <name>).`,
|
|
149
|
+
err.exitCode,
|
|
150
|
+
);
|
|
151
|
+
}
|
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
|
+
}
|