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/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,126 +11,18 @@ 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
|
-
}
|
|
28
18
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return runBwOrThrow(["get", "item", item]);
|
|
38
|
-
}
|
|
39
|
-
return runBwOrThrow(["get", field, item]);
|
|
19
|
+
program
|
|
20
|
+
.command("field")
|
|
21
|
+
.description("Get a custom field from a vault item")
|
|
22
|
+
.argument("<item>", "Item name or ID")
|
|
23
|
+
.argument("<name>", "Custom field name")
|
|
24
|
+
.action(async function (this: Command, item: string, name: string) {
|
|
25
|
+
const opts = getGlobalOpts(this);
|
|
26
|
+
emitData(await readItemField(name, item, opts, { customOnly: true }), opts);
|
|
40
27
|
});
|
|
41
|
-
|
|
42
|
-
if (!result) {
|
|
43
|
-
throw new CliError(
|
|
44
|
-
`No ${field} found for item: ${item}`,
|
|
45
|
-
ExitCode.NotFound,
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (field === "item") {
|
|
50
|
-
emitData(JSON.parse(result), opts);
|
|
51
|
-
} else {
|
|
52
|
-
emitData(result, opts);
|
|
53
|
-
}
|
|
54
|
-
} catch (err) {
|
|
55
|
-
if (
|
|
56
|
-
err instanceof CliError &&
|
|
57
|
-
/more than one result/i.test(err.message)
|
|
58
|
-
) {
|
|
59
|
-
const matches = await resolveAmbiguous(item);
|
|
60
|
-
throw new CliError(
|
|
61
|
-
formatAmbiguousError(item, matches, opts),
|
|
62
|
-
ExitCode.BadArgs,
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
throw err;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function getCustomField(
|
|
70
|
-
fieldName: string,
|
|
71
|
-
item: string,
|
|
72
|
-
opts: ReturnType<typeof getGlobalOpts>,
|
|
73
|
-
): Promise<void> {
|
|
74
|
-
const json = await withSession(opts, () =>
|
|
75
|
-
runBwOrThrow(["get", "item", item]),
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
const parsed = BwItemSchema.parse(JSON.parse(json));
|
|
79
|
-
const fields = parsed.fields ?? [];
|
|
80
|
-
const match = fields.find((f) => f.name === fieldName);
|
|
81
|
-
|
|
82
|
-
if (!match) {
|
|
83
|
-
const available = fields.map((f) => f.name).join(", ");
|
|
84
|
-
const msg = available
|
|
85
|
-
? `Field "${fieldName}" not found. Available: ${available}`
|
|
86
|
-
: `Field "${fieldName}" not found (item has no custom fields)`;
|
|
87
|
-
throw new CliError(msg, ExitCode.NotFound);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
emitData(match.value ?? "", opts);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
interface MatchInfo {
|
|
94
|
-
id: string;
|
|
95
|
-
name: string;
|
|
96
|
-
type: string;
|
|
97
|
-
username?: string;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function resolveAmbiguous(query: string): Promise<MatchInfo[]> {
|
|
101
|
-
try {
|
|
102
|
-
const json = await runBwOrThrow([
|
|
103
|
-
"list",
|
|
104
|
-
"items",
|
|
105
|
-
"--search",
|
|
106
|
-
query,
|
|
107
|
-
]);
|
|
108
|
-
const items: Array<Record<string, unknown>> = JSON.parse(json);
|
|
109
|
-
return items.map((i) => ({
|
|
110
|
-
id: i.id as string,
|
|
111
|
-
name: i.name as string,
|
|
112
|
-
type: BW_TYPE_LABELS[i.type as number] ?? `type:${i.type}`,
|
|
113
|
-
username: (i.login as Record<string, unknown> | null)?.username as
|
|
114
|
-
| string
|
|
115
|
-
| undefined,
|
|
116
|
-
}));
|
|
117
|
-
} catch {
|
|
118
|
-
return [];
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function formatAmbiguousError(
|
|
123
|
-
query: string,
|
|
124
|
-
matches: MatchInfo[],
|
|
125
|
-
opts: { json?: boolean },
|
|
126
|
-
): string {
|
|
127
|
-
if (matches.length === 0) {
|
|
128
|
-
return `Multiple items match "${query}". Use a specific ID instead.`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (opts.json) {
|
|
132
|
-
return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const lines = [`Multiple items match "${query}":\n`];
|
|
136
|
-
for (const m of matches) {
|
|
137
|
-
const user = m.username ? ` (${m.username})` : "";
|
|
138
|
-
lines.push(` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`);
|
|
139
|
-
}
|
|
140
|
-
lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
|
|
141
|
-
return lines.join("\n");
|
|
142
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
|
}
|
|
@@ -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
|
+
}
|