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/README.md +49 -3
- package/package.json +1 -1
- 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
|
@@ -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 {
|
|
5
|
-
import {
|
|
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);
|
package/src/cli/commands/sync.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
44
|
+
if (opts.quiet) return;
|
|
39
45
|
process.stderr.write(pc.yellow("⚠") + " " + message + "\n");
|
|
40
46
|
}
|
|
41
47
|
|
package/src/cli/items.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/program.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
9
|
-
|
|
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
|
+
}
|
package/src/config/store.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
22
|
-
|
|
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
|
}
|