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.
@@ -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
  }
@@ -1,8 +1,8 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData } from "../io.ts";
4
- import { getStatus } from "../../bw/session.ts";
5
- import { BW_TYPE_LABELS } from "../../bw/types.ts";
4
+ import { recordStatus } from "../../bw/freshness.ts";
5
+ import { getStatus } from "../../bw/status.ts";
6
6
  import pc from "picocolors";
7
7
 
8
8
  export function registerStatus(program: Command): void {
@@ -12,6 +12,8 @@ export function registerStatus(program: Command): void {
12
12
  .action(async function (this: Command) {
13
13
  const opts = getGlobalOpts(this);
14
14
  const status = await getStatus();
15
+ // Reads reuse this for their staleness check instead of paying for `bw status`.
16
+ recordStatus(status);
15
17
 
16
18
  if (opts.json || opts.plain) {
17
19
  emitData(status, opts);
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { emitSuccess } from "../io.ts";
4
+ import { recordSyncNow } from "../../bw/freshness.ts";
4
5
  import { runBwOrThrow } from "../../bw/runner.ts";
5
6
  import { withSession } from "../../bw/session.ts";
6
7
 
@@ -10,7 +11,11 @@ export function registerSync(program: Command): void {
10
11
  .description("Sync vault (auto-unlocks)")
11
12
  .action(async function (this: Command) {
12
13
  const opts = getGlobalOpts(this);
13
- await withSession(opts, () => runBwOrThrow(["sync"]));
14
+ // This *is* the freshness fix — checking freshness first would be circular.
15
+ await withSession(opts, () => runBwOrThrow(["sync"]), {
16
+ skipFreshnessCheck: true,
17
+ });
18
+ recordSyncNow();
14
19
  emitSuccess("Vault synced", opts);
15
20
  });
16
21
  }
@@ -0,0 +1,34 @@
1
+ import { CliError, ExitCode } from "./errors.ts";
2
+
3
+ const UNIT_MS: Record<string, number> = {
4
+ ms: 1,
5
+ s: 1_000,
6
+ m: 60_000,
7
+ h: 3_600_000,
8
+ d: 86_400_000,
9
+ };
10
+
11
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/;
12
+
13
+ /** Parses `250ms`, `30s`, `15m`, `2h`, `1d`. A bare number is seconds. */
14
+ export function parseDuration(raw: string, flag: string): number {
15
+ const match = DURATION_RE.exec(raw.trim());
16
+ if (!match) {
17
+ throw new CliError(
18
+ `Invalid duration "${raw}" for ${flag}. Use e.g. 30s, 15m, 2h`,
19
+ ExitCode.BadArgs,
20
+ );
21
+ }
22
+ return Math.round(Number(match[1]) * UNIT_MS[match[2] ?? "s"]!);
23
+ }
24
+
25
+ /** Coarse human-readable duration for diagnostics: `45s`, `12m`, `3h`, `8d`. */
26
+ export function formatDuration(ms: number): string {
27
+ const seconds = Math.max(0, Math.round(ms / 1000));
28
+ if (seconds < 60) return `${seconds}s`;
29
+ const minutes = Math.round(seconds / 60);
30
+ if (minutes < 60) return `${minutes}m`;
31
+ const hours = Math.round(minutes / 60);
32
+ if (hours < 48) return `${hours}h`;
33
+ return `${Math.round(hours / 24)}d`;
34
+ }
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";
@@ -13,6 +16,7 @@ import { registerConfig } from "./commands/config.ts";
13
16
  import { registerDoctor } from "./commands/doctor.ts";
14
17
  import { registerList } from "./commands/list.ts";
15
18
  import { registerAttach } from "./commands/attach.ts";
19
+ import { registerRun } from "./commands/run.ts";
16
20
 
17
21
  export function buildProgram(): Command {
18
22
  const program = new Command();
@@ -20,15 +24,29 @@ export function buildProgram(): Command {
20
24
  program
21
25
  .name("bwx")
22
26
  .description("Bitwarden Extended CLI")
23
- .version("0.3.0")
27
+ .version(pkg.version)
24
28
  .option("--json", "JSON output")
25
29
  .option("--plain", "Plain parseable output")
26
30
  .option("-q, --quiet", "Suppress log output")
27
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
+ )
28
41
  .exitOverride()
29
42
  .configureOutput({
30
43
  writeOut: (str) => process.stdout.write(str),
31
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);
32
50
  });
33
51
 
34
52
  registerStatus(program);
@@ -36,6 +54,7 @@ export function buildProgram(): Command {
36
54
  registerLock(program);
37
55
  registerGet(program);
38
56
  registerSearch(program);
57
+ registerRun(program);
39
58
  registerSync(program);
40
59
  registerCreate(program);
41
60
  registerEdit(program);
@@ -55,5 +74,17 @@ export function getGlobalOpts(cmd: Command): GlobalOptions {
55
74
  plain: root.plain ?? false,
56
75
  quiet: root.quiet ?? false,
57
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
+ ),
58
82
  };
59
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
  }