bwx 0.3.0 → 0.4.1
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 +49 -3
- package/package.json +3 -2
- package/skill/bwx/SKILL.md +149 -0
- package/src/bw/fields.ts +133 -0
- package/src/bw/freshness.ts +161 -0
- package/src/bw/items.ts +25 -0
- package/src/bw/runner.ts +109 -13
- package/src/bw/session.ts +57 -62
- package/src/bw/status.ts +19 -0
- package/src/cli/commands/doctor.ts +2 -3
- package/src/cli/commands/get.ts +5 -129
- package/src/cli/commands/list.ts +5 -48
- package/src/cli/commands/run.ts +138 -0
- package/src/cli/commands/search.ts +5 -49
- package/src/cli/commands/status.ts +4 -2
- package/src/cli/commands/sync.ts +6 -1
- package/src/cli/duration.ts +34 -0
- package/src/cli/io.ts +7 -1
- package/src/cli/items.ts +78 -0
- package/src/cli/program.ts +32 -1
- package/src/cli/types.ts +7 -4
- package/src/config/paths.ts +12 -0
- package/src/config/secure-file.ts +83 -0
- package/src/config/store.ts +6 -10
package/src/bw/runner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError, ExitCode, classifyBwError } from "../cli/errors.ts";
|
|
2
|
+
import { formatDuration } from "../cli/duration.ts";
|
|
2
3
|
|
|
3
4
|
export interface BwResult {
|
|
4
5
|
stdout: string;
|
|
@@ -6,7 +7,20 @@ export interface BwResult {
|
|
|
6
7
|
exitCode: number;
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
export interface RunBwOptions {
|
|
11
|
+
/** Explicit session, or `null` to run without one (login/unlock/status). */
|
|
12
|
+
session?: string | null;
|
|
13
|
+
env?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Deadline for a single `bw` call; overridable with `--timeout`. */
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
/** Grace period between SIGTERM and SIGKILL for a `bw` process past its deadline. */
|
|
20
|
+
const KILL_GRACE_MS = 2_000;
|
|
21
|
+
|
|
9
22
|
let sessionToken: string | null = null;
|
|
23
|
+
let timeoutMs: number = DEFAULT_TIMEOUT_MS;
|
|
10
24
|
|
|
11
25
|
export function setSession(token: string | null): void {
|
|
12
26
|
sessionToken = token;
|
|
@@ -16,33 +30,51 @@ export function getSession(): string | null {
|
|
|
16
30
|
return sessionToken;
|
|
17
31
|
}
|
|
18
32
|
|
|
33
|
+
/** Applies the `--timeout` value for the rest of the process. */
|
|
34
|
+
export function setBwTimeout(ms: number): void {
|
|
35
|
+
timeoutMs = ms;
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
export async function runBw(
|
|
20
39
|
args: string[],
|
|
21
|
-
options?:
|
|
40
|
+
options?: RunBwOptions,
|
|
22
41
|
): Promise<BwResult> {
|
|
23
42
|
const session = options?.session ?? sessionToken;
|
|
24
|
-
const fullArgs = session ? [...args, "--session", session] : args;
|
|
25
43
|
|
|
26
|
-
const proc = Bun.spawn(["bw", ...
|
|
44
|
+
const proc = Bun.spawn(["bw", ...args], {
|
|
27
45
|
stdout: "pipe",
|
|
28
46
|
stderr: "pipe",
|
|
29
47
|
stdin: "ignore",
|
|
30
|
-
|
|
48
|
+
// The session goes in the environment, never in argv: command lines are
|
|
49
|
+
// readable by any same-user process. Nothing here is ever logged.
|
|
50
|
+
env: buildEnv(session, options?.env),
|
|
31
51
|
});
|
|
32
52
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
53
|
+
const guard = timeoutMs > 0 ? armDeadline(proc, timeoutMs) : null;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const [stdout, stderr] = await Promise.all([
|
|
57
|
+
new Response(proc.stdout).text(),
|
|
58
|
+
new Response(proc.stderr).text(),
|
|
59
|
+
]);
|
|
60
|
+
const exitCode = await proc.exited;
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
if (guard?.expired) {
|
|
63
|
+
throw new CliError(
|
|
64
|
+
`bw ${args[0] ?? ""} timed out after ${formatDuration(timeoutMs)}`.trim(),
|
|
65
|
+
ExitCode.Timeout,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
39
68
|
|
|
40
|
-
|
|
69
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
|
70
|
+
} finally {
|
|
71
|
+
guard?.disarm();
|
|
72
|
+
}
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
export async function runBwOrThrow(
|
|
44
76
|
args: string[],
|
|
45
|
-
options?:
|
|
77
|
+
options?: RunBwOptions,
|
|
46
78
|
): Promise<string> {
|
|
47
79
|
const result = await runBw(args, options);
|
|
48
80
|
if (result.exitCode !== 0) {
|
|
@@ -55,6 +87,70 @@ export async function runBwOrThrow(
|
|
|
55
87
|
return result.stdout;
|
|
56
88
|
}
|
|
57
89
|
|
|
58
|
-
|
|
59
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Child environment for `bw`. `BW_SESSION` is set from our session, or removed
|
|
92
|
+
* outright so an ambient one from the caller's shell cannot silently substitute
|
|
93
|
+
* for it (`status`, `login`, and `unlock` rely on seeing the real vault state).
|
|
94
|
+
*
|
|
95
|
+
* `BW_NOINTERACTION` is mandatory: bwx runs `bw` with stdin ignored, so a prompt
|
|
96
|
+
* can only ever produce a hang or garbled output — a locked vault must fail
|
|
97
|
+
* loudly instead, which is what lets callers auto-unlock and retry.
|
|
98
|
+
*/
|
|
99
|
+
function buildEnv(
|
|
100
|
+
session: string | null,
|
|
101
|
+
extra?: Record<string, string>,
|
|
102
|
+
): Record<string, string | undefined> {
|
|
103
|
+
const env: Record<string, string | undefined> = {
|
|
104
|
+
...process.env,
|
|
105
|
+
BW_NOINTERACTION: "true",
|
|
106
|
+
...extra,
|
|
107
|
+
};
|
|
108
|
+
if (session) {
|
|
109
|
+
env.BW_SESSION = session;
|
|
110
|
+
} else {
|
|
111
|
+
delete env.BW_SESSION;
|
|
112
|
+
}
|
|
113
|
+
return env;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface Deadline {
|
|
117
|
+
expired: boolean;
|
|
118
|
+
disarm(): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Kills `bw` once the deadline passes so a wedged vault operation (stuck Keychain
|
|
123
|
+
* prompt, unreachable self-hosted server) cannot block the caller forever.
|
|
124
|
+
*/
|
|
125
|
+
function armDeadline(proc: Bun.Subprocess, ms: number): Deadline {
|
|
126
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
127
|
+
const deadline: Deadline = {
|
|
128
|
+
expired: false,
|
|
129
|
+
disarm: () => timers.forEach(clearTimeout),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const arm = (delay: number, fn: () => void) => {
|
|
133
|
+
const timer = setTimeout(fn, delay);
|
|
134
|
+
timer.unref?.();
|
|
135
|
+
timers.push(timer);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// A timer can still fire in the moment between exit and disarm, where the
|
|
139
|
+
// signal has nowhere to go.
|
|
140
|
+
const kill = (signal: NodeJS.Signals) => {
|
|
141
|
+
try {
|
|
142
|
+
proc.kill(signal);
|
|
143
|
+
} catch {
|
|
144
|
+
// Already gone.
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
arm(ms, () => {
|
|
149
|
+
deadline.expired = true;
|
|
150
|
+
kill("SIGTERM");
|
|
151
|
+
// Escalate if bw ignores SIGTERM.
|
|
152
|
+
arm(KILL_GRACE_MS, () => kill("SIGKILL"));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return deadline;
|
|
60
156
|
}
|
package/src/bw/session.ts
CHANGED
|
@@ -1,58 +1,41 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
1
2
|
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
2
3
|
import type { GlobalOptions } from "../cli/types.ts";
|
|
3
|
-
import { emitLog } from "../cli/io.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
type BwResult,
|
|
11
|
-
} from "./runner.ts";
|
|
12
|
-
import { BwStatusSchema, type BwStatus } from "./types.ts";
|
|
4
|
+
import { emitLog, emitWarn } from "../cli/io.ts";
|
|
5
|
+
import { SESSION_FILE } from "../config/paths.ts";
|
|
6
|
+
import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
|
|
7
|
+
import { runBw, runBwOrThrow, setSession, getSession } from "./runner.ts";
|
|
8
|
+
import { checkVaultFreshness } from "./freshness.ts";
|
|
9
|
+
import { getStatus } from "./status.ts";
|
|
10
|
+
import { BwStatusSchema } from "./types.ts";
|
|
13
11
|
import { loadConfig } from "../config/store.ts";
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Loads the cached session, ignoring a cache file that another user could have
|
|
15
|
+
* planted or read. Rejection is not fatal: the caller just unlocks again.
|
|
16
|
+
*/
|
|
17
|
+
function loadCachedSession(opts: GlobalOptions): string | null {
|
|
18
|
+
const read = readPrivateFile(SESSION_FILE);
|
|
19
|
+
if (read.kind === "rejected") {
|
|
20
|
+
emitWarn(`Ignoring cached session: ${read.reason}`, opts);
|
|
22
21
|
return null;
|
|
23
22
|
}
|
|
23
|
+
if (read.kind === "missing") return null;
|
|
24
|
+
return read.text.trim() || null;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const { mkdirSync } = await import("node:fs");
|
|
29
|
-
mkdirSync(dir, { recursive: true });
|
|
30
|
-
await Bun.write(SESSION_PATH, token);
|
|
31
|
-
const proc = Bun.spawn(["chmod", "600", SESSION_PATH]);
|
|
32
|
-
await proc.exited;
|
|
27
|
+
function saveCachedSession(token: string): void {
|
|
28
|
+
writePrivateFile(SESSION_FILE, token);
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
|
|
31
|
+
function clearCachedSession(): void {
|
|
36
32
|
try {
|
|
37
|
-
|
|
38
|
-
unlinkSync(SESSION_PATH);
|
|
33
|
+
unlinkSync(SESSION_FILE);
|
|
39
34
|
} catch {
|
|
40
35
|
// Already gone
|
|
41
36
|
}
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
export async function getStatus(): Promise<BwStatus> {
|
|
45
|
-
const result = await runBw(["status"], { session: null });
|
|
46
|
-
try {
|
|
47
|
-
return BwStatusSchema.parse(JSON.parse(result.stdout));
|
|
48
|
-
} catch {
|
|
49
|
-
throw new CliError(
|
|
50
|
-
`Failed to parse bw status: ${result.stdout}`,
|
|
51
|
-
ExitCode.BwError,
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
39
|
async function getMasterPassword(): Promise<string> {
|
|
57
40
|
const proc = Bun.spawn(
|
|
58
41
|
[
|
|
@@ -77,11 +60,9 @@ async function getMasterPassword(): Promise<string> {
|
|
|
77
60
|
return pw;
|
|
78
61
|
}
|
|
79
62
|
|
|
80
|
-
export async function ensureUnlocked(
|
|
81
|
-
opts: GlobalOptions,
|
|
82
|
-
): Promise<void> {
|
|
63
|
+
export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
|
|
83
64
|
// Try cached session first
|
|
84
|
-
const cached = getSession() ||
|
|
65
|
+
const cached = getSession() || loadCachedSession(opts);
|
|
85
66
|
if (cached) {
|
|
86
67
|
setSession(cached);
|
|
87
68
|
// Quick check — try a lightweight command
|
|
@@ -114,17 +95,9 @@ export async function ensureUnlocked(
|
|
|
114
95
|
const pw = await getMasterPassword();
|
|
115
96
|
emitLog("Logging in...", opts);
|
|
116
97
|
const loginResult = await runBw(
|
|
117
|
-
[
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"--passwordenv",
|
|
121
|
-
"BW_MASTER_PW",
|
|
122
|
-
"--quiet",
|
|
123
|
-
],
|
|
124
|
-
{
|
|
125
|
-
session: null,
|
|
126
|
-
env: { BW_MASTER_PW: pw, BW_NOINTERACTION: "true" },
|
|
127
|
-
},
|
|
98
|
+
["login", config.email, "--passwordenv", "BW_MASTER_PW", "--quiet"],
|
|
99
|
+
// BW_NOINTERACTION is set for every bw call by the runner.
|
|
100
|
+
{ session: null, env: { BW_MASTER_PW: pw } },
|
|
128
101
|
);
|
|
129
102
|
if (loginResult.exitCode !== 0) {
|
|
130
103
|
throw new CliError(
|
|
@@ -150,35 +123,57 @@ export async function ensureUnlocked(
|
|
|
150
123
|
|
|
151
124
|
const token = unlockResult.stdout;
|
|
152
125
|
setSession(token);
|
|
153
|
-
|
|
126
|
+
saveCachedSession(token);
|
|
154
127
|
}
|
|
155
128
|
|
|
156
129
|
export async function lockVault(): Promise<void> {
|
|
157
130
|
await runBw(["lock"], { session: null });
|
|
158
131
|
setSession(null);
|
|
159
|
-
|
|
132
|
+
clearCachedSession();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface SessionOptions {
|
|
136
|
+
/**
|
|
137
|
+
* Skips the stale-vault check. Set by operations that own vault freshness
|
|
138
|
+
* themselves (`sync`) to avoid recursion.
|
|
139
|
+
*/
|
|
140
|
+
skipFreshnessCheck?: boolean;
|
|
160
141
|
}
|
|
161
142
|
|
|
162
143
|
export async function withSession<T>(
|
|
163
144
|
opts: GlobalOptions,
|
|
164
145
|
fn: () => Promise<T>,
|
|
146
|
+
options?: SessionOptions,
|
|
165
147
|
): Promise<T> {
|
|
166
148
|
// Try with current/cached session
|
|
167
|
-
const cached = getSession() ||
|
|
149
|
+
const cached = getSession() || loadCachedSession(opts);
|
|
168
150
|
if (cached) {
|
|
169
151
|
setSession(cached);
|
|
170
152
|
}
|
|
171
153
|
|
|
154
|
+
if (!options?.skipFreshnessCheck) {
|
|
155
|
+
await checkVaultFreshness(opts, () => syncVault(opts));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return withAuthRetry(opts, fn);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function syncVault(opts: GlobalOptions): Promise<void> {
|
|
162
|
+
await withAuthRetry(opts, () => runBwOrThrow(["sync"]));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Runs `fn`, re-authenticating once if the cached session turned out to be stale. */
|
|
166
|
+
async function withAuthRetry<T>(
|
|
167
|
+
opts: GlobalOptions,
|
|
168
|
+
fn: () => Promise<T>,
|
|
169
|
+
): Promise<T> {
|
|
172
170
|
try {
|
|
173
171
|
return await fn();
|
|
174
172
|
} catch (err) {
|
|
175
|
-
if (
|
|
176
|
-
err instanceof CliError &&
|
|
177
|
-
err.exitCode === ExitCode.AuthFailed
|
|
178
|
-
) {
|
|
173
|
+
if (err instanceof CliError && err.exitCode === ExitCode.AuthFailed) {
|
|
179
174
|
// Clear stale session and re-auth
|
|
180
175
|
setSession(null);
|
|
181
|
-
|
|
176
|
+
clearCachedSession();
|
|
182
177
|
await ensureUnlocked(opts);
|
|
183
178
|
return await fn();
|
|
184
179
|
}
|
package/src/bw/status.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
2
|
+
import { runBw } from "./runner.ts";
|
|
3
|
+
import { BwStatusSchema, type BwStatus } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads the vault state. Deliberately runs without a session so the result
|
|
7
|
+
* reflects the vault itself rather than our cached unlock.
|
|
8
|
+
*/
|
|
9
|
+
export async function getStatus(): Promise<BwStatus> {
|
|
10
|
+
const result = await runBw(["status"], { session: null });
|
|
11
|
+
try {
|
|
12
|
+
return BwStatusSchema.parse(JSON.parse(result.stdout));
|
|
13
|
+
} catch {
|
|
14
|
+
throw new CliError(
|
|
15
|
+
`Failed to parse bw status: ${result.stdout}`,
|
|
16
|
+
ExitCode.BwError,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -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 { loadConfig } from "../../config/store.ts";
|
|
5
|
+
import { CONFIG_DIR } from "../../config/paths.ts";
|
|
5
6
|
import pc from "picocolors";
|
|
6
7
|
|
|
7
8
|
interface Check {
|
|
@@ -140,9 +141,7 @@ async function checkMasterPassword(): Promise<Check> {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
async function checkConfigDir(): Promise<Check> {
|
|
143
|
-
const
|
|
144
|
-
process.env.XDG_CONFIG_HOME ?? `${process.env.HOME}/.config`;
|
|
145
|
-
const dir = `${configDir}/bwx`;
|
|
144
|
+
const dir = CONFIG_DIR;
|
|
146
145
|
try {
|
|
147
146
|
const { accessSync, constants } = await import("node:fs");
|
|
148
147
|
accessSync(dir, constants.W_OK);
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
3
|
import { emitData } from "../io.ts";
|
|
4
|
-
import {
|
|
5
|
-
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
|
-
import { withSession } from "../../bw/session.ts";
|
|
7
|
-
import { BW_TYPE_LABELS, BwItemSchema } from "../../bw/types.ts";
|
|
8
|
-
import pc from "picocolors";
|
|
9
|
-
|
|
10
|
-
const BUILTIN_FIELDS = ["password", "username", "totp", "notes", "uri", "item"];
|
|
4
|
+
import { readItemField } from "../../bw/fields.ts";
|
|
11
5
|
|
|
12
6
|
export function registerGet(program: Command): void {
|
|
13
7
|
program
|
|
@@ -17,12 +11,9 @@ export function registerGet(program: Command): void {
|
|
|
17
11
|
.argument("<item>", "Item name or ID")
|
|
18
12
|
.action(async function (this: Command, field: string, item: string) {
|
|
19
13
|
const opts = getGlobalOpts(this);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} else {
|
|
24
|
-
await getCustomField(field, item, opts);
|
|
25
|
-
}
|
|
14
|
+
const value = await readItemField(field, item, opts);
|
|
15
|
+
// `item` is the one field that is itself JSON — emit it as structure.
|
|
16
|
+
emitData(field === "item" ? JSON.parse(value) : value, opts);
|
|
26
17
|
});
|
|
27
18
|
|
|
28
19
|
program
|
|
@@ -32,121 +23,6 @@ export function registerGet(program: Command): void {
|
|
|
32
23
|
.argument("<name>", "Custom field name")
|
|
33
24
|
.action(async function (this: Command, item: string, name: string) {
|
|
34
25
|
const opts = getGlobalOpts(this);
|
|
35
|
-
await
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function getBuiltinField(
|
|
40
|
-
field: string,
|
|
41
|
-
item: string,
|
|
42
|
-
opts: ReturnType<typeof getGlobalOpts>,
|
|
43
|
-
): Promise<void> {
|
|
44
|
-
try {
|
|
45
|
-
const result = await withSession(opts, async () => {
|
|
46
|
-
if (field === "item") {
|
|
47
|
-
return runBwOrThrow(["get", "item", item]);
|
|
48
|
-
}
|
|
49
|
-
return runBwOrThrow(["get", field, item]);
|
|
26
|
+
emitData(await readItemField(name, item, opts, { customOnly: true }), opts);
|
|
50
27
|
});
|
|
51
|
-
|
|
52
|
-
if (!result) {
|
|
53
|
-
throw new CliError(
|
|
54
|
-
`No ${field} found for item: ${item}`,
|
|
55
|
-
ExitCode.NotFound,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (field === "item") {
|
|
60
|
-
emitData(JSON.parse(result), opts);
|
|
61
|
-
} else {
|
|
62
|
-
emitData(result, opts);
|
|
63
|
-
}
|
|
64
|
-
} catch (err) {
|
|
65
|
-
if (
|
|
66
|
-
err instanceof CliError &&
|
|
67
|
-
/more than one result/i.test(err.message)
|
|
68
|
-
) {
|
|
69
|
-
const matches = await resolveAmbiguous(item);
|
|
70
|
-
throw new CliError(
|
|
71
|
-
formatAmbiguousError(item, matches, opts),
|
|
72
|
-
ExitCode.BadArgs,
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
throw err;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function getCustomField(
|
|
80
|
-
fieldName: string,
|
|
81
|
-
item: string,
|
|
82
|
-
opts: ReturnType<typeof getGlobalOpts>,
|
|
83
|
-
): Promise<void> {
|
|
84
|
-
const json = await withSession(opts, () =>
|
|
85
|
-
runBwOrThrow(["get", "item", item]),
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
const parsed = BwItemSchema.parse(JSON.parse(json));
|
|
89
|
-
const fields = parsed.fields ?? [];
|
|
90
|
-
const match = fields.find((f) => f.name === fieldName);
|
|
91
|
-
|
|
92
|
-
if (!match) {
|
|
93
|
-
const available = fields.map((f) => f.name).join(", ");
|
|
94
|
-
const msg = available
|
|
95
|
-
? `Field "${fieldName}" not found. Available: ${available}`
|
|
96
|
-
: `Field "${fieldName}" not found (item has no custom fields)`;
|
|
97
|
-
throw new CliError(msg, ExitCode.NotFound);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
emitData(match.value ?? "", opts);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
interface MatchInfo {
|
|
104
|
-
id: string;
|
|
105
|
-
name: string;
|
|
106
|
-
type: string;
|
|
107
|
-
username?: string;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function resolveAmbiguous(query: string): Promise<MatchInfo[]> {
|
|
111
|
-
try {
|
|
112
|
-
const json = await runBwOrThrow([
|
|
113
|
-
"list",
|
|
114
|
-
"items",
|
|
115
|
-
"--search",
|
|
116
|
-
query,
|
|
117
|
-
]);
|
|
118
|
-
const items: Array<Record<string, unknown>> = JSON.parse(json);
|
|
119
|
-
return items.map((i) => ({
|
|
120
|
-
id: i.id as string,
|
|
121
|
-
name: i.name as string,
|
|
122
|
-
type: BW_TYPE_LABELS[i.type as number] ?? `type:${i.type}`,
|
|
123
|
-
username: (i.login as Record<string, unknown> | null)?.username as
|
|
124
|
-
| string
|
|
125
|
-
| undefined,
|
|
126
|
-
}));
|
|
127
|
-
} catch {
|
|
128
|
-
return [];
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function formatAmbiguousError(
|
|
133
|
-
query: string,
|
|
134
|
-
matches: MatchInfo[],
|
|
135
|
-
opts: { json?: boolean },
|
|
136
|
-
): string {
|
|
137
|
-
if (matches.length === 0) {
|
|
138
|
-
return `Multiple items match "${query}". Use a specific ID instead.`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (opts.json) {
|
|
142
|
-
return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const lines = [`Multiple items match "${query}":\n`];
|
|
146
|
-
for (const m of matches) {
|
|
147
|
-
const user = m.username ? ` (${m.username})` : "";
|
|
148
|
-
lines.push(` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`);
|
|
149
|
-
}
|
|
150
|
-
lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
|
|
151
|
-
return lines.join("\n");
|
|
152
28
|
}
|
package/src/cli/commands/list.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { getGlobalOpts } from "../program.ts";
|
|
3
|
-
import { emitData } from "../io.ts";
|
|
4
3
|
import { CliError, ExitCode } from "../errors.ts";
|
|
4
|
+
import { filterItems, writeItems, type ItemListOptions } from "../items.ts";
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
|
-
import { BW_TYPE_LABELS, BW_TYPE_FROM_NAME } from "../../bw/types.ts";
|
|
8
|
-
import pc from "picocolors";
|
|
9
7
|
|
|
10
8
|
export function registerList(program: Command): void {
|
|
11
9
|
program
|
|
@@ -15,10 +13,11 @@ export function registerList(program: Command): void {
|
|
|
15
13
|
.argument("[type]", "Filter by type: logins|notes|cards|identities")
|
|
16
14
|
.option("--folder <id>", "Filter by folder ID")
|
|
17
15
|
.option("--limit <n>", "Limit results", parseInt)
|
|
16
|
+
.option("--full-items", "Emit complete item objects (includes secrets)")
|
|
18
17
|
.action(async function (
|
|
19
18
|
this: Command,
|
|
20
19
|
type: string | undefined,
|
|
21
|
-
localOpts: { folder?: string
|
|
20
|
+
localOpts: ItemListOptions & { folder?: string },
|
|
22
21
|
) {
|
|
23
22
|
const opts = getGlobalOpts(this);
|
|
24
23
|
|
|
@@ -28,54 +27,12 @@ export function registerList(program: Command): void {
|
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
const json = await withSession(opts, () => runBwOrThrow(args));
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (type) {
|
|
34
|
-
const typeVal = BW_TYPE_FROM_NAME[type];
|
|
35
|
-
if (typeVal === undefined) {
|
|
36
|
-
throw new CliError(
|
|
37
|
-
`Invalid type "${type}". Valid: logins, notes, cards, identities`,
|
|
38
|
-
ExitCode.BadArgs,
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
items = items.filter((i) => i.type === typeVal);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (localOpts.limit && localOpts.limit > 0) {
|
|
45
|
-
items = items.slice(0, localOpts.limit);
|
|
46
|
-
}
|
|
30
|
+
const items = filterItems(JSON.parse(json), { ...localOpts, type });
|
|
47
31
|
|
|
48
32
|
if (items.length === 0) {
|
|
49
33
|
throw new CliError("No items found", ExitCode.NotFound);
|
|
50
34
|
}
|
|
51
35
|
|
|
52
|
-
|
|
53
|
-
emitData(items, opts);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (opts.plain) {
|
|
58
|
-
for (const item of items) {
|
|
59
|
-
const t =
|
|
60
|
-
BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`;
|
|
61
|
-
const login = item.login as Record<string, unknown> | null;
|
|
62
|
-
const username = login?.username ?? "-";
|
|
63
|
-
process.stdout.write(
|
|
64
|
-
`${item.id}\t${t}\t${item.name}\t${username}\n`,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
for (const item of items) {
|
|
71
|
-
const t =
|
|
72
|
-
BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`;
|
|
73
|
-
const login = item.login as Record<string, unknown> | null;
|
|
74
|
-
const username = login?.username ?? "";
|
|
75
|
-
const userPart = username ? pc.dim(` (${username})`) : "";
|
|
76
|
-
process.stdout.write(
|
|
77
|
-
`${pc.dim(item.id as string)} ${pc.cyan(t)} ${item.name}${userPart}\n`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
36
|
+
writeItems(items, opts, localOpts);
|
|
80
37
|
});
|
|
81
38
|
}
|