mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
feat(cli): padronizar saída emit() — cli-table3/csv-stringify/schema (Fase 1.7)
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { apiFetch, isServerUp } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { modelListSchema } from "../schemas/output-schemas.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
export function registerModels(program) {
|
||||
@@ -66,35 +68,25 @@ export async function runModelsCommand(provider, opts = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.json || opts.output === "json") {
|
||||
console.log(JSON.stringify(models, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
console.log(t("models.noModels"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log("\x1b[36m" + " Model".padEnd(45) + "Provider".padEnd(20) + "Context\x1b[0m");
|
||||
console.log(
|
||||
"\x1b[2m " + "─".repeat(44) + " " + "─".repeat(19) + " " + "─".repeat(10) + "\x1b[0m"
|
||||
);
|
||||
const normalized = models.map((m) => ({
|
||||
id: m.id || m.name || "unknown",
|
||||
provider: m.provider || "unknown",
|
||||
contextWindow: String(m.context_length || m.max_tokens || m.contextWindow || "-"),
|
||||
}));
|
||||
|
||||
const displayModels = models.slice(0, 50);
|
||||
for (const model of displayModels) {
|
||||
const name = (model.id || model.name || "unknown").slice(0, 43);
|
||||
const prov = (model.provider || "unknown").slice(0, 18);
|
||||
const context = model.context_length || model.max_tokens || model.contextWindow || "-";
|
||||
console.log(` ${name.padEnd(45)}${prov.padEnd(20)}${String(context).padEnd(10)}`);
|
||||
}
|
||||
const display = normalized.slice(0, 50);
|
||||
emit(display, opts, modelListSchema);
|
||||
|
||||
console.log();
|
||||
if (models.length > 50) {
|
||||
console.log(`\x1b[2m ... and ${models.length - 50} more. Use --json for full list.\x1b[0m`);
|
||||
console.log(
|
||||
`\x1b[2m ... and ${models.length - 50} more. Use --output json for full list.\x1b[0m`
|
||||
);
|
||||
}
|
||||
console.log(` \x1b[32mTotal: ${models.length} models\x1b[0m`);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import Table from "cli-table3";
|
||||
import { stringify as csvStringify } from "csv-stringify/sync";
|
||||
|
||||
const MASK_RE = /sk-[A-Za-z0-9]{4,}/g;
|
||||
|
||||
export const EXIT_CODES = Object.freeze({
|
||||
@@ -17,50 +20,71 @@ export function maskSecret(value) {
|
||||
|
||||
function toRows(data) {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data !== null && typeof data === "object") return [data];
|
||||
if (data !== null && typeof data === "object") return data.items ? data.items : [data];
|
||||
return [{ value: data }];
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
function pickFormat(opts) {
|
||||
if (opts.output) return opts.output;
|
||||
if (opts.json) return "json";
|
||||
if (!process.stdout.isTTY) return "json";
|
||||
return "table";
|
||||
}
|
||||
|
||||
function inferSchema(sample) {
|
||||
return Object.keys(sample).map((k) => ({ key: k, header: k }));
|
||||
}
|
||||
|
||||
function formatCell(v, col) {
|
||||
if (v == null) return "";
|
||||
if (col.formatter) return col.formatter(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function renderTable(rows, schema, opts = {}) {
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("(empty)\n");
|
||||
return;
|
||||
}
|
||||
const keys = Array.from(
|
||||
rows.reduce((acc, row) => {
|
||||
for (const k of Object.keys(row)) acc.add(k);
|
||||
return acc;
|
||||
}, new Set())
|
||||
);
|
||||
|
||||
const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => String(r[k] ?? "").length)));
|
||||
|
||||
const sep = widths.map((w) => "-".repeat(w)).join("-+-");
|
||||
const header = keys.map((k, i) => k.padEnd(widths[i])).join(" | ");
|
||||
|
||||
process.stdout.write(`${header}\n${sep}\n`);
|
||||
const cols = schema || inferSchema(rows[0]);
|
||||
const quiet = opts.quiet === true;
|
||||
const widths = cols.map((c) => c.width || null);
|
||||
const hasWidths = widths.some((w) => w !== null);
|
||||
const tableOpts = {
|
||||
head: quiet ? [] : cols.map((c) => c.header),
|
||||
style: { head: quiet ? [] : ["cyan"] },
|
||||
};
|
||||
if (hasWidths) tableOpts.colWidths = widths;
|
||||
const table = new Table(tableOpts);
|
||||
for (const row of rows) {
|
||||
const line = keys.map((k, i) => String(row[k] ?? "").padEnd(widths[i])).join(" | ");
|
||||
process.stdout.write(`${line}\n`);
|
||||
table.push(cols.map((c) => formatCell(row[c.key], c)));
|
||||
}
|
||||
process.stdout.write(table.toString() + "\n");
|
||||
}
|
||||
|
||||
function renderCsv(rows) {
|
||||
if (rows.length === 0) return;
|
||||
const keys = Object.keys(rows[0]);
|
||||
process.stdout.write(keys.map(csvEscape).join(",") + "\n");
|
||||
for (const row of rows) {
|
||||
process.stdout.write(keys.map((k) => csvEscape(String(row[k] ?? ""))).join(",") + "\n");
|
||||
function renderCsv(rows, schema) {
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("\n");
|
||||
return;
|
||||
}
|
||||
const cols = schema || inferSchema(rows[0]);
|
||||
const headers = cols.map((c) => c.header);
|
||||
const records = rows.map((r) => cols.map((c) => formatCell(r[c.key], c)));
|
||||
process.stdout.write(csvStringify([headers, ...records]));
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
if (/[",\r\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
|
||||
return value;
|
||||
function renderJsonl(rows) {
|
||||
for (const row of rows) process.stdout.write(JSON.stringify(row) + "\n");
|
||||
}
|
||||
|
||||
export function emit(data, opts = {}) {
|
||||
const format = opts.output || "table";
|
||||
/**
|
||||
* Emit structured data to stdout in the requested format.
|
||||
* @param {unknown} data - Array of objects or single object
|
||||
* @param {object} opts - Options: { output, json, quiet }
|
||||
* @param {Array|null} schema - Column definitions: [{ key, header, width?, formatter? }]
|
||||
*/
|
||||
export function emit(data, opts = {}, schema = null) {
|
||||
const format = pickFormat(opts);
|
||||
const rows = toRows(data);
|
||||
|
||||
switch (format) {
|
||||
@@ -68,13 +92,13 @@ export function emit(data, opts = {}) {
|
||||
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
||||
break;
|
||||
case "jsonl":
|
||||
for (const row of rows) process.stdout.write(JSON.stringify(row) + "\n");
|
||||
renderJsonl(rows);
|
||||
break;
|
||||
case "csv":
|
||||
renderCsv(rows);
|
||||
renderCsv(rows, schema);
|
||||
break;
|
||||
default:
|
||||
renderTable(rows);
|
||||
renderTable(rows, schema, opts);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
56
bin/cli/schemas/output-schemas.mjs
Normal file
56
bin/cli/schemas/output-schemas.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
const checkmark = (v) => (v ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m");
|
||||
const bullet = (v) => (v ? "\x1b[32m●\x1b[0m" : "");
|
||||
const statusColor = (v) => {
|
||||
if (!v) return "";
|
||||
const s = String(v).toLowerCase();
|
||||
if (s === "ok" || s === "active" || s === "success") return `\x1b[32m${v}\x1b[0m`;
|
||||
if (s === "warn" || s === "degraded") return `\x1b[33m${v}\x1b[0m`;
|
||||
return `\x1b[31m${v}\x1b[0m`;
|
||||
};
|
||||
|
||||
export const providerListSchema = [
|
||||
{ key: "provider", header: "Provider", width: 20 },
|
||||
{ key: "name", header: "Name", width: 30 },
|
||||
{ key: "isActive", header: "Active", width: 8, formatter: checkmark },
|
||||
{ key: "testStatus", header: "Status", width: 12, formatter: statusColor },
|
||||
{ key: "lastTested", header: "Last Test", width: 22 },
|
||||
];
|
||||
|
||||
export const comboListSchema = [
|
||||
{ key: "name", header: "Name", width: 26 },
|
||||
{ key: "strategy", header: "Strategy", width: 18 },
|
||||
{ key: "enabled", header: "Enabled", width: 9, formatter: checkmark },
|
||||
{ key: "active", header: "Active", width: 8, formatter: bullet },
|
||||
];
|
||||
|
||||
export const modelListSchema = [
|
||||
{ key: "id", header: "Model ID", width: 46 },
|
||||
{ key: "provider", header: "Provider", width: 20 },
|
||||
{ key: "contextWindow", header: "Context", width: 10 },
|
||||
];
|
||||
|
||||
export const healthSchema = [
|
||||
{ key: "component", header: "Component", width: 26 },
|
||||
{ key: "status", header: "Status", width: 10, formatter: statusColor },
|
||||
{ key: "message", header: "Message" },
|
||||
];
|
||||
|
||||
export const quotaSchema = [
|
||||
{ key: "provider", header: "Provider", width: 20 },
|
||||
{ key: "used", header: "Used", width: 12 },
|
||||
{ key: "limit", header: "Limit", width: 12 },
|
||||
{ key: "remaining", header: "Remaining", width: 14 },
|
||||
{ key: "resetAt", header: "Resets At", width: 22 },
|
||||
];
|
||||
|
||||
export const keysListSchema = [
|
||||
{ key: "provider", header: "Provider", width: 20 },
|
||||
{ key: "name", header: "Name", width: 30 },
|
||||
{ key: "isActive", header: "Active", width: 8, formatter: checkmark },
|
||||
{ key: "testStatus", header: "Status", width: 12, formatter: statusColor },
|
||||
];
|
||||
|
||||
export const cacheStatusSchema = [
|
||||
{ key: "key", header: "Metric", width: 28 },
|
||||
{ key: "value", header: "Value" },
|
||||
];
|
||||
75
package-lock.json
generated
75
package-lock.json
generated
@@ -22,7 +22,9 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"bottleneck": "^2.19.5",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"csv-stringify": "^6.7.0",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.3",
|
||||
"fuse.js": "^7.3.0",
|
||||
@@ -483,6 +485,16 @@
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
@@ -5326,7 +5338,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6160,6 +6171,62 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
|
||||
"integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "10.* || >= 12.*"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@colors/colors": "1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
|
||||
@@ -6529,6 +6596,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csv-stringify": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz",
|
||||
"integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
"version": "3.33.3",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz",
|
||||
|
||||
@@ -135,7 +135,9 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"bottleneck": "^2.19.5",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"csv-stringify": "^6.7.0",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.3",
|
||||
"fuse.js": "^7.3.0",
|
||||
|
||||
83
tests/unit/cli-output.test.ts
Normal file
83
tests/unit/cli-output.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
function captureStdout(fn: () => void): string {
|
||||
const chunks: string[] = [];
|
||||
const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = (chunk: string | Uint8Array) => {
|
||||
chunks.push(typeof chunk === "string" ? chunk : chunk.toString());
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
test("emit json format outputs valid JSON", async () => {
|
||||
const { emit } = await import("../../bin/cli/output.mjs");
|
||||
const data = [{ id: "m1", provider: "openai" }];
|
||||
const out = captureStdout(() => emit(data, { output: "json" }));
|
||||
const parsed = JSON.parse(out);
|
||||
assert.deepEqual(parsed, data);
|
||||
});
|
||||
|
||||
test("emit jsonl format outputs one JSON per line", async () => {
|
||||
const { emit } = await import("../../bin/cli/output.mjs");
|
||||
const data = [{ id: "m1" }, { id: "m2" }];
|
||||
const out = captureStdout(() => emit(data, { output: "jsonl" }));
|
||||
const lines = out.trim().split("\n").filter(Boolean);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.equal(JSON.parse(lines[0]).id, "m1");
|
||||
assert.equal(JSON.parse(lines[1]).id, "m2");
|
||||
});
|
||||
|
||||
test("emit csv format outputs header + rows", async () => {
|
||||
const { emit } = await import("../../bin/cli/output.mjs");
|
||||
const schema = [
|
||||
{ key: "id", header: "Model" },
|
||||
{ key: "provider", header: "Provider" },
|
||||
];
|
||||
const data = [{ id: "gpt-4", provider: "openai" }];
|
||||
const out = captureStdout(() => emit(data, { output: "csv" }, schema));
|
||||
const lines = out.trim().split("\n");
|
||||
assert.equal(lines[0], "Model,Provider");
|
||||
assert.equal(lines[1], "gpt-4,openai");
|
||||
});
|
||||
|
||||
test("emit table format outputs non-empty content", async () => {
|
||||
const { emit } = await import("../../bin/cli/output.mjs");
|
||||
const data = [{ id: "m1", provider: "anthropic" }];
|
||||
const out = captureStdout(() => emit(data, { output: "table" }));
|
||||
assert.ok(out.length > 0);
|
||||
assert.ok(out.includes("m1"));
|
||||
assert.ok(out.includes("anthropic"));
|
||||
});
|
||||
|
||||
test("emit auto-detects json when stdout is not TTY", async () => {
|
||||
const { emit } = await import("../../bin/cli/output.mjs");
|
||||
const data = [{ id: "x" }];
|
||||
const origIsTTY = process.stdout.isTTY;
|
||||
// @ts-ignore
|
||||
process.stdout.isTTY = false;
|
||||
const out = captureStdout(() => emit(data, {}));
|
||||
// @ts-ignore
|
||||
process.stdout.isTTY = origIsTTY;
|
||||
const parsed = JSON.parse(out);
|
||||
assert.ok(Array.isArray(parsed));
|
||||
});
|
||||
|
||||
test("emit empty array outputs (empty) for table", async () => {
|
||||
const { emit } = await import("../../bin/cli/output.mjs");
|
||||
const out = captureStdout(() => emit([], { output: "table" }));
|
||||
assert.ok(out.includes("empty"));
|
||||
});
|
||||
|
||||
test("maskSecret redacts sk- keys", async () => {
|
||||
const { maskSecret } = await import("../../bin/cli/output.mjs");
|
||||
const masked = maskSecret("prefix sk-abcdefgh1234 suffix");
|
||||
assert.ok(!masked.includes("sk-abcdefgh1234"));
|
||||
assert.ok(masked.includes("sk-ab***1234"));
|
||||
});
|
||||
Reference in New Issue
Block a user