bwx 0.2.2 → 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.
@@ -0,0 +1,248 @@
1
+ import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
2
+ import { CliError, ExitCode } from "./errors.ts";
3
+
4
+ export function collect(val: string, prev: string[]): string[] {
5
+ prev.push(val);
6
+ return prev;
7
+ }
8
+
9
+ /**
10
+ * Lazily reads stdin once. Commands validate that only one explicit stdin source
11
+ * is selected, but caching also keeps implicit legacy stdin reads predictable.
12
+ */
13
+ export class StdinReader {
14
+ private textPromise: Promise<string> | null = null;
15
+
16
+ read(): Promise<string> {
17
+ this.textPromise ??= readStdinText();
18
+ return this.textPromise;
19
+ }
20
+ }
21
+
22
+ export interface TextSource {
23
+ label: string;
24
+ inline?: string;
25
+ stdin?: boolean;
26
+ file?: string;
27
+ env?: string;
28
+ flagNames: string[];
29
+ allowEmpty?: boolean;
30
+ }
31
+
32
+ export interface FieldSources {
33
+ inline: string[];
34
+ file: string[];
35
+ env: string[];
36
+ fileFlag: string;
37
+ envFlag: string;
38
+ stdin: StdinReader;
39
+ }
40
+
41
+ export interface ItemTextInputs {
42
+ notes?: string;
43
+ notesFile?: string;
44
+ password?: string;
45
+ passwordStdin?: boolean;
46
+ passwordFile?: string;
47
+ passwordEnv?: string;
48
+ }
49
+
50
+ export interface ResolvedItemTextInputs {
51
+ notes?: string;
52
+ password?: string;
53
+ }
54
+
55
+ export interface ExplicitStdinInputs extends ItemTextInputs {
56
+ fieldFileFlag?: string;
57
+ fieldFiles?: string[];
58
+ }
59
+
60
+ const NOTE_SOURCE_FLAGS = ["--notes", "--notes-file"];
61
+ const PASSWORD_SOURCE_FLAGS = [
62
+ "--password",
63
+ "--password-stdin",
64
+ "--password-file",
65
+ "--password-env",
66
+ ];
67
+
68
+ export async function resolveItemTextInputs(
69
+ inputs: ItemTextInputs,
70
+ stdin: StdinReader,
71
+ ): Promise<ResolvedItemTextInputs> {
72
+ assertSingleStdinSource(listExplicitStdinInputs(inputs));
73
+
74
+ const [notes, password] = await Promise.all([
75
+ resolveOptionalTextSource(
76
+ {
77
+ label: "notes",
78
+ inline: inputs.notes,
79
+ file: inputs.notesFile,
80
+ flagNames: NOTE_SOURCE_FLAGS,
81
+ allowEmpty: true,
82
+ },
83
+ stdin,
84
+ ),
85
+ resolveOptionalTextSource(
86
+ {
87
+ label: "password",
88
+ inline: inputs.password,
89
+ stdin: inputs.passwordStdin,
90
+ file: inputs.passwordFile,
91
+ env: inputs.passwordEnv,
92
+ flagNames: PASSWORD_SOURCE_FLAGS,
93
+ },
94
+ stdin,
95
+ ),
96
+ ]);
97
+
98
+ return { notes, password };
99
+ }
100
+
101
+ export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
102
+ return [
103
+ inputs.notesFile === "-" ? "--notes-file -" : null,
104
+ inputs.passwordStdin ? "--password-stdin" : null,
105
+ inputs.passwordFile === "-" ? "--password-file -" : null,
106
+ ...(inputs.fieldFileFlag && inputs.fieldFiles
107
+ ? stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles)
108
+ : []),
109
+ ].filter((value): value is string => value !== null);
110
+ }
111
+
112
+ export async function resolveOptionalTextSource(
113
+ source: TextSource,
114
+ stdin: StdinReader,
115
+ ): Promise<string | undefined> {
116
+ const selected = [
117
+ source.inline !== undefined ? "inline" : null,
118
+ source.stdin ? "stdin" : null,
119
+ source.file !== undefined ? "file" : null,
120
+ source.env !== undefined ? "env" : null,
121
+ ].filter((value): value is string => value !== null);
122
+
123
+ if (selected.length > 1) {
124
+ throw new CliError(
125
+ `Choose only one ${source.label} source: ${source.flagNames.join(", ")}`,
126
+ ExitCode.BadArgs,
127
+ );
128
+ }
129
+
130
+ if (selected.length === 0) return undefined;
131
+
132
+ let value: string;
133
+ if (source.inline !== undefined) {
134
+ value = source.inline;
135
+ } else if (source.stdin) {
136
+ value = await stdin.read();
137
+ } else if (source.file !== undefined) {
138
+ value = await readFileOrStdin(source.file, stdin, source.label);
139
+ } else {
140
+ value = readEnvValue(source.env!, source.label);
141
+ }
142
+
143
+ if (!source.allowEmpty && value.length === 0) {
144
+ throw new CliError(`${capitalize(source.label)} cannot be empty`, ExitCode.BadArgs);
145
+ }
146
+
147
+ return value;
148
+ }
149
+
150
+ export async function resolveFieldSources(
151
+ sources: FieldSources,
152
+ ): Promise<ParsedField[]> {
153
+ const fields = sources.inline.map(parseFieldFlag);
154
+
155
+ for (const raw of sources.file) {
156
+ const field = parseRequiredSourceField(raw, sources.fileFlag);
157
+ fields.push({
158
+ ...field,
159
+ value: await readFileOrStdin(field.value, sources.stdin, field.name),
160
+ });
161
+ }
162
+
163
+ for (const raw of sources.env) {
164
+ const field = parseRequiredSourceField(raw, sources.envFlag);
165
+ fields.push({
166
+ ...field,
167
+ value: readEnvValue(field.value, field.name),
168
+ });
169
+ }
170
+
171
+ return fields;
172
+ }
173
+
174
+ export function assertSingleStdinSource(sources: string[]): void {
175
+ if (sources.length <= 1) return;
176
+ throw new CliError(
177
+ `Only one stdin input source can be used at a time: ${sources.join(", ")}`,
178
+ ExitCode.BadArgs,
179
+ );
180
+ }
181
+
182
+ export function stdinSourcesForFieldFiles(flag: string, raws: string[]): string[] {
183
+ return raws
184
+ .filter((raw) => parseRequiredSourceField(raw, flag).value === "-")
185
+ .map((raw) => `${flag} ${raw}`);
186
+ }
187
+
188
+ export async function readOptionalStdin(stdin: StdinReader): Promise<string | null> {
189
+ const text = await stdin.read();
190
+ return text.length > 0 ? text : null;
191
+ }
192
+
193
+ function parseRequiredSourceField(raw: string, flag: string): ParsedField {
194
+ if (!raw.includes("=")) {
195
+ throw new CliError(`Expected ${flag} k=value`, ExitCode.BadArgs);
196
+ }
197
+
198
+ const field = parseFieldFlag(raw);
199
+ if (field.value.length === 0) {
200
+ throw new CliError(`Expected ${flag} ${field.name}=value`, ExitCode.BadArgs);
201
+ }
202
+
203
+ return field;
204
+ }
205
+
206
+ async function readFileOrStdin(
207
+ path: string,
208
+ stdin: StdinReader,
209
+ label: string,
210
+ ): Promise<string> {
211
+ if (path === "-") return stdin.read();
212
+
213
+ try {
214
+ return stripFinalNewline(await Bun.file(path).text());
215
+ } catch (err) {
216
+ const message = err instanceof Error ? err.message : String(err);
217
+ throw new CliError(
218
+ `Failed to read ${label} from "${path}": ${message}`,
219
+ ExitCode.BadArgs,
220
+ );
221
+ }
222
+ }
223
+
224
+ function readEnvValue(name: string, label: string): string {
225
+ if (!Object.prototype.hasOwnProperty.call(process.env, name)) {
226
+ throw new CliError(
227
+ `Environment variable ${name} is not set for ${label}`,
228
+ ExitCode.BadArgs,
229
+ );
230
+ }
231
+ return process.env[name] ?? "";
232
+ }
233
+
234
+ async function readStdinText(): Promise<string> {
235
+ const chunks: Uint8Array[] = [];
236
+ for await (const chunk of Bun.stdin.stream()) {
237
+ chunks.push(chunk);
238
+ }
239
+ return stripFinalNewline(Buffer.concat(chunks).toString());
240
+ }
241
+
242
+ function stripFinalNewline(text: string): string {
243
+ return text.replace(/\r?\n$/, "");
244
+ }
245
+
246
+ function capitalize(value: string): string {
247
+ return value.charAt(0).toUpperCase() + value.slice(1);
248
+ }
package/src/cli/io.ts CHANGED
@@ -34,8 +34,14 @@ export function emitSuccess(message: string, opts: GlobalOptions): void {
34
34
  process.stderr.write(pc.green("✓") + " " + message + "\n");
35
35
  }
36
36
 
37
+ /**
38
+ * Warnings say the returned data may be wrong (stale vault, rejected session
39
+ * cache), so unlike logs they survive `--json` — the machine-readable callers are
40
+ * the ones that most need to hear it. Still stderr-only, so stdout stays
41
+ * parseable; `-q` is the way to silence them.
42
+ */
37
43
  export function emitWarn(message: string, opts: GlobalOptions): void {
38
- if (opts.quiet || opts.json) return;
44
+ if (opts.quiet) return;
39
45
  process.stderr.write(pc.yellow("⚠") + " " + message + "\n");
40
46
  }
41
47
 
@@ -0,0 +1,78 @@
1
+ import pc from "picocolors";
2
+ import { summarizeItem, type ItemSummary } from "../bw/items.ts";
3
+ import { BW_TYPE_FROM_NAME } from "../bw/types.ts";
4
+ import { CliError, ExitCode } from "./errors.ts";
5
+ import { emitData } from "./io.ts";
6
+ import type { GlobalOptions } from "./types.ts";
7
+
8
+ export interface ItemListOptions {
9
+ type?: string;
10
+ limit?: number;
11
+ /** Opt in to raw bw items, which carry passwords, notes, and custom fields. */
12
+ fullItems?: boolean;
13
+ }
14
+
15
+ export function filterItems(
16
+ items: Array<Record<string, unknown>>,
17
+ options: ItemListOptions,
18
+ ): Array<Record<string, unknown>> {
19
+ let result = items;
20
+
21
+ if (options.type) {
22
+ const typeVal = BW_TYPE_FROM_NAME[options.type];
23
+ if (typeVal === undefined) {
24
+ throw new CliError(
25
+ `Invalid type "${options.type}". Valid: login, note, card, identity (plural forms accepted)`,
26
+ ExitCode.BadArgs,
27
+ );
28
+ }
29
+ result = result.filter((item) => item.type === typeVal);
30
+ }
31
+
32
+ if (options.limit && options.limit > 0) {
33
+ result = result.slice(0, options.limit);
34
+ }
35
+
36
+ return result;
37
+ }
38
+
39
+ /**
40
+ * Prints a set of vault items. Human, `--plain`, and `--json` output all carry
41
+ * the same reduced projection, so asking for machine-readable output is a
42
+ * serialization choice and never widens data access. `--full-items` is the
43
+ * explicit opt-in for callers that genuinely need the complete objects.
44
+ */
45
+ export function writeItems(
46
+ items: Array<Record<string, unknown>>,
47
+ opts: GlobalOptions,
48
+ options: ItemListOptions = {},
49
+ ): void {
50
+ if (opts.json) {
51
+ emitData(options.fullItems ? items : items.map(summarizeItem), opts);
52
+ return;
53
+ }
54
+
55
+ if (options.fullItems) {
56
+ // Plain output stays line-oriented: one compact item per line (NDJSON).
57
+ if (opts.plain) writeLines(items, (item) => JSON.stringify(item));
58
+ else emitData(items, opts);
59
+ return;
60
+ }
61
+
62
+ writeLines(items.map(summarizeItem), opts.plain ? plainLine : humanLine);
63
+ }
64
+
65
+ function plainLine(summary: ItemSummary): string {
66
+ return `${summary.id}\t${summary.type}\t${summary.name}\t${summary.username ?? "-"}`;
67
+ }
68
+
69
+ function humanLine(summary: ItemSummary): string {
70
+ const userPart = summary.username ? pc.dim(` (${summary.username})`) : "";
71
+ return `${pc.dim(summary.id)} ${pc.cyan(summary.type)} ${summary.name}${userPart}`;
72
+ }
73
+
74
+ function writeLines<T>(values: T[], format: (value: T) => string): void {
75
+ for (const value of values) {
76
+ process.stdout.write(format(value) + "\n");
77
+ }
78
+ }
@@ -1,4 +1,7 @@
1
1
  import { Command } from "commander";
2
+ import pkg from "../../package.json" with { type: "json" };
3
+ import { DEFAULT_TIMEOUT_MS, setBwTimeout } from "../bw/runner.ts";
4
+ import { formatDuration, parseDuration } from "./duration.ts";
2
5
  import type { GlobalOptions } from "./types.ts";
3
6
  import { registerGet } from "./commands/get.ts";
4
7
  import { registerSearch } from "./commands/search.ts";
@@ -12,6 +15,8 @@ import { registerLock } from "./commands/lock.ts";
12
15
  import { registerConfig } from "./commands/config.ts";
13
16
  import { registerDoctor } from "./commands/doctor.ts";
14
17
  import { registerList } from "./commands/list.ts";
18
+ import { registerAttach } from "./commands/attach.ts";
19
+ import { registerRun } from "./commands/run.ts";
15
20
 
16
21
  export function buildProgram(): Command {
17
22
  const program = new Command();
@@ -19,15 +24,29 @@ export function buildProgram(): Command {
19
24
  program
20
25
  .name("bwx")
21
26
  .description("Bitwarden Extended CLI")
22
- .version("0.2.2")
27
+ .version(pkg.version)
23
28
  .option("--json", "JSON output")
24
29
  .option("--plain", "Plain parseable output")
25
30
  .option("-q, --quiet", "Suppress log output")
26
31
  .option("-v, --verbose", "Verbose output")
32
+ .option(
33
+ "--timeout <duration>",
34
+ "Deadline for each bw call, 0 to disable",
35
+ formatDuration(DEFAULT_TIMEOUT_MS),
36
+ )
37
+ .option(
38
+ "--sync-if-older-than <duration>",
39
+ "Sync first when the vault last synced longer ago than this",
40
+ )
27
41
  .exitOverride()
28
42
  .configureOutput({
29
43
  writeOut: (str) => process.stdout.write(str),
30
44
  writeErr: () => {}, // silenced — we handle all errors in main()
45
+ })
46
+ // Global options that configure the runtime are applied once, before any
47
+ // command action runs.
48
+ .hook("preAction", (_program, actionCommand) => {
49
+ setBwTimeout(getGlobalOpts(actionCommand).timeoutMs ?? DEFAULT_TIMEOUT_MS);
31
50
  });
32
51
 
33
52
  registerStatus(program);
@@ -35,10 +54,12 @@ export function buildProgram(): Command {
35
54
  registerLock(program);
36
55
  registerGet(program);
37
56
  registerSearch(program);
57
+ registerRun(program);
38
58
  registerSync(program);
39
59
  registerCreate(program);
40
60
  registerEdit(program);
41
61
  registerDelete(program);
62
+ registerAttach(program);
42
63
  registerList(program);
43
64
  registerConfig(program);
44
65
  registerDoctor(program);
@@ -53,5 +74,17 @@ export function getGlobalOpts(cmd: Command): GlobalOptions {
53
74
  plain: root.plain ?? false,
54
75
  quiet: root.quiet ?? false,
55
76
  verbose: root.verbose ?? false,
77
+ timeoutMs: optionalDuration(root.timeout, "--timeout") ?? DEFAULT_TIMEOUT_MS,
78
+ syncIfOlderThanMs: optionalDuration(
79
+ root.syncIfOlderThan,
80
+ "--sync-if-older-than",
81
+ ),
56
82
  };
57
83
  }
84
+
85
+ function optionalDuration(
86
+ raw: string | undefined,
87
+ flag: string,
88
+ ): number | undefined {
89
+ return raw === undefined ? undefined : parseDuration(raw, flag);
90
+ }
package/src/cli/types.ts CHANGED
@@ -3,8 +3,11 @@ export interface GlobalOptions {
3
3
  plain?: boolean;
4
4
  quiet?: boolean;
5
5
  verbose?: boolean;
6
- }
7
-
8
- export interface CliContext {
9
- opts: GlobalOptions;
6
+ /** Deadline for each `bw` call in ms; `0` disables it. */
7
+ timeoutMs?: number;
8
+ /**
9
+ * Sync before vault access when the last sync is older than this, in ms.
10
+ * Undefined means "only warn about a stale vault".
11
+ */
12
+ syncIfOlderThanMs?: number;
10
13
  }
@@ -0,0 +1,12 @@
1
+ // All bwx state lives in one XDG-respecting directory so config, session, and
2
+ // freshness state can never drift apart.
3
+
4
+ const CONFIG_HOME =
5
+ process.env.XDG_CONFIG_HOME ?? `${process.env.HOME}/.config`;
6
+
7
+ export const CONFIG_DIR = `${CONFIG_HOME}/bwx`;
8
+ export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
9
+ /** Cached `bw` session token (owner-only). */
10
+ export const SESSION_FILE = `${CONFIG_DIR}/session`;
11
+ /** Cached vault-freshness snapshot — see bw/freshness.ts. */
12
+ export const STATE_FILE = `${CONFIG_DIR}/state.json`;
@@ -0,0 +1,83 @@
1
+ import {
2
+ chmodSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname } from "node:path";
11
+
12
+ const PRIVATE_FILE_MODE = 0o600;
13
+ const PRIVATE_DIR_MODE = 0o700;
14
+ /** Group/other permission bits — none of them may be set on a private file. */
15
+ const SHARED_BITS = 0o077;
16
+
17
+ export type PrivateFileRead =
18
+ | { kind: "ok"; text: string }
19
+ | { kind: "missing" }
20
+ | { kind: "rejected"; reason: string };
21
+
22
+ /**
23
+ * Writes owner-only data atomically: a temp file in the destination directory is
24
+ * created with mode 0600 and then renamed over the target. Unlike write-then-chmod
25
+ * there is no window in which the payload is readable by others, and a crash leaves
26
+ * either the old file or the new one — never a permissive partial write.
27
+ */
28
+ export function writePrivateFile(path: string, contents: string): void {
29
+ mkdirSync(dirname(path), { recursive: true, mode: PRIVATE_DIR_MODE });
30
+
31
+ const tmp = `${path}.${process.pid}.tmp`;
32
+ try {
33
+ writeFileSync(tmp, contents, { mode: PRIVATE_FILE_MODE });
34
+ // writeFileSync's mode is subject to umask; chmod makes it exact.
35
+ chmodSync(tmp, PRIVATE_FILE_MODE);
36
+ renameSync(tmp, path);
37
+ } catch (err) {
38
+ try {
39
+ unlinkSync(tmp);
40
+ } catch {
41
+ // Nothing to clean up.
42
+ }
43
+ throw err;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Reads a file that is only trustworthy when it is a regular, owner-only file.
49
+ * A symlink, foreign owner, or any group/other permission bit means someone else
50
+ * could have planted or read the contents, so it is reported as rejected rather
51
+ * than returned — callers fall back to acquiring the data again.
52
+ */
53
+ export function readPrivateFile(path: string): PrivateFileRead {
54
+ let stat;
55
+ try {
56
+ stat = lstatSync(path);
57
+ } catch {
58
+ return { kind: "missing" };
59
+ }
60
+
61
+ if (stat.isSymbolicLink()) {
62
+ return { kind: "rejected", reason: `${path} is a symlink` };
63
+ }
64
+ if (!stat.isFile()) {
65
+ return { kind: "rejected", reason: `${path} is not a regular file` };
66
+ }
67
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
68
+ return { kind: "rejected", reason: `${path} is owned by uid ${stat.uid}` };
69
+ }
70
+ if (stat.mode & SHARED_BITS) {
71
+ const mode = (stat.mode & 0o777).toString(8).padStart(3, "0");
72
+ return { kind: "rejected", reason: `${path} has mode ${mode}, expected 600` };
73
+ }
74
+
75
+ try {
76
+ return { kind: "ok", text: readFileSync(path, "utf8") };
77
+ } catch (err) {
78
+ return {
79
+ kind: "rejected",
80
+ reason: `${path} could not be read: ${err instanceof Error ? err.message : String(err)}`,
81
+ };
82
+ }
83
+ }
@@ -1,16 +1,14 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { CONFIG_DIR, CONFIG_FILE } from "./paths.ts";
1
3
  import { ConfigSchema, type Config } from "./schema.ts";
2
4
 
3
- const CONFIG_DIR =
4
- process.env.XDG_CONFIG_HOME ?? `${process.env.HOME}/.config`;
5
- const CONFIG_PATH = `${CONFIG_DIR}/bwx/config.json`;
6
-
7
5
  export function getConfigPath(): string {
8
- return CONFIG_PATH;
6
+ return CONFIG_FILE;
9
7
  }
10
8
 
11
9
  export async function loadConfig(): Promise<Config> {
12
10
  try {
13
- const text = await Bun.file(CONFIG_PATH).text();
11
+ const text = await Bun.file(CONFIG_FILE).text();
14
12
  return ConfigSchema.parse(JSON.parse(text));
15
13
  } catch {
16
14
  return ConfigSchema.parse({});
@@ -18,8 +16,6 @@ export async function loadConfig(): Promise<Config> {
18
16
  }
19
17
 
20
18
  export async function saveConfig(config: Config): Promise<void> {
21
- const dir = CONFIG_PATH.replace(/\/[^/]+$/, "");
22
- const { mkdirSync } = await import("node:fs");
23
- mkdirSync(dir, { recursive: true });
24
- await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
19
+ mkdirSync(CONFIG_DIR, { recursive: true });
20
+ await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
25
21
  }
package/src/index.ts CHANGED
@@ -14,19 +14,7 @@ export async function main(argv: string[]): Promise<void> {
14
14
  }
15
15
 
16
16
  // Parse global opts early for error formatting
17
- let opts: GlobalOptions = {};
18
- try {
19
- program.parseOptions(argv.slice(2));
20
- const rawOpts = program.opts();
21
- opts = {
22
- json: rawOpts.json ?? false,
23
- plain: rawOpts.plain ?? false,
24
- quiet: rawOpts.quiet ?? false,
25
- verbose: rawOpts.verbose ?? false,
26
- };
27
- } catch {
28
- // Fall through — full parse below will handle
29
- }
17
+ const opts = parseGlobalOpts(argv.slice(2));
30
18
 
31
19
  try {
32
20
  await program.parseAsync(argv);
@@ -56,3 +44,12 @@ export async function main(argv: string[]): Promise<void> {
56
44
  handleFatalError(err, opts);
57
45
  }
58
46
  }
47
+
48
+ function parseGlobalOpts(args: string[]): GlobalOptions {
49
+ return {
50
+ json: args.includes("--json"),
51
+ plain: args.includes("--plain"),
52
+ quiet: args.includes("-q") || args.includes("--quiet"),
53
+ verbose: args.includes("-v") || args.includes("--verbose"),
54
+ };
55
+ }