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.
- package/README.md +94 -7
- package/package.json +1 -1
- package/src/bw/attachments.ts +58 -0
- 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/attach.ts +112 -0
- package/src/cli/commands/create.ts +59 -50
- package/src/cli/commands/doctor.ts +2 -3
- package/src/cli/commands/edit.ts +52 -24
- package/src/cli/commands/get.ts +12 -126
- 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/input.ts +248 -0
- package/src/cli/io.ts +7 -1
- package/src/cli/items.ts +78 -0
- package/src/cli/program.ts +34 -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/index.ts +10 -13
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
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { listAttachments, resolveAttachment } from "../../bw/attachments.ts";
|
|
4
|
+
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
5
|
+
import { withSession } from "../../bw/session.ts";
|
|
6
|
+
import { emitData, emitSuccess } from "../io.ts";
|
|
7
|
+
import { getGlobalOpts } from "../program.ts";
|
|
8
|
+
|
|
9
|
+
export function registerAttach(program: Command): void {
|
|
10
|
+
const attach = program
|
|
11
|
+
.command("attach")
|
|
12
|
+
.aliases(["attachment", "attachments"])
|
|
13
|
+
.description("List and download item attachments");
|
|
14
|
+
|
|
15
|
+
attach
|
|
16
|
+
.command("list")
|
|
17
|
+
.alias("ls")
|
|
18
|
+
.description("List attachments for an item")
|
|
19
|
+
.argument("<item>", "Item name or ID")
|
|
20
|
+
.action(async function (this: Command, item: string) {
|
|
21
|
+
const opts = getGlobalOpts(this);
|
|
22
|
+
const current = await getItem(item, opts);
|
|
23
|
+
const attachments = listAttachments(current);
|
|
24
|
+
|
|
25
|
+
if (opts.json) {
|
|
26
|
+
emitData(attachments, opts);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (opts.plain) {
|
|
31
|
+
for (const attachment of attachments) {
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
[
|
|
34
|
+
attachment.id,
|
|
35
|
+
attachment.fileName,
|
|
36
|
+
attachment.sizeName ?? attachment.size ?? "-",
|
|
37
|
+
].join("\t") + "\n",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (attachments.length === 0) {
|
|
44
|
+
const itemName = typeof current.name === "string" ? current.name : item;
|
|
45
|
+
process.stdout.write(`No attachments on "${itemName}"\n`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const attachment of attachments) {
|
|
50
|
+
const size = attachment.sizeName ?? attachment.size;
|
|
51
|
+
const sizePart = size === undefined ? "" : pc.dim(` (${size})`);
|
|
52
|
+
process.stdout.write(
|
|
53
|
+
`${pc.dim(attachment.id)} ${attachment.fileName}${sizePart}\n`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
attach
|
|
59
|
+
.command("get")
|
|
60
|
+
.description("Download an attachment from an item")
|
|
61
|
+
.argument("<item>", "Item name or ID")
|
|
62
|
+
.argument("<attachment>", "Attachment ID or filename")
|
|
63
|
+
.option("-o, --output <path>", "Output file or directory")
|
|
64
|
+
.action(async function (
|
|
65
|
+
this: Command,
|
|
66
|
+
item: string,
|
|
67
|
+
attachmentQuery: string,
|
|
68
|
+
localOpts: { output?: string },
|
|
69
|
+
) {
|
|
70
|
+
const opts = getGlobalOpts(this);
|
|
71
|
+
const current = await getItem(item, opts);
|
|
72
|
+
const attachments = listAttachments(current);
|
|
73
|
+
const attachment = resolveAttachment(attachments, attachmentQuery);
|
|
74
|
+
const itemId = String(current.id);
|
|
75
|
+
const output = localOpts.output ?? attachment.fileName;
|
|
76
|
+
|
|
77
|
+
await withSession(opts, () =>
|
|
78
|
+
runBwOrThrow([
|
|
79
|
+
"get",
|
|
80
|
+
"attachment",
|
|
81
|
+
attachment.id,
|
|
82
|
+
"--itemid",
|
|
83
|
+
itemId,
|
|
84
|
+
"--output",
|
|
85
|
+
output,
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const result = {
|
|
90
|
+
itemId,
|
|
91
|
+
attachmentId: attachment.id,
|
|
92
|
+
fileName: attachment.fileName,
|
|
93
|
+
output,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
emitData(result, opts);
|
|
98
|
+
} else if (opts.plain) {
|
|
99
|
+
emitData(output, opts);
|
|
100
|
+
} else {
|
|
101
|
+
emitSuccess(`Saved "${attachment.fileName}" to ${output}`, opts);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function getItem(
|
|
107
|
+
item: string,
|
|
108
|
+
opts: ReturnType<typeof getGlobalOpts>,
|
|
109
|
+
): Promise<Record<string, unknown>> {
|
|
110
|
+
const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
|
|
111
|
+
return JSON.parse(json) as Record<string, unknown>;
|
|
112
|
+
}
|
|
@@ -4,11 +4,29 @@ import { emitData, emitSuccess } from "../io.ts";
|
|
|
4
4
|
import { CliError, ExitCode } from "../errors.ts";
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
|
-
import { buildNewItem,
|
|
7
|
+
import { buildNewItem, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
import {
|
|
9
|
+
assertSingleStdinSource,
|
|
10
|
+
collect,
|
|
11
|
+
listExplicitStdinInputs,
|
|
12
|
+
readOptionalStdin,
|
|
13
|
+
resolveFieldSources,
|
|
14
|
+
resolveItemTextInputs,
|
|
15
|
+
StdinReader,
|
|
16
|
+
type ItemTextInputs,
|
|
17
|
+
} from "../input.ts";
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
interface CreateOptions extends ItemTextInputs {
|
|
20
|
+
type: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
username?: string;
|
|
23
|
+
uri: string[];
|
|
24
|
+
field: string[];
|
|
25
|
+
fieldFile: string[];
|
|
26
|
+
fieldEnv: string[];
|
|
27
|
+
folder?: string;
|
|
28
|
+
favorite?: boolean;
|
|
29
|
+
fromJson?: boolean;
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
export function registerCreate(program: Command): void {
|
|
@@ -18,44 +36,40 @@ export function registerCreate(program: Command): void {
|
|
|
18
36
|
.option("--type <type>", "Item type: login|note", "note")
|
|
19
37
|
.option("--name <name>", "Item name")
|
|
20
38
|
.option("--notes <text>", "Notes content")
|
|
39
|
+
.option("--notes-file <path>", "Read notes from file (use - for stdin)")
|
|
21
40
|
.option("--username <user>", "Username (login type)")
|
|
22
41
|
.option("--password <pass>", "Password (login type)")
|
|
42
|
+
.option("--password-stdin", "Read password from stdin")
|
|
43
|
+
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
44
|
+
.option("--password-env <name>", "Read password from environment variable")
|
|
23
45
|
.option("--uri <url>", "URI (repeatable)", collect, [])
|
|
24
46
|
.option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
|
|
47
|
+
.option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
|
|
48
|
+
.option("--field-env <kv>", "Custom field value from env var k=ENV (repeatable)", collect, [])
|
|
25
49
|
.option("--folder <id>", "Folder ID")
|
|
26
50
|
.option("--favorite", "Mark as favorite")
|
|
27
51
|
.option("--from-json", "Read full item JSON from stdin")
|
|
28
52
|
.action(async function (
|
|
29
53
|
this: Command,
|
|
30
|
-
localOpts:
|
|
31
|
-
type: string;
|
|
32
|
-
name?: string;
|
|
33
|
-
notes?: string;
|
|
34
|
-
username?: string;
|
|
35
|
-
password?: string;
|
|
36
|
-
uri: string[];
|
|
37
|
-
field: string[];
|
|
38
|
-
folder?: string;
|
|
39
|
-
favorite?: boolean;
|
|
40
|
-
fromJson?: boolean;
|
|
41
|
-
},
|
|
54
|
+
localOpts: CreateOptions,
|
|
42
55
|
) {
|
|
43
56
|
const opts = getGlobalOpts(this);
|
|
57
|
+
const stdin = new StdinReader();
|
|
44
58
|
|
|
45
59
|
let encoded: string;
|
|
46
60
|
|
|
47
61
|
if (localOpts.fromJson) {
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
62
|
+
const stdinText = await stdin.read();
|
|
63
|
+
if (!stdinText) {
|
|
50
64
|
throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
|
|
51
65
|
}
|
|
52
66
|
// Validate it's JSON
|
|
53
67
|
try {
|
|
54
|
-
JSON.parse(
|
|
68
|
+
JSON.parse(stdinText);
|
|
55
69
|
} catch {
|
|
56
70
|
throw new CliError("Invalid JSON on stdin", ExitCode.BadArgs);
|
|
57
71
|
}
|
|
58
|
-
encoded = bwEncode(
|
|
72
|
+
encoded = bwEncode(stdinText);
|
|
59
73
|
} else {
|
|
60
74
|
if (!localOpts.name) {
|
|
61
75
|
throw new CliError("--name is required", ExitCode.BadArgs);
|
|
@@ -68,33 +82,41 @@ export function registerCreate(program: Command): void {
|
|
|
68
82
|
);
|
|
69
83
|
}
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
const explicitStdinSources = listExplicitStdinInputs({
|
|
86
|
+
...localOpts,
|
|
87
|
+
fieldFileFlag: "--field-file",
|
|
88
|
+
fieldFiles: localOpts.fieldFile,
|
|
89
|
+
});
|
|
90
|
+
assertSingleStdinSource(explicitStdinSources);
|
|
91
|
+
|
|
92
|
+
const textInputs = await resolveItemTextInputs(localOpts, stdin);
|
|
93
|
+
let notes = textInputs.notes;
|
|
77
94
|
|
|
78
|
-
//
|
|
79
|
-
let password = localOpts.password ?? null;
|
|
95
|
+
// Backward compatibility: piped create input becomes note content.
|
|
80
96
|
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
notes === undefined &&
|
|
98
|
+
textInputs.password === undefined &&
|
|
99
|
+
explicitStdinSources.length === 0 &&
|
|
100
|
+
!process.stdin.isTTY
|
|
85
101
|
) {
|
|
86
|
-
|
|
87
|
-
if (stdinText) password = stdinText;
|
|
102
|
+
notes = (await readOptionalStdin(stdin)) ?? undefined;
|
|
88
103
|
}
|
|
89
104
|
|
|
90
|
-
const fields =
|
|
105
|
+
const fields = await resolveFieldSources({
|
|
106
|
+
inline: localOpts.field,
|
|
107
|
+
file: localOpts.fieldFile,
|
|
108
|
+
env: localOpts.fieldEnv,
|
|
109
|
+
fileFlag: "--field-file",
|
|
110
|
+
envFlag: "--field-env",
|
|
111
|
+
stdin,
|
|
112
|
+
});
|
|
91
113
|
|
|
92
114
|
const item = buildNewItem({
|
|
93
115
|
type: localOpts.type,
|
|
94
116
|
name: localOpts.name,
|
|
95
|
-
notes,
|
|
117
|
+
notes: notes ?? null,
|
|
96
118
|
username: localOpts.username ?? null,
|
|
97
|
-
password,
|
|
119
|
+
password: textInputs.password ?? null,
|
|
98
120
|
uris: localOpts.uri,
|
|
99
121
|
fields,
|
|
100
122
|
folderId: localOpts.folder ?? null,
|
|
@@ -118,16 +140,3 @@ export function registerCreate(program: Command): void {
|
|
|
118
140
|
}
|
|
119
141
|
});
|
|
120
142
|
}
|
|
121
|
-
|
|
122
|
-
async function readStdin(): Promise<string | null> {
|
|
123
|
-
try {
|
|
124
|
-
const chunks: Uint8Array[] = [];
|
|
125
|
-
for await (const chunk of Bun.stdin.stream()) {
|
|
126
|
-
chunks.push(chunk);
|
|
127
|
-
}
|
|
128
|
-
const text = Buffer.concat(chunks).toString().trim();
|
|
129
|
-
return text || null;
|
|
130
|
-
} catch {
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
@@ -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/edit.ts
CHANGED
|
@@ -4,11 +4,28 @@ import { emitData, emitSuccess } from "../io.ts";
|
|
|
4
4
|
import { CliError, ExitCode } from "../errors.ts";
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
|
-
import { patchItem,
|
|
7
|
+
import { patchItem, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
import {
|
|
9
|
+
assertSingleStdinSource,
|
|
10
|
+
collect,
|
|
11
|
+
listExplicitStdinInputs,
|
|
12
|
+
resolveFieldSources,
|
|
13
|
+
resolveItemTextInputs,
|
|
14
|
+
StdinReader,
|
|
15
|
+
type ItemTextInputs,
|
|
16
|
+
} from "../input.ts";
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
interface EditOptions extends ItemTextInputs {
|
|
19
|
+
name?: string;
|
|
20
|
+
username?: string;
|
|
21
|
+
uri: string[];
|
|
22
|
+
addField: string[];
|
|
23
|
+
addFieldFile: string[];
|
|
24
|
+
addFieldEnv: string[];
|
|
25
|
+
rmField: string[];
|
|
26
|
+
folder?: string;
|
|
27
|
+
favorite?: boolean;
|
|
28
|
+
fromJson?: boolean;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
export function registerEdit(program: Command): void {
|
|
@@ -18,10 +35,16 @@ export function registerEdit(program: Command): void {
|
|
|
18
35
|
.argument("<item>", "Item name or ID")
|
|
19
36
|
.option("--name <name>", "New name")
|
|
20
37
|
.option("--notes <text>", "New notes")
|
|
38
|
+
.option("--notes-file <path>", "Read notes from file (use - for stdin)")
|
|
21
39
|
.option("--username <user>", "New username")
|
|
22
40
|
.option("--password <pass>", "New password")
|
|
41
|
+
.option("--password-stdin", "Read password from stdin")
|
|
42
|
+
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
43
|
+
.option("--password-env <name>", "Read password from environment variable")
|
|
23
44
|
.option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
|
|
24
45
|
.option("--add-field <kv>", "Add/update field k=v", collect, [])
|
|
46
|
+
.option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
|
|
47
|
+
.option("--add-field-env <kv>", "Add/update field value from env var k=ENV", collect, [])
|
|
25
48
|
.option("--rm-field <name>", "Remove field by name", collect, [])
|
|
26
49
|
.option("--folder <id>", "Move to folder")
|
|
27
50
|
.option("--favorite", "Set favorite")
|
|
@@ -30,20 +53,10 @@ export function registerEdit(program: Command): void {
|
|
|
30
53
|
.action(async function (
|
|
31
54
|
this: Command,
|
|
32
55
|
item: string,
|
|
33
|
-
localOpts:
|
|
34
|
-
name?: string;
|
|
35
|
-
notes?: string;
|
|
36
|
-
username?: string;
|
|
37
|
-
password?: string;
|
|
38
|
-
uri: string[];
|
|
39
|
-
addField: string[];
|
|
40
|
-
rmField: string[];
|
|
41
|
-
folder?: string;
|
|
42
|
-
favorite?: boolean;
|
|
43
|
-
fromJson?: boolean;
|
|
44
|
-
},
|
|
56
|
+
localOpts: EditOptions,
|
|
45
57
|
) {
|
|
46
58
|
const opts = getGlobalOpts(this);
|
|
59
|
+
const stdin = new StdinReader();
|
|
47
60
|
|
|
48
61
|
const result = await withSession(opts, async () => {
|
|
49
62
|
// Fetch current item
|
|
@@ -54,23 +67,38 @@ export function registerEdit(program: Command): void {
|
|
|
54
67
|
let encoded: string;
|
|
55
68
|
|
|
56
69
|
if (localOpts.fromJson) {
|
|
57
|
-
const
|
|
58
|
-
for await (const chunk of Bun.stdin.stream()) {
|
|
59
|
-
chunks.push(chunk);
|
|
60
|
-
}
|
|
61
|
-
const stdinText = Buffer.concat(chunks).toString().trim();
|
|
70
|
+
const stdinText = await stdin.read();
|
|
62
71
|
if (!stdinText) {
|
|
63
72
|
throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
|
|
64
73
|
}
|
|
65
74
|
encoded = bwEncode(stdinText);
|
|
66
75
|
} else {
|
|
76
|
+
assertSingleStdinSource(
|
|
77
|
+
listExplicitStdinInputs({
|
|
78
|
+
...localOpts,
|
|
79
|
+
fieldFileFlag: "--add-field-file",
|
|
80
|
+
fieldFiles: localOpts.addFieldFile,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const textInputs = await resolveItemTextInputs(localOpts, stdin);
|
|
85
|
+
|
|
86
|
+
const addFields = await resolveFieldSources({
|
|
87
|
+
inline: localOpts.addField,
|
|
88
|
+
file: localOpts.addFieldFile,
|
|
89
|
+
env: localOpts.addFieldEnv,
|
|
90
|
+
fileFlag: "--add-field-file",
|
|
91
|
+
envFlag: "--add-field-env",
|
|
92
|
+
stdin,
|
|
93
|
+
});
|
|
94
|
+
|
|
67
95
|
const patched = patchItem(current, {
|
|
68
96
|
name: localOpts.name,
|
|
69
|
-
notes:
|
|
97
|
+
notes: textInputs.notes,
|
|
70
98
|
username: localOpts.username,
|
|
71
|
-
password:
|
|
99
|
+
password: textInputs.password,
|
|
72
100
|
uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
|
|
73
|
-
addFields
|
|
101
|
+
addFields,
|
|
74
102
|
rmFields: localOpts.rmField,
|
|
75
103
|
folderId: localOpts.folder,
|
|
76
104
|
favorite: localOpts.favorite,
|