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/cli/input.ts
ADDED
|
@@ -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
|
|
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";
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
}
|
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
|
-
|
|
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
|
+
}
|