bwx 0.3.0 → 0.4.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/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
- runBw,
6
- runBwOrThrow,
7
- setSession,
8
- getSession,
9
- isAuthError,
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
- const SESSION_PATH = `${process.env.HOME}/.config/bwx/session`;
16
-
17
- async function loadCachedSession(): Promise<string | null> {
18
- try {
19
- const token = await Bun.file(SESSION_PATH).text();
20
- return token.trim() || null;
21
- } catch {
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
- async function saveCachedSession(token: string): Promise<void> {
27
- const dir = SESSION_PATH.replace(/\/[^/]+$/, "");
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
- async function clearCachedSession(): Promise<void> {
31
+ function clearCachedSession(): void {
36
32
  try {
37
- const { unlinkSync } = await import("node:fs");
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() || (await loadCachedSession());
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
- "login",
119
- config.email,
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
- await saveCachedSession(token);
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
- await clearCachedSession();
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() || (await loadCachedSession());
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
- await clearCachedSession();
176
+ clearCachedSession();
182
177
  await ensureUnlocked(opts);
183
178
  return await fn();
184
179
  }
@@ -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 configDir =
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);
@@ -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 { CliError, ExitCode } from "../errors.ts";
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
- if (BUILTIN_FIELDS.includes(field)) {
22
- await getBuiltinField(field, item, opts);
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 getCustomField(name, item, opts);
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
  }
@@ -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; limit?: number },
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
- let items: Array<Record<string, unknown>> = JSON.parse(json);
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
- if (opts.json) {
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
  }
@@ -0,0 +1,138 @@
1
+ import type { Command } from "commander";
2
+ import { constants } from "node:os";
3
+ import { readItemField } from "../../bw/fields.ts";
4
+ import { CliError, ExitCode } from "../errors.ts";
5
+ import { collect } from "../input.ts";
6
+ import { emitLog } from "../io.ts";
7
+ import { getGlobalOpts } from "../program.ts";
8
+ import type { GlobalOptions } from "../types.ts";
9
+
10
+ /** `NAME=<field>:<item>` — one secret to place in the child's environment. */
11
+ export interface EnvSpec {
12
+ name: string;
13
+ field: string;
14
+ item: string;
15
+ }
16
+
17
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
+
19
+ const SPEC_HINT = "Expected --env NAME=<field>:<item>, e.g. --env DB_PASS=password:'Prod DB'";
20
+
21
+ export function registerRun(program: Command): void {
22
+ program
23
+ .command("run")
24
+ .description("Run a command with secrets injected into its environment")
25
+ .argument("<command...>", "Command to run — use -- before it if it takes its own flags")
26
+ .option(
27
+ "--env <spec>",
28
+ "Inject a secret as NAME=<field>:<item> (repeatable)",
29
+ collect,
30
+ [],
31
+ )
32
+ .action(async function (
33
+ this: Command,
34
+ command: string[],
35
+ localOpts: { env: string[] },
36
+ ) {
37
+ const opts = getGlobalOpts(this);
38
+ const specs = parseEnvSpecs(localOpts.env);
39
+
40
+ const secrets = await resolveSecrets(specs, opts);
41
+
42
+ // Only the names are ever logged; values exist solely in the child env.
43
+ if (opts.verbose) {
44
+ emitLog(`Injecting ${specs.map((s) => s.name).join(", ")}`, opts);
45
+ }
46
+
47
+ process.exit(await spawnChild(command, secrets));
48
+ });
49
+ }
50
+
51
+ /** Parses every `--env` flag, rejecting bad specs before any vault access. */
52
+ export function parseEnvSpecs(raws: string[]): EnvSpec[] {
53
+ if (raws.length === 0) {
54
+ throw new CliError(`No secrets requested. ${SPEC_HINT}`, ExitCode.BadArgs);
55
+ }
56
+
57
+ const specs = raws.map(parseEnvSpec);
58
+
59
+ const seen = new Set<string>();
60
+ for (const spec of specs) {
61
+ if (seen.has(spec.name)) {
62
+ throw new CliError(`Duplicate --env name "${spec.name}"`, ExitCode.BadArgs);
63
+ }
64
+ seen.add(spec.name);
65
+ }
66
+
67
+ return specs;
68
+ }
69
+
70
+ /**
71
+ * Splits `NAME=<field>:<item>` on the first `=` and then the first `:`, so item
72
+ * names may contain colons (`password:http://my.router`) while field names may not.
73
+ * Fields resolve exactly as in `bwx get`: built-ins first, otherwise custom fields.
74
+ */
75
+ export function parseEnvSpec(raw: string): EnvSpec {
76
+ const eq = raw.indexOf("=");
77
+ if (eq <= 0) {
78
+ throw new CliError(`Invalid --env "${raw}". ${SPEC_HINT}`, ExitCode.BadArgs);
79
+ }
80
+
81
+ const name = raw.slice(0, eq);
82
+ if (!ENV_NAME_RE.test(name)) {
83
+ throw new CliError(
84
+ `Invalid environment variable name "${name}". Use letters, digits, and underscores.`,
85
+ ExitCode.BadArgs,
86
+ );
87
+ }
88
+
89
+ const ref = raw.slice(eq + 1);
90
+ const colon = ref.indexOf(":");
91
+ if (colon <= 0 || colon === ref.length - 1) {
92
+ throw new CliError(`Invalid --env "${raw}". ${SPEC_HINT}`, ExitCode.BadArgs);
93
+ }
94
+
95
+ return { name, field: ref.slice(0, colon), item: ref.slice(colon + 1) };
96
+ }
97
+
98
+ /** Resolves secrets one at a time: each read spawns `bw`, and unlocks must not race. */
99
+ async function resolveSecrets(
100
+ specs: EnvSpec[],
101
+ opts: GlobalOptions,
102
+ ): Promise<Record<string, string>> {
103
+ const secrets: Record<string, string> = {};
104
+
105
+ for (const spec of specs) {
106
+ secrets[spec.name] = await readItemField(spec.field, spec.item, opts);
107
+ }
108
+
109
+ return secrets;
110
+ }
111
+
112
+ /**
113
+ * Runs the child with inherited stdio so it stays usable interactively, and
114
+ * mirrors its exit status (`128 + signal` when it was killed, as shells do).
115
+ */
116
+ async function spawnChild(
117
+ command: string[],
118
+ secrets: Record<string, string>,
119
+ ): Promise<number> {
120
+ let proc;
121
+ try {
122
+ proc = Bun.spawn(command, {
123
+ stdin: "inherit",
124
+ stdout: "inherit",
125
+ stderr: "inherit",
126
+ env: { ...process.env, ...secrets },
127
+ });
128
+ } catch (err) {
129
+ throw new CliError(
130
+ `Failed to run "${command[0]}": ${err instanceof Error ? err.message : String(err)}`,
131
+ ExitCode.BadArgs,
132
+ );
133
+ }
134
+
135
+ const exitCode = await proc.exited;
136
+ const signal = proc.signalCode;
137
+ return signal ? 128 + constants.signals[signal] : exitCode;
138
+ }
@@ -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 registerSearch(program: Command): void {
11
9
  program
@@ -15,10 +13,11 @@ export function registerSearch(program: Command): void {
15
13
  .option("--type <type>", "Filter by type: login|note|card|identity")
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
  query: string,
21
- localOpts: { type?: string; folder?: string; limit?: number },
20
+ localOpts: ItemListOptions & { folder?: string },
22
21
  ) {
23
22
  const opts = getGlobalOpts(this);
24
23
 
@@ -28,24 +27,7 @@ export function registerSearch(program: Command): void {
28
27
  }
29
28
 
30
29
  const json = await withSession(opts, () => runBwOrThrow(args));
31
- let items: Array<Record<string, unknown>> = JSON.parse(json);
32
-
33
- // Filter by type
34
- if (localOpts.type) {
35
- const typeVal = BW_TYPE_FROM_NAME[localOpts.type];
36
- if (typeVal === undefined) {
37
- throw new CliError(
38
- `Invalid type "${localOpts.type}". Valid: login, note, card, identity`,
39
- ExitCode.BadArgs,
40
- );
41
- }
42
- items = items.filter((i) => i.type === typeVal);
43
- }
44
-
45
- // Limit
46
- if (localOpts.limit && localOpts.limit > 0) {
47
- items = items.slice(0, localOpts.limit);
48
- }
30
+ const items = filterItems(JSON.parse(json), localOpts);
49
31
 
50
32
  if (items.length === 0) {
51
33
  throw new CliError(
@@ -54,32 +36,6 @@ export function registerSearch(program: Command): void {
54
36
  );
55
37
  }
56
38
 
57
- if (opts.json) {
58
- emitData(items, opts);
59
- return;
60
- }
61
-
62
- if (opts.plain) {
63
- for (const item of items) {
64
- const type = BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`;
65
- const login = item.login as Record<string, unknown> | null;
66
- const username = login?.username ?? "-";
67
- process.stdout.write(
68
- `${item.id}\t${type}\t${item.name}\t${username}\n`,
69
- );
70
- }
71
- return;
72
- }
73
-
74
- // Human-friendly table
75
- for (const item of items) {
76
- const type = BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`;
77
- const login = item.login as Record<string, unknown> | null;
78
- const username = login?.username ?? "";
79
- const userPart = username ? pc.dim(` (${username})`) : "";
80
- process.stdout.write(
81
- `${pc.dim(item.id as string)} ${pc.cyan(type)} ${item.name}${userPart}\n`,
82
- );
83
- }
39
+ writeItems(items, opts, localOpts);
84
40
  });
85
41
  }