mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
Initial open-source release
This commit is contained in:
commit
1a42152e6f
1199 changed files with 257054 additions and 0 deletions
60
packages/cli/src/io/mode.test.ts
Normal file
60
packages/cli/src/io/mode.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { KloCliIo } from '../cli-runtime.js';
|
||||
import { resolveOutputMode } from './mode.js';
|
||||
|
||||
function ioWith(isTTY: boolean | undefined): KloCliIo {
|
||||
return {
|
||||
stdout: { isTTY, write: () => {} },
|
||||
stderr: { write: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveOutputMode', () => {
|
||||
it('uses explicit value when provided', () => {
|
||||
expect(resolveOutputMode({ explicit: 'pretty', io: ioWith(false), env: {} })).toBe('pretty');
|
||||
expect(resolveOutputMode({ explicit: 'plain', io: ioWith(true), env: {} })).toBe('plain');
|
||||
expect(resolveOutputMode({ explicit: 'json', io: ioWith(true), env: {} })).toBe('json');
|
||||
});
|
||||
|
||||
it('json:true takes precedence over explicit value', () => {
|
||||
expect(resolveOutputMode({ explicit: 'pretty', json: true, io: ioWith(true), env: {} })).toBe('json');
|
||||
});
|
||||
|
||||
it('throws on unknown explicit value', () => {
|
||||
expect(() => resolveOutputMode({ explicit: 'fancy', io: ioWith(true), env: {} })).toThrow(/Invalid --output/);
|
||||
});
|
||||
|
||||
it('honors KLO_OUTPUT env var when no explicit value', () => {
|
||||
expect(resolveOutputMode({ io: ioWith(true), env: { KLO_OUTPUT: 'plain' } })).toBe('plain');
|
||||
expect(resolveOutputMode({ io: ioWith(false), env: { KLO_OUTPUT: 'pretty' } })).toBe('pretty');
|
||||
expect(resolveOutputMode({ io: ioWith(false), env: { KLO_OUTPUT: 'json' } })).toBe('json');
|
||||
});
|
||||
|
||||
it('throws on unknown KLO_OUTPUT', () => {
|
||||
expect(() => resolveOutputMode({ io: ioWith(true), env: { KLO_OUTPUT: 'fancy' } })).toThrow(/Invalid KLO_OUTPUT/);
|
||||
});
|
||||
|
||||
it('returns plain when CI is set to a truthy value', () => {
|
||||
expect(resolveOutputMode({ io: ioWith(true), env: { CI: 'true' } })).toBe('plain');
|
||||
expect(resolveOutputMode({ io: ioWith(true), env: { CI: '1' } })).toBe('plain');
|
||||
});
|
||||
|
||||
it('ignores CI when set to a falsy value', () => {
|
||||
expect(resolveOutputMode({ io: ioWith(true), env: { CI: '' } })).toBe('pretty');
|
||||
expect(resolveOutputMode({ io: ioWith(true), env: { CI: '0' } })).toBe('pretty');
|
||||
expect(resolveOutputMode({ io: ioWith(true), env: { CI: 'false' } })).toBe('pretty');
|
||||
});
|
||||
|
||||
it('returns pretty when stdout is a TTY and CI is not set', () => {
|
||||
expect(resolveOutputMode({ io: ioWith(true), env: {} })).toBe('pretty');
|
||||
});
|
||||
|
||||
it('returns plain when stdout is not a TTY', () => {
|
||||
expect(resolveOutputMode({ io: ioWith(false), env: {} })).toBe('plain');
|
||||
expect(resolveOutputMode({ io: ioWith(undefined), env: {} })).toBe('plain');
|
||||
});
|
||||
|
||||
it('explicit value beats KLO_OUTPUT env var', () => {
|
||||
expect(resolveOutputMode({ explicit: 'json', io: ioWith(true), env: { KLO_OUTPUT: 'plain' } })).toBe('json');
|
||||
});
|
||||
});
|
||||
40
packages/cli/src/io/mode.ts
Normal file
40
packages/cli/src/io/mode.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { KloCliIo } from '../cli-runtime.js';
|
||||
|
||||
export type KloOutputMode = 'pretty' | 'plain' | 'json';
|
||||
|
||||
const MODES: ReadonlySet<string> = new Set(['pretty', 'plain', 'json']);
|
||||
|
||||
export interface ResolveOutputModeArgs {
|
||||
explicit?: string;
|
||||
json?: boolean;
|
||||
io: KloCliIo;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export function resolveOutputMode(args: ResolveOutputModeArgs): KloOutputMode {
|
||||
if (args.json === true) {
|
||||
return 'json';
|
||||
}
|
||||
if (args.explicit !== undefined) {
|
||||
if (!MODES.has(args.explicit)) {
|
||||
throw new Error(`Invalid --output value: ${args.explicit}. Expected one of pretty, plain, json.`);
|
||||
}
|
||||
return args.explicit as KloOutputMode;
|
||||
}
|
||||
const env = args.env ?? process.env;
|
||||
const envMode = env.KLO_OUTPUT;
|
||||
if (envMode !== undefined && envMode !== '') {
|
||||
if (!MODES.has(envMode)) {
|
||||
throw new Error(`Invalid KLO_OUTPUT value: ${envMode}. Expected one of pretty, plain, json.`);
|
||||
}
|
||||
return envMode as KloOutputMode;
|
||||
}
|
||||
const ci = env.CI;
|
||||
if (ci !== undefined && ci !== '' && ci !== '0' && ci !== 'false') {
|
||||
return 'plain';
|
||||
}
|
||||
if (args.io.stdout.isTTY === true) {
|
||||
return 'pretty';
|
||||
}
|
||||
return 'plain';
|
||||
}
|
||||
171
packages/cli/src/io/print-list.test.ts
Normal file
171
packages/cli/src/io/print-list.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { KloCliIo } from '../cli-runtime.js';
|
||||
import { printList, type PrintListColumn } from './print-list.js';
|
||||
import { SYMBOLS } from './symbols.js';
|
||||
|
||||
function recorder(): { io: KloCliIo; out: () => string; err: () => string } {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: (chunk: string) => { stdout += chunk; } },
|
||||
stderr: { write: (chunk: string) => { stderr += chunk; } },
|
||||
},
|
||||
out: () => stdout,
|
||||
err: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
interface SlRow {
|
||||
connectionId: string;
|
||||
name: string;
|
||||
columnCount: number;
|
||||
measureCount: number;
|
||||
joinCount: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const SL_COLUMNS: ReadonlyArray<PrintListColumn<SlRow>> = [
|
||||
{ key: 'connectionId', label: 'CONNECTION', plain: '' },
|
||||
{ key: 'name', label: 'NAME', plain: '' },
|
||||
{ key: 'columnCount', label: 'COLS', plain: 'columns=', dim: true },
|
||||
{ key: 'measureCount', label: 'MEASURES', plain: 'measures=', dim: true },
|
||||
{ key: 'joinCount', label: 'JOINS', plain: 'joins=', dim: true },
|
||||
{ key: 'description', label: 'DESCRIPTION', plain: false, optional: true, dim: true },
|
||||
];
|
||||
|
||||
const ORDERS: SlRow = { connectionId: 'warehouse', name: 'orders', columnCount: 5, measureCount: 3, joinCount: 1 };
|
||||
const USERS: SlRow = { connectionId: 'warehouse', name: 'users', columnCount: 8, measureCount: 2, joinCount: 2, description: 'User profile + auth' };
|
||||
|
||||
describe('printList — plain mode', () => {
|
||||
it('emits one tab-separated row per item, skipping plain:false columns', () => {
|
||||
const r = recorder();
|
||||
printList<SlRow>({
|
||||
rows: [ORDERS, USERS],
|
||||
columns: SL_COLUMNS,
|
||||
mode: 'plain',
|
||||
command: 'sl list',
|
||||
emptyMessage: 'No sources',
|
||||
io: r.io,
|
||||
});
|
||||
expect(r.out()).toBe(
|
||||
'warehouse\torders\tcolumns=5\tmeasures=3\tjoins=1\n' +
|
||||
'warehouse\tusers\tcolumns=8\tmeasures=2\tjoins=2\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits nothing on empty list (preserves current sl list zero-row behavior)', () => {
|
||||
const r = recorder();
|
||||
printList<SlRow>({
|
||||
rows: [],
|
||||
columns: SL_COLUMNS,
|
||||
mode: 'plain',
|
||||
command: 'sl list',
|
||||
emptyMessage: 'No sources',
|
||||
io: r.io,
|
||||
});
|
||||
expect(r.out()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('printList — json mode', () => {
|
||||
it('emits the envelope with kind=list, data.items, and meta.command', () => {
|
||||
const r = recorder();
|
||||
printList<SlRow>({
|
||||
rows: [ORDERS, USERS],
|
||||
columns: SL_COLUMNS,
|
||||
mode: 'json',
|
||||
command: 'sl list',
|
||||
emptyMessage: 'No sources',
|
||||
io: r.io,
|
||||
});
|
||||
const written = r.out();
|
||||
expect(written.endsWith('\n')).toBe(true);
|
||||
const parsed = JSON.parse(written);
|
||||
expect(parsed).toEqual({
|
||||
kind: 'list',
|
||||
data: { items: [ORDERS, USERS] },
|
||||
meta: { command: 'sl list' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits an empty items array when no rows', () => {
|
||||
const r = recorder();
|
||||
printList<SlRow>({
|
||||
rows: [],
|
||||
columns: SL_COLUMNS,
|
||||
mode: 'json',
|
||||
command: 'sl list',
|
||||
emptyMessage: 'No sources',
|
||||
io: r.io,
|
||||
});
|
||||
expect(JSON.parse(r.out())).toEqual({
|
||||
kind: 'list',
|
||||
data: { items: [] },
|
||||
meta: { command: 'sl list' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(s: string): string {
|
||||
// Matches ESC [ ... m sequences emitted by node:util.styleText.
|
||||
return s.replace(/\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('printList — pretty mode', () => {
|
||||
it('renders a Clack-style header, grouped rows, and footer', () => {
|
||||
const r = recorder();
|
||||
printList<SlRow>({
|
||||
rows: [ORDERS, USERS],
|
||||
columns: SL_COLUMNS,
|
||||
groupBy: 'connectionId',
|
||||
mode: 'pretty',
|
||||
command: 'sl list',
|
||||
emptyMessage: 'No sources',
|
||||
io: r.io,
|
||||
});
|
||||
const out = stripAnsi(r.out());
|
||||
expect(out).toContain(`${SYMBOLS.barStart} sl list`);
|
||||
expect(out).toContain(`${SYMBOLS.group} warehouse`);
|
||||
expect(out).toContain('(2 sources)');
|
||||
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} orders\\s+5 cols ${escapeRegExp(SYMBOLS.middot)} 3 measures ${escapeRegExp(SYMBOLS.middot)} 1 join\\b`));
|
||||
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} users\\s+8 cols ${escapeRegExp(SYMBOLS.middot)} 2 measures ${escapeRegExp(SYMBOLS.middot)} 2 joins\\b`));
|
||||
expect(out).toContain(`${SYMBOLS.emDash} User profile + auth`);
|
||||
expect(out).toContain(`${SYMBOLS.barEnd} 2 sources`);
|
||||
});
|
||||
|
||||
it('renders an empty-state message when no rows', () => {
|
||||
const r = recorder();
|
||||
printList<SlRow>({
|
||||
rows: [],
|
||||
columns: SL_COLUMNS,
|
||||
groupBy: 'connectionId',
|
||||
mode: 'pretty',
|
||||
command: 'sl list',
|
||||
emptyMessage: 'No semantic-layer sources found in /tmp/proj',
|
||||
io: r.io,
|
||||
});
|
||||
const out = stripAnsi(r.out());
|
||||
expect(out).toContain(`${SYMBOLS.barStart} sl list`);
|
||||
expect(out).toContain(`${SYMBOLS.barEnd} No semantic-layer sources found in /tmp/proj`);
|
||||
});
|
||||
|
||||
it('singularizes the footer when there is one row', () => {
|
||||
const r = recorder();
|
||||
printList<SlRow>({
|
||||
rows: [ORDERS],
|
||||
columns: SL_COLUMNS,
|
||||
groupBy: 'connectionId',
|
||||
mode: 'pretty',
|
||||
command: 'sl list',
|
||||
emptyMessage: 'No sources',
|
||||
io: r.io,
|
||||
});
|
||||
const out = stripAnsi(r.out());
|
||||
expect(out).toContain(`${SYMBOLS.barEnd} 1 source`);
|
||||
});
|
||||
});
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
164
packages/cli/src/io/print-list.ts
Normal file
164
packages/cli/src/io/print-list.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import type { KloCliIo } from '../cli-runtime.js';
|
||||
import type { KloOutputMode } from './mode.js';
|
||||
import { bold, dim, SYMBOLS } from './symbols.js';
|
||||
|
||||
export interface PrintListColumn<Row> {
|
||||
key: keyof Row & string;
|
||||
label?: string;
|
||||
/**
|
||||
* Plain-mode rendering control.
|
||||
* - `string` (including `''`): emit `${plain}${value}` as a tab-separated cell.
|
||||
* - `false`: omit this column entirely in plain mode.
|
||||
* - `undefined`: same as `''`.
|
||||
*/
|
||||
plain?: string | false;
|
||||
/** Skip this column when the row's value is null / undefined / empty string. */
|
||||
optional?: boolean;
|
||||
/** Pretty-mode hint: render this column dim. */
|
||||
dim?: boolean;
|
||||
}
|
||||
|
||||
export interface PrintListArgs<Row> {
|
||||
rows: ReadonlyArray<Row>;
|
||||
columns: ReadonlyArray<PrintListColumn<Row>>;
|
||||
groupBy?: keyof Row & string;
|
||||
emptyMessage: string;
|
||||
command: string;
|
||||
mode: KloOutputMode;
|
||||
io: KloCliIo;
|
||||
}
|
||||
|
||||
export function printList<Row extends object>(args: PrintListArgs<Row>): void {
|
||||
switch (args.mode) {
|
||||
case 'json':
|
||||
printListJson(args);
|
||||
return;
|
||||
case 'plain':
|
||||
printListPlain(args);
|
||||
return;
|
||||
case 'pretty':
|
||||
printListPretty(args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
function printListPlain<Row extends object>(args: PrintListArgs<Row>): void {
|
||||
for (const row of args.rows) {
|
||||
const cells: string[] = [];
|
||||
for (const col of args.columns) {
|
||||
if (col.plain === false) continue;
|
||||
const value = row[col.key];
|
||||
if (col.optional && isEmpty(value)) continue;
|
||||
const prefix = col.plain ?? '';
|
||||
cells.push(`${prefix}${value === undefined || value === null ? '' : String(value)}`);
|
||||
}
|
||||
args.io.stdout.write(`${cells.join('\t')}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function printListJson<Row extends object>(args: PrintListArgs<Row>): void {
|
||||
const envelope = {
|
||||
kind: 'list',
|
||||
data: { items: args.rows },
|
||||
meta: { command: args.command },
|
||||
};
|
||||
args.io.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function pluralize(count: number, singular: string): string {
|
||||
return `${count} ${count === 1 ? singular : `${singular}s`}`;
|
||||
}
|
||||
|
||||
function metricCell(label: string, count: number): string {
|
||||
// "5 cols", "3 measures", "1 join" / "2 joins"
|
||||
// The label in PrintListColumn is uppercase; pretty mode lowercases it.
|
||||
const word = label.toLowerCase();
|
||||
return `${count} ${count === 1 ? singularize(word) : word}`;
|
||||
}
|
||||
|
||||
function singularize(word: string): string {
|
||||
if (word === 'joins') return 'join';
|
||||
if (word === 'measures') return 'measure';
|
||||
if (word === 'cols') return 'col';
|
||||
if (word.endsWith('s')) return word.slice(0, -1);
|
||||
return word;
|
||||
}
|
||||
|
||||
function groupRows<Row extends object>(
|
||||
rows: ReadonlyArray<Row>,
|
||||
key: keyof Row & string,
|
||||
): Map<string, Row[]> {
|
||||
const groups = new Map<string, Row[]>();
|
||||
for (const row of rows) {
|
||||
const value = String(row[key] ?? '');
|
||||
const bucket = groups.get(value);
|
||||
if (bucket) {
|
||||
bucket.push(row);
|
||||
} else {
|
||||
groups.set(value, [row]);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
|
||||
const { io, command, rows, columns, groupBy, emptyMessage } = args;
|
||||
|
||||
io.stdout.write(`${SYMBOLS.barStart} ${command}\n`);
|
||||
io.stdout.write(`${SYMBOLS.bar}\n`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
io.stdout.write(`${SYMBOLS.barEnd} ${emptyMessage}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Identify role of each column.
|
||||
// - First non-grouped, non-metric, non-optional column = "name" column (bolded)
|
||||
// - Columns with a `plain` prefix = metric columns (rendered as "N word")
|
||||
// - optional columns = trailing suffix (em-dash + value), only when value is present
|
||||
const nameCol = columns.find(
|
||||
(c) => c.key !== groupBy && !c.plain && !c.optional && c.plain !== false,
|
||||
);
|
||||
const metricCols = columns.filter((c) => typeof c.plain === 'string' && c.plain.length > 0);
|
||||
const optionalCols = columns.filter((c) => c.optional === true);
|
||||
|
||||
const buckets = groupBy ? groupRows(rows, groupBy) : new Map<string, Row[]>([['', [...rows]]]);
|
||||
|
||||
const nameWidth = nameCol
|
||||
? Math.max(...rows.map((r) => String(r[nameCol.key] ?? '').length))
|
||||
: 0;
|
||||
|
||||
for (const [groupValue, groupRowList] of buckets) {
|
||||
if (groupBy) {
|
||||
io.stdout.write(
|
||||
`${SYMBOLS.bar} ${SYMBOLS.group} ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, 'source')})`)}\n`,
|
||||
);
|
||||
}
|
||||
for (const row of groupRowList) {
|
||||
const segments: string[] = [];
|
||||
if (nameCol) {
|
||||
segments.push(String(row[nameCol.key] ?? '').padEnd(nameWidth));
|
||||
}
|
||||
const metrics = metricCols
|
||||
.map((c) => metricCell(c.label ?? c.key, Number(row[c.key] ?? 0)))
|
||||
.join(` ${SYMBOLS.middot} `);
|
||||
if (metrics.length > 0) segments.push(dim(metrics));
|
||||
const optionalSuffix = optionalCols
|
||||
.map((c) => row[c.key])
|
||||
.filter((v) => !isEmpty(v))
|
||||
.map((v) => `${SYMBOLS.emDash} ${dim(String(v))}`)
|
||||
.join(' ');
|
||||
if (optionalSuffix.length > 0) segments.push(optionalSuffix);
|
||||
|
||||
const indent = groupBy ? ' ' : ' ';
|
||||
io.stdout.write(`${SYMBOLS.bar}${indent}${SYMBOLS.item} ${segments.join(' ')}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
io.stdout.write(`${SYMBOLS.bar}\n`);
|
||||
io.stdout.write(`${SYMBOLS.barEnd} ${pluralize(rows.length, 'source')}\n`);
|
||||
}
|
||||
37
packages/cli/src/io/symbols.ts
Normal file
37
packages/cli/src/io/symbols.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { styleText } from 'node:util';
|
||||
|
||||
function detectUnicodeSupport(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (process.platform !== 'win32') {
|
||||
return env.TERM !== 'linux';
|
||||
}
|
||||
return (
|
||||
Boolean(env.WT_SESSION) ||
|
||||
env.TERM_PROGRAM === 'vscode' ||
|
||||
env.TERM === 'xterm-256color' ||
|
||||
env.TERM === 'alacritty'
|
||||
);
|
||||
}
|
||||
|
||||
const unicode = detectUnicodeSupport();
|
||||
|
||||
export const SYMBOLS = {
|
||||
bar: unicode ? '│' : '|',
|
||||
barStart: unicode ? '◇' : 'o',
|
||||
barEnd: unicode ? '└' : '—',
|
||||
group: unicode ? '●' : '*',
|
||||
item: unicode ? '◆' : '*',
|
||||
middot: unicode ? '·' : '-',
|
||||
emDash: unicode ? '—' : '--',
|
||||
} as const;
|
||||
|
||||
export function dim(text: string): string {
|
||||
return styleText('dim', text);
|
||||
}
|
||||
|
||||
export function bold(text: string): string {
|
||||
return styleText('bold', text);
|
||||
}
|
||||
|
||||
export function gray(text: string): string {
|
||||
return styleText('gray', text);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue