Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94b03f8654 | |||
| 5816d7d672 | |||
| 10a909fd2d | |||
| 5378863a22 | |||
| cd00a46688 | |||
| 7f82b6f800 | |||
| 1490579e7f | |||
| 454b94d340 | |||
| 5fc77cca08 |
@@ -307,10 +307,11 @@ opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-tra
|
||||
|
||||
## Output Formats
|
||||
|
||||
All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
|
||||
Registry-backed commands, `opencli list`, and `opencli plugin list` support `--format` / `-f` with `yaml` (default), `json`, `plain`, `md`, and `csv`. Terminal rich layout is removed; text formats stay available.
|
||||
|
||||
```bash
|
||||
opencli bilibili hot -f json # Pipe to jq or LLMs
|
||||
opencli bilibili hot -f plain # Plain text response
|
||||
opencli bilibili hot -f csv # Spreadsheet-friendly
|
||||
opencli bilibili hot -v # Verbose: show pipeline debug steps
|
||||
```
|
||||
|
||||
@@ -136,6 +136,38 @@ describe('built-in browser commands verbose wiring', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders generate output as summary by default', async () => {
|
||||
const program = createProgram('', '');
|
||||
|
||||
mockGenerateVerifiedFromUrl.mockResolvedValueOnce({
|
||||
status: 'success',
|
||||
adapter: { command: 'demo/top' },
|
||||
stats: { endpoint_count: 1, api_endpoint_count: 1, candidate_count: 1, verified: true, repair_attempted: false, explore_dir: '/tmp/demo' },
|
||||
});
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'generate', 'https://example.com']);
|
||||
|
||||
expect(mockRenderGenerateVerifiedSummary).toHaveBeenCalled();
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('generate-summary');
|
||||
});
|
||||
|
||||
it('renders generate output as json when requested', async () => {
|
||||
const program = createProgram('', '');
|
||||
|
||||
mockGenerateVerifiedFromUrl.mockResolvedValueOnce({
|
||||
status: 'blocked',
|
||||
reason: 'no-viable-api-surface',
|
||||
stage: 'explore',
|
||||
confidence: 'high',
|
||||
stats: { endpoint_count: 0, api_endpoint_count: 0, candidate_count: 0, verified: false, repair_attempted: false, explore_dir: '/tmp/demo' },
|
||||
});
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'generate', 'https://example.com', '--format', 'json']);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('"status": "blocked"'));
|
||||
expect(mockRenderGenerateVerifiedSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enables OPENCLI_VERBOSE for record via the real CLI command', async () => {
|
||||
const program = createProgram('', '');
|
||||
|
||||
|
||||
+7
-118
@@ -12,8 +12,8 @@ import { fileURLToPath } from 'node:url';
|
||||
import { Command } from 'commander';
|
||||
import { styleText } from 'node:util';
|
||||
import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js';
|
||||
import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
|
||||
import { serializeCommand, formatArgSummary } from './serialization.js';
|
||||
import { fullName, getRegistry } from './registry.js';
|
||||
import { serializeCommand } from './serialization.js';
|
||||
import { render as renderOutput } from './output.js';
|
||||
import { getBrowserFactory, browserSession } from './runtime.js';
|
||||
import { PKG_VERSION } from './version.js';
|
||||
@@ -199,74 +199,13 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
program
|
||||
.command('list')
|
||||
.description('List all available CLI commands')
|
||||
.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
|
||||
.option('-f, --format <fmt>', 'Output format: yaml, json, plain, md, csv', 'yaml')
|
||||
.option('--json', 'JSON output (deprecated)')
|
||||
.action((opts) => {
|
||||
const registry = getRegistry();
|
||||
const commands = [...new Set(registry.values())].sort((a, b) => fullName(a).localeCompare(fullName(b)));
|
||||
const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format;
|
||||
const isStructured = fmt === 'json' || fmt === 'yaml';
|
||||
|
||||
if (fmt !== 'table') {
|
||||
const rows = isStructured
|
||||
? commands.map(serializeCommand)
|
||||
: commands.map(c => ({
|
||||
command: fullName(c),
|
||||
site: c.site,
|
||||
name: c.name,
|
||||
aliases: c.aliases?.join(', ') ?? '',
|
||||
description: c.description,
|
||||
strategy: strategyLabel(c),
|
||||
browser: !!c.browser,
|
||||
args: formatArgSummary(c.args),
|
||||
}));
|
||||
renderOutput(rows, {
|
||||
fmt,
|
||||
columns: ['command', 'site', 'name', 'aliases', 'description', 'strategy', 'browser', 'args',
|
||||
...(isStructured ? ['columns', 'domain'] : [])],
|
||||
title: 'opencli/list',
|
||||
source: 'opencli list',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Table (default) — grouped by site
|
||||
const sites = new Map<string, CliCommand[]>();
|
||||
for (const cmd of commands) {
|
||||
const g = sites.get(cmd.site) ?? [];
|
||||
g.push(cmd);
|
||||
sites.set(cmd.site, g);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(styleText('bold', ' opencli') + styleText('dim', ' — available commands'));
|
||||
console.log();
|
||||
for (const [site, cmds] of sites) {
|
||||
console.log(styleText(['bold', 'cyan'], ` ${site}`));
|
||||
for (const cmd of cmds) {
|
||||
const label = strategyLabel(cmd);
|
||||
const tag = label === 'public'
|
||||
? styleText('green', '[public]')
|
||||
: styleText('yellow', `[${label}]`);
|
||||
const aliases = cmd.aliases?.length ? styleText('dim', ` (aliases: ${cmd.aliases.join(', ')})`) : '';
|
||||
console.log(` ${cmd.name} ${tag}${aliases}${cmd.description ? styleText('dim', ` — ${cmd.description}`) : ''}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
const externalClis = loadExternalClis();
|
||||
if (externalClis.length > 0) {
|
||||
console.log(styleText(['bold', 'cyan'], ' external CLIs'));
|
||||
for (const ext of externalClis) {
|
||||
const isInstalled = isBinaryInstalled(ext.binary);
|
||||
const tag = isInstalled ? styleText('green', '[installed]') : styleText('yellow', '[auto-install]');
|
||||
console.log(` ${ext.name} ${tag}${ext.description ? styleText('dim', ` — ${ext.description}`) : ''}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(styleText('dim', ` ${commands.length} built-in commands across ${sites.size} sites, ${externalClis.length} external CLIs`));
|
||||
console.log();
|
||||
const fmt = opts.json ? 'json' : opts.format;
|
||||
renderOutput(commands.map(serializeCommand), { fmt });
|
||||
});
|
||||
|
||||
// ── Built-in: validate / verify ───────────────────────────────────────────
|
||||
@@ -1094,60 +1033,10 @@ cli({
|
||||
pluginCmd
|
||||
.command('list')
|
||||
.description('List installed plugins')
|
||||
.option('-f, --format <fmt>', 'Output format: table, json', 'table')
|
||||
.option('-f, --format <fmt>', 'Output format: yaml, json, plain, md, csv', 'yaml')
|
||||
.action(async (opts) => {
|
||||
const { listPlugins } = await import('./plugin.js');
|
||||
const plugins = listPlugins();
|
||||
if (plugins.length === 0) {
|
||||
console.log(styleText('dim', ' No plugins installed.'));
|
||||
console.log(styleText('dim', ' Install one with: opencli plugin install github:user/repo'));
|
||||
return;
|
||||
}
|
||||
if (opts.format === 'json') {
|
||||
renderOutput(plugins, {
|
||||
fmt: 'json',
|
||||
columns: ['name', 'commands', 'source'],
|
||||
title: 'opencli/plugins',
|
||||
source: 'opencli plugin list',
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log();
|
||||
console.log(styleText('bold', ' Installed plugins'));
|
||||
console.log();
|
||||
|
||||
// Group by monorepo
|
||||
const standalone = plugins.filter((p) => !p.monorepoName);
|
||||
const monoGroups = new Map<string, typeof plugins>();
|
||||
for (const p of plugins) {
|
||||
if (!p.monorepoName) continue;
|
||||
const g = monoGroups.get(p.monorepoName) ?? [];
|
||||
g.push(p);
|
||||
monoGroups.set(p.monorepoName, g);
|
||||
}
|
||||
|
||||
for (const p of standalone) {
|
||||
const version = p.version ? styleText('green', ` @${p.version}`) : '';
|
||||
const desc = p.description ? styleText('dim', ` — ${p.description}`) : '';
|
||||
const cmds = p.commands.length > 0 ? styleText('dim', ` (${p.commands.join(', ')})`) : '';
|
||||
const src = p.source ? styleText('dim', ` ← ${p.source}`) : '';
|
||||
console.log(` ${styleText('cyan', p.name)}${version}${desc}${cmds}${src}`);
|
||||
}
|
||||
|
||||
for (const [mono, group] of monoGroups) {
|
||||
console.log();
|
||||
console.log(styleText(['bold', 'magenta'], ` 📦 ${mono}`) + styleText('dim', ' (monorepo)'));
|
||||
for (const p of group) {
|
||||
const version = p.version ? styleText('green', ` @${p.version}`) : '';
|
||||
const desc = p.description ? styleText('dim', ` — ${p.description}`) : '';
|
||||
const cmds = p.commands.length > 0 ? styleText('dim', ` (${p.commands.join(', ')})`) : '';
|
||||
console.log(` ${styleText('cyan', p.name)}${version}${desc}${cmds}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(styleText('dim', ` ${plugins.length} plugin(s) installed`));
|
||||
console.log();
|
||||
renderOutput(listPlugins(), { fmt: opts.format });
|
||||
});
|
||||
|
||||
pluginCmd
|
||||
|
||||
@@ -266,7 +266,7 @@ describe('commanderAdapter default formats', () => {
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('uses the command defaultFormat when the user keeps the default table format', async () => {
|
||||
it('preserves a command defaultFormat when the user does not override it', async () => {
|
||||
const program = new Command();
|
||||
const siteCmd = program.command('gemini');
|
||||
registerCommandToProgram(siteCmd, cmd);
|
||||
@@ -279,7 +279,7 @@ describe('commanderAdapter default formats', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('respects an explicit user format over the command defaultFormat', async () => {
|
||||
it('still respects an explicit user format override', async () => {
|
||||
const program = new Command();
|
||||
const siteCmd = program.command('gemini');
|
||||
registerCommandToProgram(siteCmd, cmd);
|
||||
@@ -291,6 +291,29 @@ describe('commanderAdapter default formats', () => {
|
||||
expect.objectContaining({ fmt: 'json' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to yaml for commands without an explicit defaultFormat', async () => {
|
||||
const yamlDefaultCmd: CliCommand = {
|
||||
site: 'demo',
|
||||
name: 'structured',
|
||||
description: 'Structured command',
|
||||
browser: false,
|
||||
args: [],
|
||||
columns: ['name'],
|
||||
func: vi.fn(),
|
||||
};
|
||||
const program = new Command();
|
||||
const siteCmd = program.command('demo');
|
||||
registerCommandToProgram(siteCmd, yamlDefaultCmd);
|
||||
|
||||
mockExecuteCommand.mockResolvedValueOnce([{ name: 'alice' }]);
|
||||
await program.parseAsync(['node', 'opencli', 'demo', 'structured']);
|
||||
|
||||
expect(mockRenderOutput).toHaveBeenCalledWith(
|
||||
[{ name: 'alice' }],
|
||||
expect.objectContaining({ fmt: 'yaml' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commanderAdapter error envelope output', () => {
|
||||
|
||||
+4
-14
@@ -50,7 +50,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
}
|
||||
}
|
||||
subCmd
|
||||
.option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
|
||||
.option('-f, --format <fmt>', 'Output format: yaml, json, plain, md, csv', 'yaml')
|
||||
.option('-v, --verbose', 'Debug output', false);
|
||||
|
||||
subCmd.addHelpText('after', formatRegistryHelpText(cmd));
|
||||
@@ -77,7 +77,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
const kwargs = prepareCommandArgs(cmd, rawKwargs);
|
||||
|
||||
const verbose = optionsRecord.verbose === true;
|
||||
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
|
||||
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'yaml';
|
||||
const formatExplicit = subCmd.getOptionValueSource('format') === 'cli';
|
||||
if (verbose) process.env.OPENCLI_VERBOSE = '1';
|
||||
if (cmd.deprecated) {
|
||||
@@ -90,24 +90,14 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
if (result === null || result === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
|
||||
if (!formatExplicit && format === 'table' && resolved.defaultFormat) {
|
||||
if (!formatExplicit && resolved.defaultFormat) {
|
||||
format = resolved.defaultFormat;
|
||||
}
|
||||
|
||||
if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
|
||||
log.warn('Command returned an empty result.');
|
||||
}
|
||||
renderOutput(result, {
|
||||
fmt: format,
|
||||
fmtExplicit: formatExplicit,
|
||||
columns: resolved.columns,
|
||||
title: `${resolved.site}/${resolved.name}`,
|
||||
elapsed: (Date.now() - startTime) / 1000,
|
||||
source: fullName(resolved),
|
||||
footerExtra: resolved.footerExtra?.(kwargs),
|
||||
});
|
||||
renderOutput(result, { fmt: format, columns: resolved.columns });
|
||||
} catch (err) {
|
||||
renderError(err, fullName(cmd), optionsRecord.verbose === true);
|
||||
process.exitCode = resolveExitCode(err);
|
||||
|
||||
+31
-11
@@ -14,20 +14,20 @@ describe('output TTY detection', () => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('outputs YAML in non-TTY when format is default table', () => {
|
||||
it('defaults to YAML in non-TTY', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
||||
// commanderAdapter always passes fmt:'table' as default — this must still trigger downgrade
|
||||
render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
|
||||
render([{ name: 'alice', score: 10 }]);
|
||||
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
|
||||
expect(out).toContain('name: alice');
|
||||
expect(out).toContain('score: 10');
|
||||
});
|
||||
|
||||
it('outputs table in TTY when format is default table', () => {
|
||||
it('defaults to YAML in TTY too', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
|
||||
render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
|
||||
render([{ name: 'alice', score: 10 }]);
|
||||
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
|
||||
expect(out).toContain('alice');
|
||||
expect(out).toContain('name: alice');
|
||||
expect(out).toContain('score: 10');
|
||||
});
|
||||
|
||||
it('respects explicit -f json even in non-TTY', () => {
|
||||
@@ -37,12 +37,32 @@ describe('output TTY detection', () => {
|
||||
expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
|
||||
});
|
||||
|
||||
it('explicit -f table overrides non-TTY auto-downgrade', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
||||
render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] });
|
||||
it('renders plain output for chat-style single-field rows', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
|
||||
render([{ response: 'hello' }], { fmt: 'plain' });
|
||||
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
|
||||
// Should be table output, not YAML
|
||||
expect(out).not.toContain('name: alice');
|
||||
expect(out).toContain('hello');
|
||||
});
|
||||
|
||||
it('renders markdown tables as plain text', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
|
||||
render([{ name: 'alice' }], { fmt: 'md' });
|
||||
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
|
||||
expect(out).toContain('| name |');
|
||||
});
|
||||
|
||||
it('renders csv as plain text', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
|
||||
render([{ name: 'alice' }], { fmt: 'csv' });
|
||||
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
|
||||
expect(out).toContain('name');
|
||||
expect(out).toContain('alice');
|
||||
});
|
||||
|
||||
it('falls back to YAML for removed table format', () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
|
||||
render([{ name: 'alice' }], { fmt: 'table' });
|
||||
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
|
||||
expect(out).toContain('name: alice');
|
||||
});
|
||||
});
|
||||
|
||||
+26
-67
@@ -1,20 +1,12 @@
|
||||
/**
|
||||
* Output formatting: table, JSON, Markdown, CSV, YAML.
|
||||
* Output formatting with structured defaults and text-only opt-in formats.
|
||||
*/
|
||||
|
||||
import { styleText } from 'node:util';
|
||||
import Table from 'cli-table3';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export interface RenderOptions {
|
||||
fmt?: string;
|
||||
/** True when the user explicitly passed -f on the command line */
|
||||
fmtExplicit?: boolean;
|
||||
columns?: string[];
|
||||
title?: string;
|
||||
elapsed?: number;
|
||||
source?: string;
|
||||
footerExtra?: string;
|
||||
}
|
||||
|
||||
function normalizeRows(data: unknown): Record<string, unknown>[] {
|
||||
@@ -28,64 +20,34 @@ function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): s
|
||||
}
|
||||
|
||||
export function render(data: unknown, opts: RenderOptions = {}): void {
|
||||
let fmt = opts.fmt ?? 'table';
|
||||
// Non-TTY auto-downgrade only when format was NOT explicitly passed by user.
|
||||
if (!opts.fmtExplicit) {
|
||||
if (fmt === 'table' && !process.stdout.isTTY) fmt = 'yaml';
|
||||
}
|
||||
const fmt = opts.fmt ?? 'yaml';
|
||||
if (data === null || data === undefined) {
|
||||
console.log(data);
|
||||
return;
|
||||
}
|
||||
switch (fmt) {
|
||||
case 'json': renderJson(data); break;
|
||||
case 'plain': renderPlain(data, opts); break;
|
||||
case 'md': case 'markdown': renderMarkdown(data, opts); break;
|
||||
case 'plain': renderPlain(data); break;
|
||||
case 'md':
|
||||
case 'markdown': renderMarkdown(data, opts); break;
|
||||
case 'csv': renderCsv(data, opts); break;
|
||||
case 'yaml': case 'yml': renderYaml(data); break;
|
||||
default: renderTable(data, opts); break;
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
case 'table':
|
||||
default:
|
||||
renderYaml(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(data: unknown, opts: RenderOptions): void {
|
||||
const rows = normalizeRows(data);
|
||||
if (!rows.length) { console.log(styleText('dim', '(no data)')); return; }
|
||||
const columns = resolveColumns(rows, opts);
|
||||
|
||||
const header = columns.map(c => capitalize(c));
|
||||
const table = new Table({
|
||||
head: header.map(h => styleText('bold', h)),
|
||||
style: { head: [], border: [] },
|
||||
wordWrap: true,
|
||||
wrapOnWordBoundary: true,
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
table.push(columns.map(c => {
|
||||
const v = (row as Record<string, unknown>)[c];
|
||||
return v === null || v === undefined ? '' : String(v);
|
||||
}));
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (opts.title) console.log(styleText('dim', ` ${opts.title}`));
|
||||
console.log(table.toString());
|
||||
const footer: string[] = [];
|
||||
footer.push(`${rows.length} items`);
|
||||
if (opts.elapsed) footer.push(`${opts.elapsed.toFixed(1)}s`);
|
||||
if (opts.source) footer.push(opts.source);
|
||||
if (opts.footerExtra) footer.push(opts.footerExtra);
|
||||
console.log(styleText('dim', footer.join(' · ')));
|
||||
}
|
||||
|
||||
function renderJson(data: unknown): void {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
function renderPlain(data: unknown, opts: RenderOptions): void {
|
||||
|
||||
function renderPlain(data: unknown): void {
|
||||
const rows = normalizeRows(data);
|
||||
if (!rows.length) return;
|
||||
|
||||
// Single-row single-field shortcuts for chat-style commands.
|
||||
if (rows.length === 1) {
|
||||
const row = rows[0];
|
||||
const entries = Object.entries(row);
|
||||
@@ -99,23 +61,23 @@ function renderPlain(data: unknown, opts: RenderOptions): void {
|
||||
}
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const entries = Object.entries(row).filter(([, value]) => value !== undefined && value !== null && String(value) !== '');
|
||||
entries.forEach(([key, value]) => {
|
||||
console.log(`${key}: ${value}`);
|
||||
});
|
||||
Object.entries(row)
|
||||
.filter(([, value]) => value !== undefined && value !== null && String(value) !== '')
|
||||
.forEach(([key, value]) => {
|
||||
console.log(`${key}: ${value}`);
|
||||
});
|
||||
if (index < rows.length - 1) console.log('');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function renderMarkdown(data: unknown, opts: RenderOptions): void {
|
||||
const rows = normalizeRows(data);
|
||||
if (!rows.length) return;
|
||||
const columns = resolveColumns(rows, opts);
|
||||
console.log('| ' + columns.join(' | ') + ' |');
|
||||
console.log('| ' + columns.map(() => '---').join(' | ') + ' |');
|
||||
console.log(`| ${columns.join(' | ')} |`);
|
||||
console.log(`| ${columns.map(() => '---').join(' | ')} |`);
|
||||
for (const row of rows) {
|
||||
console.log('| ' + columns.map(c => String((row as Record<string, unknown>)[c] ?? '')).join(' | ') + ' |');
|
||||
console.log(`| ${columns.map((column) => String(row[column] ?? '')).join(' | ')} |`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,10 +87,11 @@ function renderCsv(data: unknown, opts: RenderOptions): void {
|
||||
const columns = resolveColumns(rows, opts);
|
||||
console.log(columns.join(','));
|
||||
for (const row of rows) {
|
||||
console.log(columns.map(c => {
|
||||
const v = String((row as Record<string, unknown>)[c] ?? '');
|
||||
return v.includes(',') || v.includes('"') || v.includes('\n') || v.includes('\r')
|
||||
? `"${v.replace(/"/g, '""')}"` : v;
|
||||
console.log(columns.map((column) => {
|
||||
const value = String(row[column] ?? '');
|
||||
return value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')
|
||||
? `"${value.replace(/"/g, '""')}"`
|
||||
: value;
|
||||
}).join(','));
|
||||
}
|
||||
}
|
||||
@@ -136,7 +99,3 @@ function renderCsv(data: unknown, opts: RenderOptions): void {
|
||||
function renderYaml(data: unknown): void {
|
||||
console.log(yaml.dump(data, { sortKeys: false, lineWidth: 120, noRefs: true }));
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
@@ -24,21 +24,12 @@ describe('management commands E2E', () => {
|
||||
expect(data[0]).toHaveProperty('browser');
|
||||
});
|
||||
|
||||
it('list default table format renders sites', async () => {
|
||||
it('list defaults to yaml', async () => {
|
||||
const { stdout, code } = await runCli(['list']);
|
||||
expect(code).toBe(0);
|
||||
// Should contain site names
|
||||
expect(stdout).toContain('hackernews');
|
||||
expect(stdout).toContain('bilibili');
|
||||
expect(stdout).toContain('twitter');
|
||||
expect(stdout).toContain('commands across');
|
||||
});
|
||||
|
||||
it('list -f yaml produces valid yaml', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'yaml']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('command:');
|
||||
expect(stdout).toContain('site:');
|
||||
expect(stdout).toContain('hackernews');
|
||||
});
|
||||
|
||||
it('list -f csv produces valid csv', async () => {
|
||||
@@ -51,8 +42,7 @@ describe('management commands E2E', () => {
|
||||
it('list -f md produces markdown table', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'md']);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('|');
|
||||
expect(stdout).toContain('command');
|
||||
expect(stdout).toContain('| command |');
|
||||
});
|
||||
|
||||
// ── validate ──
|
||||
|
||||
@@ -30,13 +30,11 @@ describe('output formats E2E', () => {
|
||||
}
|
||||
|
||||
if (fmt === 'csv') {
|
||||
// CSV should have a header row + data rows
|
||||
const lines = stdout.trim().split('\n');
|
||||
expect(lines.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
|
||||
if (fmt === 'md') {
|
||||
// Markdown table should have pipe characters
|
||||
expect(stdout).toContain('| command |');
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
Reference in New Issue
Block a user