Skip to content

Commit 8774e11

Browse files
authored
Merge pull request #14 from bx33661/refactor/cli-commands
refactor(cli): split omv.ts into commands/ + wire orphan commands
2 parents 328cf05 + c5739af commit 8774e11

19 files changed

Lines changed: 1672 additions & 1430 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Split the `omv` CLI dispatcher (`omv.ts`) into one module per command under `commands/`, collapsing 12 duplicated error handlers into one.
6+
- Wired three commands that were validated and advertised but previously unreachable: `omv repro init`, `omv report artifacts`, and `omv findings doctor`. They now dispatch to the existing domain logic (`initReproArtifacts`, `checkReportArtifacts`, `doctorFinding`).
57
- `omv-find` now excludes packages that already exist in `.omv/findings/` or `.omv/archive/findings/`; pass `--include-known` to override. Added a behavior eval + golden output for local dedup.
68

79
## v0.8.0 - Workflow readiness gates

src/cli/commands/config.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { configGet, configList, configSet, configUnset } from "../config.js";
2+
import { configUsage } from "../usage.js";
3+
import { firstPositionalAfter } from "./shared.js";
4+
5+
export async function run(args: string[]): Promise<void> {
6+
const subcommand = args[1] ?? "list";
7+
switch (subcommand) {
8+
case "get": {
9+
const key = firstPositionalAfter(args, "get");
10+
if (!key) {
11+
console.error("Missing config key.");
12+
process.exit(1);
13+
}
14+
const value = await configGet(key);
15+
if (value === undefined) {
16+
console.error(`Config key "${key}" is not set.`);
17+
process.exit(1);
18+
}
19+
console.log(`${key}=${value}`);
20+
return;
21+
}
22+
case "set": {
23+
const key = firstPositionalAfter(args, "set");
24+
const value = firstPositionalAfter(args, key ?? "");
25+
if (!key || !value) {
26+
console.error("Usage: omv config set <key> <value>");
27+
process.exit(1);
28+
}
29+
await configSet(key, value);
30+
console.log(`${key}=${value}`);
31+
return;
32+
}
33+
case "unset": {
34+
const key = firstPositionalAfter(args, "unset");
35+
if (!key) {
36+
console.error("Missing config key.");
37+
process.exit(1);
38+
}
39+
await configUnset(key);
40+
console.log(`${key} unset`);
41+
return;
42+
}
43+
case "list": {
44+
const entries = await configList();
45+
const keys = Object.keys(entries);
46+
if (keys.length === 0) {
47+
console.log("No config values set.");
48+
return;
49+
}
50+
for (const key of keys.sort()) {
51+
console.log(`${key}=${entries[key]}`);
52+
}
53+
return;
54+
}
55+
default:
56+
console.error(`Unknown config command: ${subcommand}\n`);
57+
configUsage(undefined);
58+
process.exit(1);
59+
}
60+
}

src/cli/commands/dashboard.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
listFindingWorkflow,
3+
type FindingWorkflowSummary,
4+
} from "../findings.js";
5+
import { readWorkspaceActivity, workspaceStatus, type WorkspaceActivityEntry, type WorkspaceStatus } from "../workspace.js";
6+
import { wantsJson } from "./shared.js";
7+
import { command as cmd, empty, kv, panel, readiness, statusBadge, table, title, truncate, warn } from "../tui.js";
8+
9+
export async function run(args: string[]): Promise<void> {
10+
const json = wantsJson(args);
11+
const [status, workflow, activity] = await Promise.all([
12+
workspaceStatus(),
13+
listFindingWorkflow(),
14+
readWorkspaceActivity(),
15+
]);
16+
const result = { status, workflow, activity: activity.slice(-8) };
17+
if (json) {
18+
console.log(JSON.stringify(result, null, 2));
19+
return;
20+
}
21+
printDashboard(status, workflow, activity.slice(-8));
22+
}
23+
24+
function printDashboard(
25+
status: WorkspaceStatus,
26+
workflow: FindingWorkflowSummary[],
27+
activity: WorkspaceActivityEntry[],
28+
): void {
29+
console.log(title("oh-my-vul dashboard"));
30+
const statuses = Object.entries(status.statusCounts)
31+
.map(([name, count]) => `${name}=${count}`)
32+
.join(", ");
33+
console.log(
34+
panel("workspace", [
35+
...kv([
36+
["root", status.root],
37+
["active", String(status.activeCount)],
38+
["archived", String(status.archivedCount)],
39+
["statuses", statuses || "none"],
40+
["next", workflow[0] ? cmd(workflow[0].nextAction) : cmd("omv findings init <id>")],
41+
]),
42+
...status.warnings.map((item) => warn(`warning ${item}`)),
43+
]),
44+
);
45+
46+
if (workflow.length === 0) {
47+
console.log(empty("No active findings. Start with omv findings init <id> or /omv-find."));
48+
} else {
49+
console.log(
50+
table(
51+
["id", "status", "evidence", "submission", "next action"],
52+
workflow.slice(0, 8).map((finding) => [
53+
truncate(finding.id, 30),
54+
statusBadge(finding.status),
55+
readiness(finding.evidenceScore),
56+
readiness(finding.submissionScore),
57+
cmd(truncate(finding.nextAction, 54)),
58+
]),
59+
),
60+
);
61+
}
62+
63+
if (activity.length > 0) {
64+
console.log(
65+
table(
66+
["time", "action", "id"],
67+
activity.map((entry) => [
68+
truncate(entry.timestamp, 27),
69+
entry.action,
70+
truncate(entry.id ?? "-", 28),
71+
]),
72+
),
73+
);
74+
}
75+
}

src/cli/commands/dedup.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { planDedup, updateDedup, type DedupUpdateResult } from "../dedup.js";
2+
import { showFinding } from "../findings.js";
3+
import { firstPositionalAfter, parseOption, wantsJson } from "./shared.js";
4+
import { kv, muted, panel } from "../tui.js";
5+
6+
export async function run(args: string[]): Promise<void> {
7+
const id = firstPositionalAfter(args, "dedup");
8+
const json = wantsJson(args);
9+
if (!id) {
10+
console.error("Missing finding id.");
11+
process.exit(1);
12+
}
13+
const detail = await showFinding(id);
14+
const result = args.includes("--confirm")
15+
? await updateDedup(detail.path, detail.id, {
16+
existingCve: parseOption(args, "--existing-cve") ?? "none",
17+
notes: parseOption(args, "--notes") ?? "dedup searched with omv dedup",
18+
confirmed: true,
19+
})
20+
: { ...(await planDedup(detail.path, detail.id)), updated: false };
21+
if (json) {
22+
console.log(JSON.stringify(result, null, 2));
23+
return;
24+
}
25+
printDedupResult(result);
26+
}
27+
28+
function printDedupResult(result: DedupUpdateResult): void {
29+
console.log(
30+
panel("dedup", [
31+
...kv([
32+
["id", result.id],
33+
["updated", result.updated ? "yes" : "no"],
34+
["path", result.path],
35+
]),
36+
"",
37+
muted("queries"),
38+
...result.queries.map((query) => ` ${query}`),
39+
...(result.updated ? [] : ["", muted("writeback rerun with --confirm --existing-cve <CVE|none> --notes <text>")]),
40+
]),
41+
);
42+
}

src/cli/commands/disclose.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { commandUsage } from "../usage.js";
2+
import { firstPositionalAfter, parseOption, wantsJson } from "./shared.js";
3+
import { table, title } from "../tui.js";
4+
5+
export async function run(args: string[]): Promise<void> {
6+
const subcommand = args[1] ?? "timeline";
7+
const json = wantsJson(args);
8+
if (subcommand !== "timeline") {
9+
console.error(`Unknown disclose command: ${subcommand}\n`);
10+
commandUsage(args, args[0], "disclose", args[1]);
11+
process.exit(1);
12+
}
13+
const id = firstPositionalAfter(args, "timeline");
14+
if (!id) {
15+
console.error("Missing finding id.");
16+
process.exit(1);
17+
}
18+
const result = disclosureTimeline(id, Number(parseOption(args, "--days") ?? "90"));
19+
if (json) {
20+
console.log(JSON.stringify(result, null, 2));
21+
return;
22+
}
23+
printDisclosureTimeline(result);
24+
}
25+
26+
function disclosureTimeline(id: string, days: number): { id: string; days: number; milestones: { name: string; date: string }[] } {
27+
if (!Number.isInteger(days) || days <= 0) {
28+
throw new Error("--days must be a positive integer");
29+
}
30+
const start = new Date();
31+
const addDays = (count: number) => {
32+
const date = new Date(start);
33+
date.setUTCDate(date.getUTCDate() + count);
34+
return date.toISOString().slice(0, 10);
35+
};
36+
return {
37+
id,
38+
days,
39+
milestones: [
40+
{ name: "initial contact", date: addDays(0) },
41+
{ name: "follow-up", date: addDays(Math.min(45, Math.max(1, Math.floor(days / 2)))) },
42+
{ name: "7-day reminder", date: addDays(Math.max(0, days - 7)) },
43+
{ name: "planned disclosure", date: addDays(days) },
44+
],
45+
};
46+
}
47+
48+
function printDisclosureTimeline(result: { id: string; days: number; milestones: { name: string; date: string }[] }): void {
49+
console.log(title(`disclosure ${result.id}`));
50+
console.log(table(["milestone", "date"], result.milestones.map((item) => [item.name, item.date])));
51+
}

src/cli/commands/doctor.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { doctor, type Check, type DoctorResult } from "../doctor.js";
2+
import { resolveOptionalScope, wantsJson } from "./shared.js";
3+
import { command as cmd, kv, outcomeBadge, panel, section, statusIcon, table, title, truncate, warn } from "../tui.js";
4+
5+
export async function run(args: string[]): Promise<void> {
6+
const json = wantsJson(args);
7+
const strict = args.includes("--strict");
8+
const scope = await resolveOptionalScope(args);
9+
const result = await doctor({ scope });
10+
const ok = result.ok && (!strict || !result.warnings);
11+
12+
if (json) {
13+
console.log(JSON.stringify(result, null, 2));
14+
if (!ok) {
15+
process.exit(1);
16+
}
17+
return;
18+
}
19+
20+
printDoctorResult(result, strict);
21+
if (!ok) {
22+
process.exit(1);
23+
}
24+
}
25+
26+
function printDoctorResult(result: DoctorResult, strict: boolean): void {
27+
const passed = result.checks.filter((item) => item.status === "pass").length;
28+
const warned = result.checks.filter((item) => item.status === "warn").length;
29+
const failed = result.checks.filter((item) => item.status === "fail").length;
30+
const finalState = failed > 0 ? "fail" : strict && warned > 0 ? "fail" : warned > 0 ? "warn" : "pass";
31+
const next = failed > 0
32+
? `omv setup --scope ${result.scope} --force`
33+
: warned > 0
34+
? `omv doctor --scope ${result.scope} --strict`
35+
: "omv dashboard";
36+
37+
console.log(title("oh-my-vul doctor"));
38+
console.log(
39+
panel("health summary", [
40+
...kv([
41+
["scope", result.scope],
42+
["skills", result.skillsDir],
43+
["status", outcomeBadge(finalState)],
44+
["checks", `${passed} pass, ${warned} warn, ${failed} fail`],
45+
["next", cmd(next)],
46+
]),
47+
]),
48+
);
49+
50+
console.log(section("Checks"));
51+
console.log(
52+
table(
53+
["", "check", "state", "detail"],
54+
result.checks.map((check) => [
55+
statusIcon(check.status),
56+
truncate(check.name, 30),
57+
outcomeBadge(check.status),
58+
truncate(check.message, 76),
59+
]),
60+
),
61+
);
62+
63+
const warnings = result.checks.filter((item) => item.status === "warn");
64+
if (warnings.length > 0) {
65+
console.log(panel("warnings", warnings.map(formatCheckDetail)));
66+
}
67+
const failures = result.checks.filter((item) => item.status === "fail");
68+
if (failures.length > 0) {
69+
console.log(panel("failures", failures.map(formatCheckDetail)));
70+
} else if (strict && warnings.length > 0) {
71+
console.log(panel("strict mode", [warn("warnings are treated as failures in --strict mode")]));
72+
}
73+
}
74+
75+
function formatCheckDetail(check: Check): string {
76+
return `${statusIcon(check.status)} ${check.name}: ${check.message}`;
77+
}

0 commit comments

Comments
 (0)