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
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
|
}
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|