Files
qqq-cli/packages/cli/src/format.ts
T
james.maesandClaude Opus 5.5 ea79ddeb78 feat: reference CLI, generated UI API client, conformance suite and dev IdP
- @qrun-io/qqq-ui-api-client: typed client generated (openapi-typescript +
  openapi-fetch) from the merged UI API document; device-flow sign-in, refresh,
  UiApiError (BasicErrorResponseV1), SSE reader
- @qrun-io/qqq-cli: qqq login/whoami/apps/tables/query/get/run/jobs/tail,
  only through /qqq/v1 with Authorization: Bearer
- @qrun-io/qqq-ui-api-conformance: black-box suite + qqq-dev-idp
- scripts/merge-spec.mjs, Prism mock, CI and GitHub Packages publish workflows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:47:12 -05:00

79 lines
3.6 KiB
TypeScript

/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { components } from "@qrun-io/qqq-ui-api-client"
type Json = Record<string, unknown>
type Operator = NonNullable<components["schemas"]["FilterCriteria"]["operator"]>
const OPERATORS = new Set([
'EQUALS', 'NOT_EQUALS', 'NOT_EQUALS_OR_IS_NULL', 'IN', 'NOT_IN', 'IS_NULL_OR_IN', 'LIKE', 'NOT_LIKE', 'STARTS_WITH', 'ENDS_WITH',
'CONTAINS', 'CONTAINS_ALL', 'NOT_STARTS_WITH', 'NOT_ENDS_WITH', 'NOT_CONTAINS', 'LESS_THAN', 'LESS_THAN_OR_EQUALS', 'GREATER_THAN',
'GREATER_THAN_OR_EQUALS', 'IS_BLANK', 'IS_NOT_BLANK', 'BETWEEN', 'NOT_BETWEEN', 'TRUE', 'FALSE',
])
const SHORTHAND: Record<string, string> = {
'=': 'EQUALS', '==': 'EQUALS', '!=': 'NOT_EQUALS', '<': 'LESS_THAN', '<=': 'LESS_THAN_OR_EQUALS', '>': 'GREATER_THAN',
'>=': 'GREATER_THAN_OR_EQUALS', '~': 'CONTAINS',
}
/** A filter criterion of the UI API (FilterCriteria). */
export interface Criterion {
fieldName: string
operator: Operator
values: string[]
}
/**
* Parses `field OPERATOR value[,value]` (operators as in FilterCriteria, or =, !=, <, <=, >, >=, ~).
*
* @param text - The criterion.
* @param fields - The table's fields (to check the name).
*/
export function parseCriteria(text: string, fields: Record<string, unknown>): Criterion {
const match = /^\s*(\S+)\s+(\S+)\s*(.*)$/.exec(text)
if (!match) throw new Error(`Criteria must be "field OPERATOR value": ${text}`)
const [, fieldName, rawOperator, rest] = match
const operator = SHORTHAND[rawOperator] ?? rawOperator.toUpperCase()
if (!OPERATORS.has(operator)) throw new Error(`Unknown operator ${rawOperator}; use one of ${[...OPERATORS].join(', ')}`)
if (Object.keys(fields).length && !(fieldName in fields)) throw new Error(`No field ${fieldName}; fields: ${Object.keys(fields).join(', ')}`)
const values = rest === '' ? [] : ['IN', 'NOT_IN', 'IS_NULL_OR_IN', 'BETWEEN', 'NOT_BETWEEN', 'CONTAINS_ALL'].includes(operator) ? rest.split(',').map((v) => v.trim()) : [rest.trim()]
return { fieldName, operator: operator as Operator, values }
}
/** Parses `name=value` pairs. */
export function parseValues(pairs: string[]): Record<string, string> {
const values: Record<string, string> = {}
for (const pair of pairs) {
const at = pair.indexOf('=')
if (at < 1) throw new Error(`Values are name=value: ${pair}`)
values[pair.slice(0, at)] = pair.slice(at + 1)
}
return values
}
/** A record's value as text: its display value when the server sent one. */
export function valueText(record: Json, field: string): string {
const display = (record.displayValues as Json | undefined)?.[field]
const raw = (record.values as Json | undefined)?.[field]
const value = display ?? raw
if (value === null || value === undefined) return ''
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}
/**
* Formats rows as an aligned text table.
*
* @param rows - Objects.
* @param columns - Keys to show.
* @param headers - Optional header labels.
*/
export function formatTable(rows: Array<Record<string, unknown>>, columns: string[], headers: string[] = columns): string {
const cell = (v: unknown) => (v === null || v === undefined ? '' : String(v)).replace(/\s+/g, ' ').slice(0, 60)
const widths = columns.map((c, i) => Math.max(headers[i].length, ...rows.map((r) => cell(r[c]).length)))
const line = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd()
return [line(headers), line(widths.map((w) => '-'.repeat(w))), ...rows.map((r) => line(columns.map((c) => cell(r[c]))))].join('\n')
}