cli: noun-verb grammar on the kernel, output formats, JMESPath, CRUD, api escape hatch

qqq <noun> <verb> for table (list, describe, query, count, get, create, update, delete, export),
process (list, describe, run) and job (list, get, logs, cancel, retry), with the 0.1 flat
commands kept as hidden aliases. Global --output json|table|text|csv|yaml, --json, --query
(JMESPath), --fields, --yes and --trace work before or after the subcommand. Filters take
--where, --group (AND/OR with parentheses), --or, --order, --filter-json, --print-filter and
--count; queries page with --limit, --skip and --all. qqq api <operationId> calls any
operation from the generated index (--path, --param, --body-json, --form, --bytes, --out).
Exit codes: 2 usage and bad filters, 3 auth, 4 unreachable or rate limited, 5 not found,
6 validation, 70 server error. The realtime stream can carry a session cookie for tail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-10-31 17:03:18 -05:00
1 parent a0cc4d8be2
commit 8ed694cd2f
25 files changed
+1273 -407

No files matched your search

+33 -15
View File
@@ -76,27 +76,45 @@ not hit this. Windows binary is cross-compiled and unverified.
## CLI (`qqq`, M1 in progress)
`qqq` on your PATH runs the CLI from this checkout with Bun (no build step; the client package is rebuilt
when its source changed). Sign in once per server; the mock sample server gets a server session that is
sent as a cookie.
`qqq` on your PATH runs the CLI from this checkout with Bun (no build step; the client and kernel packages
are rebuilt when their source changed). Grammar is `qqq <noun> <verb>`; the 0.1 flat commands (`apps`,
`tables`, `query`, `get`, `run`, `jobs`, `tail`) still work as aliases.
```sh
qqq login --server http://localhost:8077 # MOCK: server session. OAUTH2/AUTH_0: device flow.
qqq login --server http://localhost:8077 # MOCK: server session (cookie). OAUTH2/AUTH_0: device flow.
qqq whoami
qqq apps
qqq tables --app peopleApp
qqq query person --where "lastName STARTS_WITH S" --order -id --limit 5
qqq query person --where "birthDate GREATER_THAN 1990-06-01" --fields id,firstName,birthDate --json
qqq get pet 3
qqq run sleepInteractive -v sleepMillis=1500 --timeout 0 # async job, polled
qqq tail # realtime stream, Ctrl-C to stop
qqq --help ; qqq query --help
qqq table list --app peopleApp
qqq table describe person
qqq table query person -w "lastName STARTS_WITH S" -o -id -l 5
qqq table query person -g "(firstName = Avery OR firstName = Blair) AND birthDate > now(-40y)"
qqq table query person -w "annualSalary >= 300000" --count
qqq table query person -w "createDate > thisPeriod(month)" --print-filter # the QueryFilter JSON
qqq table query person -l 2 --output csv --fields id,firstName,email
qqq table query person --output yaml -Q '[].{id: values.id, name: recordLabel}' # JMESPath, as aws --query
qqq table get pet 3
qqq table create pet --generate-input-skeleton
qqq table create pet --set name=Pixel --set personId=2 --set speciesId=2
qqq table update pet 7 --set name=Pixel2
qqq table delete pet 7 # asks; --yes skips
qqq table export person --format xlsx --out people.xlsx
qqq process list ; qqq process describe sleepInteractive
qqq process run sleepInteractive -v sleepMillis=1500 # async job, polled, progress on stderr
qqq process run clonePeople -r 1,2 # records by primary key
qqq api --list ; qqq api getTableMetaData --path tableName=person -Q 'fields.*.label'
qqq search Avery ; qqq tail
```
`--server` or `QQQ_SERVER` picks the server; `--json` prints machine output. `qqq jobs` reports that
this sample server has no jobs operations. Coming in M1: the noun-verb grammar (`qqq table query …`)
with the current commands kept as aliases, `--output json|table|text|csv|yaml`, `--query` (JMESPath),
create/update/delete, `qqq api <operationId>`, non-interactive process inputs, completion, keyring.
Global options go anywhere: `--server`, `--output json|table|text|csv|yaml` (`-O`), `--json`, `--query` (`-Q`),
`--fields`, `--yes`, `--trace`. Table on a terminal, JSON in a pipe, `QQQ_OUTPUT` overrides. Exit codes:
0 ok, 1 error, 2 usage or bad filter, 3 auth, 4 server unreachable or rate limited, 5 not found,
6 validation, 70 server error. `qqq job …` says this sample server has no jobs operations.
Filter values can be expressions: `now()`, `now(-7d)`, `thisPeriod(month)`, `lastPeriod(week)`,
`startOf(quarter,-1)`, `me()`, `${variable.x}`; quote values with commas: `city IN "St. Louis, MO",Chicago`.
Still to come in M1: profiles and keyring, metadata cache and shell completion, the process engine with
form adjusters (contingent fields) and bulk load, widgets and reports, preferences and help.
## Reporting what you find
+1
View File
@@ -2957,6 +2957,7 @@
"version": "0.2.0-dev",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-core": "0.1.0-dev",
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
"commander": "15.0.0"
},
+1 -1
View File
@@ -15,7 +15,7 @@
],
"scripts": {
"spec:merge": "node scripts/merge-spec.mjs --out packages/ui-api-client/openapi/qqq-v1.json",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client && node scripts/gen-operations.mjs",
"build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-core && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli",
"test": "npm test --workspaces --if-present",
"mock": "prism mock --dynamic --port 4010 packages/ui-api-client/openapi/qqq-v1.json"
+2 -1
View File
@@ -25,6 +25,7 @@
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
"commander": "15.0.0"
"commander": "15.0.0",
"@qrun-io/qqq-core": "0.1.0-dev"
}
}
+59
View File
@@ -0,0 +1,59 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { basename } from 'node:path'
import type { Command } from 'commander'
import { OPERATIONS, operation } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { parsePairs, readJson } from './shared.js'
export function registerApi(program: Command, ctx: () => Context): void {
program.command('api [operationId]')
.description('Call any UI API operation by its operationId (every operation of the document is reachable this way).')
.option('-p, --path <name=value...>', 'path parameters')
.option('-q, --param <name=value...>', 'query parameters')
.option('-b, --body-json <json|file|->', 'JSON request body (inline, a file, or - for stdin)')
.option('-F, --form <name=value|name=@file...>', 'multipart form fields; @file uploads a file')
.option('--bytes <file>', 'raw request body (application/octet-stream)')
.option('-o, --out <file>', 'write a binary response to a file')
.option('-l, --list', 'list the operations instead of calling one')
.action(async (operationId: string | undefined, options: { path?: string[]; param?: string[]; bodyJson?: string; form?: string[]; bytes?: string; out?: string; list?: boolean }) => {
const c = ctx()
if (options.list || !operationId) {
const rows = OPERATIONS.map((o) => ({ operationId: o.id, method: o.method, path: o.path, body: o.bodyTypes.join('|'), summary: o.summary }))
c.print({ data: rows, columns: ['operationId', 'method', 'path', 'body'], footer: `(${rows.length} operations; qqq api <operationId> --path k=v --param k=v --body-json '{...}')` })
return
}
const op = operation(operationId)
if (!op) throw new UsageError(`No operation "${operationId}"; \`qqq api --list\` shows them.`)
let form: Record<string, string | Blob> | undefined
if (options.form) {
form = {}
for (const [k, v] of Object.entries(parsePairs(options.form, '--form'))) {
form[k] = v.startsWith('@') ? new File([readFileSync(v.slice(1))], basename(v.slice(1))) : v
}
}
const result = await c.call(operationId, {
path: parsePairs(options.path, '--path'),
query: parsePairs(options.param, '--param'),
json: options.bodyJson !== undefined ? readJson(options.bodyJson, '--body-json') : undefined,
form,
bytes: options.bytes ? new Uint8Array(readFileSync(options.bytes)) : undefined,
})
if (result.bytes) {
if (!options.out) throw new UsageError(`${operationId} returned ${result.contentType} (${result.bytes.length} bytes); pass --out <file>`)
writeFileSync(options.out, result.bytes)
c.note(`wrote ${options.out} (${result.bytes.length} bytes, ${result.contentType})`)
return
}
if (typeof result.data === 'string') {
process.stdout.write(result.data.endsWith('\n') ? result.data : result.data + '\n')
return
}
c.print({ data: result.data })
})
}
+84
View File
@@ -0,0 +1,84 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { createSession, decodeJwtClaims, deviceSignIn, getAuthenticationMetaData } from '@qrun-io/qqq-ui-api-client'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { CLIENT_CHANNEL, currentToken, getLogin, nativeConfig, removeLogin, saveLogin } from '../session.js'
import type { Json } from './shared.js'
export function registerAuth(program: Command, ctx: () => Context): void {
program.command('login')
.description('Sign in. OAUTH2 and AUTH_0 servers use the device-authorization grant; MOCK and FULLY_ANONYMOUS servers get a server session.')
.option('--device', 'force the device-authorization grant')
.action(async (options: { device?: boolean }) => {
const c = ctx()
const origin = c.server
const auth = await getAuthenticationMetaData(origin)
const type = String(auth.type ?? '').toUpperCase()
if (!options.device && (type === 'MOCK' || type === 'FULLY_ANONYMOUS')) {
const session = await createSession(origin, {}, CLIENT_CHANNEL)
const user = (session.values.user ?? {}) as Record<string, unknown>
saveLogin(origin, { kind: 'session', authType: type, sessionUUID: session.sessionUUID, user, createdAt: new Date().toISOString() })
c.note(`Signed in to ${origin} (${type.toLowerCase()} authentication${user.name || user.email ? ` as ${user.name ?? user.email}` : ''}).`)
return
}
if (!options.device && type === 'TABLE_BASED') throw new UsageError('TABLE_BASED sign-in (username and password) is not implemented yet; set QQQ_TOKEN to a session UUID for now.')
if (!options.device && type !== 'OAUTH2' && type !== 'AUTH_0') throw new UsageError(`Unknown authentication type "${auth.type}"; this CLI knows OAUTH2, AUTH_0, MOCK and FULLY_ANONYMOUS.`)
const config = await nativeConfig(origin)
const tokens = await deviceSignIn(config, (prompt) => {
c.note(`To sign in to ${origin}, open:\n\n ${prompt.verificationUriComplete ?? prompt.verificationUri}\n`)
c.note(`and confirm the code ${prompt.userCode} (it expires in ${Math.round(prompt.expiresIn / 60)} minutes).`)
c.note('Waiting for approval...')
})
saveLogin(origin, { kind: 'oauth', config, tokens })
const claims = decodeJwtClaims(tokens.accessToken)
c.note(`Signed in to ${origin} as ${claims.email ?? claims.preferred_username ?? claims.sub}.`)
})
program.command('logout')
.description("Forget this server's sign-in (and end a server session).")
.action(async () => {
const c = ctx()
const origin = c.server
const login = getLogin(origin)
if (login?.kind === 'session') {
try {
await c.call('logout', {})
} catch {
// the session may already be gone
}
}
c.note(removeLogin(origin) ? `Signed out of ${origin}.` : `Not signed in to ${origin}.`)
})
program.command('whoami')
.description('Show the signed-in user, the server version and its extensions.')
.action(async () => {
const c = ctx()
const origin = c.server
const version = await c.unwrap(c.client.GET('/qqq/v1/version')) as Json
const token = await currentToken(origin)
const claims = token && token.includes('.') ? decodeJwtClaims(token) : {}
const metaData = await c.unwrap(c.client.GET('/qqq/v1/metaData', {})) as Json
const login = getLogin(origin)
const sessionUser = login?.kind === 'session' ? login.user ?? {} : {}
const result = {
server: origin,
auth: login?.kind === 'session' ? `${login.authType.toLowerCase()} session` : login ? 'oauth2 bearer' : process.env.QQQ_TOKEN ? 'QQQ_TOKEN' : 'none',
user: claims.email ?? claims.preferred_username ?? claims.sub ?? sessionUser.email ?? sessionUser.username ?? sessionUser.name ?? '',
name: claims.name ?? sessionUser.displayName ?? sessionUser.name ?? '',
tokenExpires: login && login.kind !== 'session' ? new Date(login.tokens.expiresAt).toISOString() : '',
apiVersion: version.apiVersion,
features: ((version.features ?? []) as string[]).join(', '),
extensions: ((version.extensions ?? []) as Json[]).map((e) => `${e.name} ${e.version}`).join(', '),
apps: Object.keys((metaData.apps ?? {}) as Json).length,
tables: Object.keys((metaData.tables ?? {}) as Json).length,
processes: Object.keys((metaData.processes ?? {}) as Json).length,
}
c.print({ data: result, rows: Object.entries(result).map(([key, value]) => ({ key, value })), columns: ['key', 'value'] })
})
}
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import type { Context } from '../context.js'
import type { Json } from './shared.js'
function noJobs(error: unknown): never {
if (error instanceof UiApiError && error.status === 404) throw new Error('This server has no jobs operations (durable jobs are not enabled on it).')
throw error
}
export function registerJob(program: Command, ctx: () => Context): void {
const job = program.command('job').description('Jobs (durable and tracked processes): list, get, logs, cancel, retry.')
job.command('list')
.description('List your jobs (active by default; --all for every state).')
.option('--all', 'all states')
.option('-l, --limit <n>', 'most jobs', '50')
.action(async (options: { all?: boolean; limit: string }) => {
const c = ctx()
const result = await c.call('listJobs', { query: { scope: 'mine', state: options.all ? undefined : 'active', limit: options.limit } }).catch(noJobs)
const data = result.data as Json
const jobs = ((data.jobs ?? []) as Json[]).map((j) => ({ id: j.jobId, label: j.label ?? j.processName, status: j.status, percent: j.percent ?? '', message: j.message ?? '' }))
c.print({ data, rows: jobs, columns: ['id', 'label', 'status', 'percent', 'message'], footer: `(${data.activeCount ?? 0} active)` })
})
job.command('get <jobId>')
.description('One job with its phases.')
.action(async (jobId: string) => {
const c = ctx()
const result = await c.call('getJob', { path: { jobId } }).catch(noJobs)
c.print({ data: result.data })
})
job.command('logs <jobId>')
.description("A job's progress log; --follow keeps reading until it ends.")
.option('-f, --follow', 'keep following')
.action(async (jobId: string, options: { follow?: boolean }) => {
const c = ctx()
let afterLogId: string | undefined
for (;;) {
const result = await c.call('getJobProgress', { path: { jobId }, query: { afterLogId, logLimit: '200' } }).catch(noJobs)
const data = result.data as Json
const lines = (data.log ?? data.lines ?? []) as Json[]
for (const line of lines) {
console.log(`${String(line.timestamp ?? line.at ?? '').slice(11, 19)} ${line.level ?? 'info'} ${line.message ?? ''}`.trim())
afterLogId = String(line.id ?? line.logId ?? afterLogId ?? '')
}
const status = String(data.status ?? data.state ?? '')
if (!options.follow || /complete|error|fail|cancel/i.test(status)) {
if (status) c.note(`status: ${status}`)
break
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
})
job.command('cancel <jobId>').description('Cancel a job.').action(async (jobId: string) => {
const c = ctx()
const result = await c.call('cancelJob', { path: { jobId } }).catch(noJobs)
c.print({ data: result.data ?? {}, rows: [{ cancelled: jobId }], columns: ['cancelled'] })
})
job.command('retry <jobId>').description('Retry a failed job.').action(async (jobId: string) => {
const c = ctx()
const result = await c.call('retryJob', { path: { jobId } }).catch(noJobs)
c.print({ data: result.data ?? {}, rows: [{ retried: jobId }], columns: ['retried'] })
})
program.command('jobs', { hidden: true }).description('Alias of `job list`.').option('--all').action(async (options: { all?: boolean }) => { await job.commands.find((x) => x.name() === 'list')!.parseAsync(options.all ? ['--all'] : [], { from: 'user' }) })
}
+66
View File
@@ -0,0 +1,66 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { openRealtimeStream, unwrap } from '@qrun-io/qqq-ui-api-client'
import type { Context } from '../context.js'
import { CLIENT_CHANNEL, currentToken, sessionCookieFor } from '../session.js'
import type { Json } from './shared.js'
export function registerMisc(program: Command, ctx: () => Context): void {
program.command('apps')
.description('List the apps you may use.')
.action(async () => {
const c = ctx()
const metaData = await c.unwrap(c.client.GET('/qqq/v1/metaData', {})) as Json
const apps = Object.values((metaData.apps ?? {}) as Record<string, Json>)
.map((a) => ({ name: a.name, label: a.label, items: (a.children as unknown[] | undefined)?.length ?? 0 }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
c.print({ data: apps, columns: ['name', 'label', 'items'] })
})
program.command('search <text>')
.description('Search records across tables (the global search of the web UI).')
.option('-t, --table <name>', 'only this table')
.option('-l, --limit <n>', 'most results', '20')
.action(async (text: string, options: { table?: string; limit: string }) => {
const c = ctx()
const result = await c.call('searchRecords', { json: { searchTerm: text, tableNames: options.table ? [options.table] : undefined, limitPerTable: Number(options.limit) } })
const data = result.data as Json
const results = ((data.results ?? []) as Json[]).map((r) => ({ table: r.tableName, key: r.recordId, label: r.recordLabel, details: Array.isArray(r.details) ? (r.details as unknown[]).map((d) => (typeof d === 'object' && d ? Object.values(d as Json).join(' ') : String(d))).join(' · ') : r.details ?? '' }))
c.print({ data, rows: results, columns: ['table', 'key', 'label', 'details'], footer: `(${results.length} results)` })
})
program.command('tail')
.description('Follow your jobs and notifications live (realtime stream, channel user:me).')
.option('-c, --channel <channels...>', 'more channels, e.g. table:person or record:person:42')
.action(async (options: { channel?: string[] }) => {
const c = ctx()
const origin = c.server
const controller = new AbortController()
process.on('SIGINT', () => controller.abort())
const session = sessionCookieFor(origin)
const stream = openRealtimeStream({ baseUrl: origin, token: () => currentToken(origin), clientChannel: CLIENT_CHANNEL, signal: controller.signal, ...(session ? { headers: { Cookie: `sessionUUID=${session}` } } : {}) } as never)
for await (const message of stream) {
const data = (message.data ?? {}) as Json
if (message.event === 'hello') {
const connectionId = String(data.connectionId ?? (data.data as Json | undefined)?.connectionId ?? '')
const add = ['user:me', ...(options.channel ?? [])]
await unwrap(c.client.POST('/qqq/v1/realtime/subscriptions', { body: { connectionId, add } }))
c.note(`Following ${add.join(', ')} on ${origin} (Ctrl-C to stop).`)
continue
}
if (c.format === 'json') {
console.log(JSON.stringify(message))
continue
}
const payload = (data.data ?? data) as Json
const time = new Date().toISOString().slice(11, 19)
const detail = payload.label ?? payload.title ?? payload.message ?? payload.jobId ?? ''
const progress = payload.percent !== undefined && payload.percent !== null ? ` ${payload.percent}%` : ''
console.log(`${time} ${message.event}${data.channel ? ` [${data.channel}]` : ''} ${detail}${progress}${payload.status ? ` ${payload.status}` : ''}`.trim())
}
})
}
+126
View File
@@ -0,0 +1,126 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import type { UiApiClient } from '@qrun-io/qqq-ui-api-client'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { parsePairs, readJson, type Json } from './shared.js'
async function processMeta(c: Context, name: string): Promise<Json> {
return await c.unwrap(c.client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName: name } } })) as Json
}
/** Polls an asynchronous step (JOB_STARTED / RUNNING) until it completes, reporting progress on stderr. */
async function waitForJob(c: Context, client: UiApiClient, processName: string, response: Json, quiet: boolean): Promise<Json> {
let current = response
let lastLine = ''
while (current.type === 'JOB_STARTED' || current.type === 'RUNNING') {
const jobUUID = String(current.jobUUID ?? response.jobUUID)
await new Promise((resolve) => setTimeout(resolve, 500))
current = await c.unwrap(client.GET('/qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}', {
params: { path: { processName, processUUID: String(response.processUUID), jobUUID } },
})) as Json
if (current.type === 'RUNNING' && !quiet) {
const line = ` running${current.current !== undefined ? ` ${current.current}/${current.total ?? '?'}` : ''}${current.message ? ` ${current.message}` : ''}`
if (line !== lastLine) c.note(line)
lastLine = line
}
current = { ...current, jobUUID, processUUID: response.processUUID }
}
return current
}
export function registerProcess(program: Command, ctx: () => Context): void {
const proc = program.command('process').description('Processes: list, describe, run.')
proc.command('list')
.description('List the processes you may run (hidden ones with --all).')
.option('--all', 'include hidden processes (bulk edit/delete and the like)')
.option('-t, --table <name>', 'only processes of this table')
.action(async (options: { all?: boolean; table?: string }) => {
const c = ctx()
const metaData = await c.unwrap(c.client.GET('/qqq/v1/metaData', {})) as Json
const rows = Object.values((metaData.processes ?? {}) as Record<string, Json>)
.filter((p) => (options.all || !p.isHidden) && (!options.table || p.tableName === options.table))
.map((p) => ({ name: p.name, label: p.label, table: p.tableName ?? '', hidden: p.isHidden ? 'yes' : '' }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
c.print({ data: rows, columns: ['name', 'label', 'table', 'hidden'] })
})
proc.command('describe <process>')
.description("A process's steps and the fields each step asks for (what --values can set).")
.action(async (name: string) => {
const c = ctx()
const meta = await processMeta(c, name)
const rows: Json[] = []
for (const step of (meta.frontendSteps ?? []) as Json[]) {
const components = ((step.components ?? []) as Json[]).map((x) => x.type).join(', ')
const formFields = (step.formFields ?? []) as Json[]
if (!formFields.length) rows.push({ step: step.name, label: step.label, components, field: '', type: '', required: '' })
for (const f of formFields) rows.push({ step: step.name, label: step.label, components, field: f.name, type: f.type, required: f.isRequired ? 'yes' : '' })
}
c.print({ data: meta, rows, columns: ['step', 'label', 'components', 'field', 'type', 'required'], footer: `${meta.label} (${meta.name})${meta.tableName ? ` on table ${meta.tableName}` : ''}; records: ${meta.minInputRecords ?? 0}..${meta.maxInputRecords ?? 'any'}` })
})
proc.command('run <process>')
.description('Run a process to completion: init, then each step with the given values, polling asynchronous jobs.')
.option('-v, --values <name=value...>', 'input values for form fields (any step)')
.option('--values-json <json|file|->', 'input values as a JSON object')
.option('-r, --records <ids>', 'comma-separated primary keys of the process table to run on')
.option('--filter-json <json|file|->', 'a QueryFilter selecting the records to run on')
.option('--timeout <ms>', 'how long each step may run before the server backgrounds it', '0')
.option('--skip-validation', 'on a validation review step, skip full validation')
.option('--validate', 'on a validation review step, run full validation first')
.option('--quiet', 'no progress on stderr')
.action(async (name: string, options: { values?: string[]; valuesJson?: string; records?: string; filterJson?: string; timeout: string; skipValidation?: boolean; validate?: boolean; quiet?: boolean }) => {
const c = ctx()
const client = c.client
const meta = await processMeta(c, name)
const steps = ((meta.frontendSteps ?? []) as Json[])
const stepNames = steps.map((s) => String(s.name))
const values: Json = { ...(options.valuesJson ? readJson(options.valuesJson, '--values-json') as Json : {}), ...parsePairs(options.values, '--values') }
if (options.validate && options.skipValidation) throw new UsageError('--validate and --skip-validation exclude each other.')
if (options.validate) values.doFullValidation = 'true'
if (options.skipValidation) values.doFullValidation = 'false'
const form = (extra: Record<string, string>) => {
const data = new FormData()
data.append('values', JSON.stringify(values))
data.append('stepTimeoutMillis', options.timeout)
for (const [k, v] of Object.entries(extra)) data.append(k, v)
return data
}
const records: Record<string, string> = options.records ? { recordsParam: 'recordIds', recordIds: options.records }
: options.filterJson ? { recordsParam: 'filterJSON', filterJSON: JSON.stringify(readJson(options.filterJson, '--filter-json')) } : {}
let response = await c.unwrap(client.POST('/qqq/v1/processes/{processName}/init', {
params: { path: { processName: name } }, body: {} as never, bodySerializer: () => form(records),
})) as Json
if (!options.quiet) c.note(`${meta.label ?? name}: started (${response.processUUID})`)
for (let guard = 0; guard < 100; guard++) {
response = await waitForJob(c, client, name, response, Boolean(options.quiet))
if (response.type === 'ERROR' || response.error) throw new Error(String(response.userFacingError ?? response.error))
const next = response.nextStep as string | undefined
if (!next) break
const step = steps.find((s) => s.name === next)
const isLast = stepNames.indexOf(next) === stepNames.length - 1
if (!options.quiet) c.note(` step ${next}${step?.label ? ` (${step.label})` : ''}${isLast ? ' - results' : ''}`)
if (isLast) break
const missing = ((step?.formFields ?? []) as Json[]).filter((f) => f.isRequired && values[String(f.name)] === undefined && (response.values as Json | undefined)?.[String(f.name)] === undefined)
if (missing.length) throw new UsageError(`Step "${next}" needs ${missing.map((f) => `--values ${f.name}=…`).join(' ')} (qqq process describe ${name} lists every field)`)
response = await c.unwrap(client.POST('/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}', {
params: { path: { processName: name, processUUID: String(response.processUUID), stepName: next } }, body: {} as never, bodySerializer: () => form({}),
})) as Json
}
const out = (response.values ?? {}) as Json
const summary = (out.processResults ?? out.recordListSummary ?? []) as Json[]
if (Array.isArray(summary) && summary.length) {
const rows = summary.map((line) => ({ status: line.status ?? 'INFO', count: line.count ?? '', message: String(line.message ?? '').replace(/\s+/g, ' ').trim() }))
c.print({ data: response, rows, columns: ['status', 'count', 'message'] })
} else {
const shown = Object.entries(out).filter(([, v]) => v !== null && typeof v !== 'object').slice(0, 30)
c.print({ data: response, rows: shown.map(([value, result]) => ({ value, result: String(result) })), columns: ['value', 'result'], footer: shown.length ? '' : 'Done.' })
}
})
}
+60
View File
@@ -0,0 +1,60 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { readFileSync } from 'node:fs'
import { UsageError } from '../context.js'
export type Json = Record<string, unknown>
/** Parses `name=value` pairs (`--set`, `--values`). */
export function parsePairs(pairs: string[] | undefined, flag: string): Record<string, string> {
const values: Record<string, string> = {}
for (const pair of pairs ?? []) {
const at = pair.indexOf('=')
if (at < 1) throw new UsageError(`${flag} takes name=value: ${pair}`)
values[pair.slice(0, at)] = pair.slice(at + 1)
}
return values
}
/** Reads a JSON argument: inline text, a file path, or `-` for stdin. */
export function readJson(source: string, flag: string): unknown {
const text = source === '-' ? readFileSync(0, 'utf8') : source.trim().startsWith('{') || source.trim().startsWith('[') ? source : readFileSync(source, 'utf8')
try {
return JSON.parse(text)
} catch (error) {
throw new UsageError(`${flag}: not valid JSON (${error instanceof Error ? error.message : String(error)})`)
}
}
/** 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)
}
/** The fields a query shows by default: the primary key, then the sections' fields by tier, then the rest; capped. */
export function defaultFields(meta: Json, cap = 8): string[] {
const fields = (meta.fields ?? {}) as Record<string, Json>
const pk = String(meta.primaryKeyField ?? 'id')
const order: string[] = [pk]
const sections = (meta.sections ?? []) as Json[]
for (const tier of ['T1', 'T2', 'T3']) {
for (const section of sections.filter((s) => s.tier === tier)) {
for (const name of (section.fieldNames ?? []) as string[]) if (!order.includes(name) && fields[name] && !fields[name].isHidden) order.push(name)
}
}
for (const name of Object.keys(fields)) if (!order.includes(name) && !fields[name]!.isHidden && !fields[name]!.isHeavy) order.push(name)
return order.slice(0, cap)
}
/** Field labels for column headers. */
export function labelsFor(meta: Json, names: string[]): string[] {
const fields = (meta.fields ?? {}) as Record<string, Json>
return names.map((n) => String(fields[n]?.label ?? n))
}
+213
View File
@@ -0,0 +1,213 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { writeFileSync } from 'node:fs'
import type { Command } from 'commander'
import { buildFilter, printFilter, type QueryFilter } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { defaultFields, labelsFor, parsePairs, readJson, valueText, type Json } from './shared.js'
interface QueryOptions { where?: string[]; group?: string[]; or?: boolean; order?: string[]; limit: string; skip?: string; all?: boolean; pageSize: string; fields?: string; filterJson?: string; printFilter?: boolean; count?: boolean }
async function tableMeta(c: Context, table: string): Promise<Json> {
return await c.unwrap(c.client.GET('/qqq/v1/metaData/table/{tableName}', { params: { path: { tableName: table } } })) as Json
}
function filterFrom(options: QueryOptions, meta: Json): QueryFilter {
if (options.filterJson) return readJson(options.filterJson, '--filter-json') as QueryFilter
return buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: Object.keys((meta.fields ?? {}) as Json) })
}
export function registerTable(program: Command, ctx: () => Context): void {
const table = program.command('table').description('Tables: list, describe, query, count, get, create, update, delete, export.')
table.command('list')
.description('List the tables you may read (optionally of one app).')
.option('-a, --app <name>', 'only tables in this app')
.action(async (options: { app?: string }) => {
const c = ctx()
const metaData = await c.unwrap(c.client.GET('/qqq/v1/metaData', {})) as Json
let tables = Object.values((metaData.tables ?? {}) as Record<string, Json>).filter((t) => !t.isHidden && t.readPermission !== false)
if (options.app) {
const app = (metaData.apps as Record<string, Json> | undefined)?.[options.app]
if (!app) throw new UsageError(`No app named ${options.app} (see \`qqq apps\`)`)
const names = new Set<unknown>()
const visit = (node: Json | undefined, seen: Set<unknown>) => {
for (const child of (node?.children ?? []) as Json[]) {
if (child.type === 'TABLE') names.add(child.name)
else if (child.type === 'APP' && !seen.has(child.name)) visit((metaData.apps as Record<string, Json>)[String(child.name)], seen.add(child.name))
}
}
visit(app, new Set([options.app]))
tables = tables.filter((t) => names.has(t.name))
}
const rows = tables.map((t) => ({ name: t.name, label: t.label, insert: t.insertPermission ? 'yes' : '', edit: t.editPermission ? 'yes' : '', delete: t.deletePermission ? 'yes' : '' }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
c.print({ data: rows, columns: ['name', 'label', 'insert', 'edit', 'delete'] })
})
table.command('describe <table>')
.description("A table's fields, sections and capabilities.")
.action(async (name: string) => {
const c = ctx()
const meta = await tableMeta(c, name)
const fields = Object.values((meta.fields ?? {}) as Record<string, Json>).map((f) => ({
name: f.name, label: f.label, type: f.type, required: f.isRequired ? 'yes' : '', editable: f.isEditable === false ? 'no' : 'yes',
possibleValues: f.possibleValueSourceName ?? '', hidden: f.isHidden ? 'yes' : '',
}))
const sections = ((meta.sections ?? []) as Json[]).map((s) => `${s.tier} ${s.label}: ${((s.fieldNames ?? []) as string[]).join(', ')}`).join('\n')
c.print({ data: meta, rows: fields, columns: ['name', 'label', 'type', 'required', 'editable', 'possibleValues', 'hidden'], footer: `${meta.label} (${meta.name}), primary key ${meta.primaryKeyField}\n${sections}\ncapabilities: ${((meta.capabilities ?? []) as string[]).join(', ')}` })
})
const query = table.command('query <table>')
.description('Query a table. Criteria: "field OPERATOR value[,value]" (operators as the UI API, or = != < <= > >= ~); values may be now(-7d), thisPeriod(month), startOf(quarter,-1), me(), ${variable.x}.')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses, e.g. "(a = 1 AND b = 2) OR c IS_BLANK"')
.option('--or', 'OR the --where criteria instead of AND')
.option('-o, --order <field...>', 'order by fields; prefix - for descending')
.option('-l, --limit <n>', 'most records to return', '25')
.option('--skip <n>', 'records to skip')
.option('--all', 'fetch every page (with --page-size)')
.option('--page-size <n>', 'page size for --all', '500')
.option('-f, --fields <names>', 'comma-separated fields to show')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON, instead of --where/--group/--order')
.option('--print-filter', 'print the QueryFilter JSON the options produced and stop')
.option('--count', 'print only the count of matching records')
.action(async (name: string, options: QueryOptions) => {
const c = ctx()
const meta = await tableMeta(c, name)
const filter = filterFrom(options, meta)
if (options.printFilter) {
c.print({ data: filter })
return
}
if (options.count) {
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/count', { params: { path: { tableName: name }, query: { includeDistinct: false } }, body: { filter: filter as never } })) as Json
c.print({ data: result, rows: [{ count: result.count }], columns: ['count'] })
return
}
const records: Json[] = []
const limit = options.all ? Number(options.pageSize) : Number(options.limit)
let skip = Number(options.skip ?? 0)
for (;;) {
const body = { filter: { ...filter, skip, limit } as never }
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/query', { params: { path: { tableName: name } }, body })) as Json
const page = (result.records ?? []) as Json[]
records.push(...page)
if (!options.all || page.length < limit) break
skip += limit
}
const fields = c.fields ?? (options.fields ? options.fields.split(',') : defaultFields(meta))
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f)])))
c.print({ data: records, rows, columns: fields, headers: labelsFor(meta, fields), footer: `(${records.length} record${records.length === 1 ? '' : 's'}${filter.criteria.length || filter.subFilters?.length ? `; filter: ${printFilter(filter)}` : ''}${!options.all && records.length === limit ? '; more may exist: --limit, --skip or --all' : ''})` })
})
table.command('count <table>')
.description('Count the records matching criteria.')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON')
.action(async (name: string, options: QueryOptions) => {
const c = ctx()
const meta = await tableMeta(c, name)
const filter = filterFrom(options, meta)
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/count', { params: { path: { tableName: name }, query: { includeDistinct: false } }, body: { filter: filter as never } })) as Json
c.print({ data: result, rows: [{ count: result.count }], columns: ['count'] })
})
table.command('get <table> <primaryKey>')
.description('View one record, with field labels and display values.')
.option('--associations', 'include associated records')
.action(async (name: string, primaryKey: string, options: { associations?: boolean }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const result = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { includeAssociations: options.associations ? true : undefined } as never } })) as Json
const record = (result.record ?? result) as Json
const fieldMeta = (meta.fields ?? {}) as Record<string, Json>
const rows = defaultFields(meta, 1000).map((f) => ({ field: String(fieldMeta[f]?.label ?? f), value: valueText(record, f) }))
c.print({ data: record, rows, columns: ['field', 'value'], headers: ['Field', 'Value'], footer: String(record.recordLabel ?? `${meta.label} ${primaryKey}`) })
})
table.command('create <table>')
.description('Insert a record from --set name=value pairs or --input-json.')
.option('-s, --set <name=value...>', 'field values')
.option('--input-json <json|file|->', 'the whole record as a JSON object of field values')
.option('--generate-input-skeleton', 'print an empty JSON object with the editable fields and stop')
.action(async (name: string, options: { set?: string[]; inputJson?: string; generateInputSkeleton?: boolean }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const fields = (meta.fields ?? {}) as Record<string, Json>
if (options.generateInputSkeleton) {
const editable = Object.values(fields).filter((f) => f.isEditable !== false && !f.isHidden && f.name !== meta.primaryKeyField)
c.print({ data: Object.fromEntries(editable.map((f) => [f.name, f.defaultValue ?? ''])) })
c.note(editable.map((f) => `${String(f.name).padEnd(24)} ${f.type}${f.isRequired ? ' required' : ''}${f.possibleValueSourceName ? ` (possible values: ${f.possibleValueSourceName})` : ''}`).join('\n'))
return
}
const values = { ...(options.inputJson ? readJson(options.inputJson, '--input-json') as Json : {}), ...parsePairs(options.set, '--set') }
if (!Object.keys(values).length) throw new UsageError('Give field values with --set name=value or --input-json.')
for (const k of Object.keys(values)) if (!fields[k]) throw new UsageError(`No field "${k}" in ${name}; fields: ${Object.keys(fields).join(', ')}`)
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}', { params: { path: { tableName: name } }, body: values as never })) as Json
const record = (result.record ?? result) as Json
const pk = (record.values as Json | undefined)?.[String(meta.primaryKeyField)] ?? record[String(meta.primaryKeyField)] ?? ''
c.print({ data: result, rows: [{ created: `${meta.label} ${pk}`, label: record.recordLabel ?? '' }], columns: ['created', 'label'] })
})
table.command('update <table> <primaryKey>')
.description('Update fields of a record from --set name=value pairs or --input-json.')
.option('-s, --set <name=value...>', 'field values (an empty value clears the field)')
.option('--input-json <json|file|->', 'field values as a JSON object')
.option('--expected-version <v>', 'optimistic locking: fail when the record changed')
.action(async (name: string, primaryKey: string, options: { set?: string[]; inputJson?: string; expectedVersion?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const fields = (meta.fields ?? {}) as Record<string, Json>
const values = { ...(options.inputJson ? readJson(options.inputJson, '--input-json') as Json : {}), ...parsePairs(options.set, '--set') }
if (!Object.keys(values).length) throw new UsageError('Give field values with --set name=value or --input-json.')
for (const k of Object.keys(values)) if (!fields[k]) throw new UsageError(`No field "${k}" in ${name}; fields: ${Object.keys(fields).join(', ')}`)
const result = await c.unwrap(c.client.PATCH('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { expectedVersion: options.expectedVersion } as never }, body: values as never })) as Json
const record = (result.record ?? result) as Json
c.print({ data: result, rows: [{ updated: `${meta.label} ${primaryKey}`, label: record.recordLabel ?? '', fields: Object.keys(values).join(', ') }], columns: ['updated', 'label', 'fields'] })
})
table.command('delete <table> <primaryKey>')
.description('Delete a record (asks first; --yes skips the question).')
.action(async (name: string, primaryKey: string) => {
const c = ctx()
const meta = await tableMeta(c, name)
if (!await c.confirm(`Delete ${meta.label} ${primaryKey}?`)) {
c.note('Not deleted.')
return
}
const result = await c.unwrap(c.client.DELETE('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey } } })) as Json
c.print({ data: result ?? {}, rows: [{ deleted: `${meta.label} ${primaryKey}` }], columns: ['deleted'] })
})
table.command('export <table>')
.description('Export matching records to a file (csv, xlsx, json, tsv).')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option('-o, --order <field...>', 'order by fields; prefix - for descending')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON')
.option('--format <fmt>', 'csv, xlsx, json or tsv', 'csv')
.option('-f, --fields <names>', 'comma-separated fields to include')
.option('-l, --limit <n>', 'most records')
.option('--out <file>', 'output file (default: <table>.<format>)')
.action(async (name: string, options: QueryOptions & { format: string; out?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const filter = filterFrom(options, meta)
const out = options.out ?? `${name}.${options.format}`
const result = await c.call('exportTable', { path: { tableName: name }, json: { format: options.format, filter, fieldNames: options.fields ? options.fields.split(',') : undefined, limit: options.limit ? Number(options.limit) : undefined } })
const bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
writeFileSync(out, bytes)
c.note(`wrote ${out} (${bytes.length} bytes, ${result.contentType})`)
})
// flat aliases, kept from 0.1
program.command('tables', { hidden: true }).description('Alias of `table list`.').option('-a, --app <name>').action(async (options: { app?: string }) => { await table.commands.find((x) => x.name() === 'list')!.parseAsync([...(options.app ? ['--app', options.app] : [])], { from: 'user' }) })
}
+126
View File
@@ -0,0 +1,126 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* What every command shares: the resolved server, the authenticated client, output rendering with
* the global --output / --query / --fields options, confirmations, and the exit-code policy.
*/
import type { Command } from 'commander'
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
import { FilterSyntaxError, OUTPUT_FORMATS, applyQuery, callOperation, defaultFormat, render, type CallInput, type CallResult, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
import { authenticatedFetch, clientFor, resolveServer } from './session.js'
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
export const EXIT = { ok: 0, error: 1, usage: 2, auth: 3, transient: 4, notFound: 5, validation: 6, unexpected: 70 } as const
export interface GlobalOptions {
server?: string
output?: OutputFormat
json?: boolean
query?: string
fields?: string
yes?: boolean
trace?: boolean
noPager?: boolean
}
export class Context {
readonly options: GlobalOptions
private clientCache?: UiApiClient
constructor(program: Command) {
this.options = program.opts<GlobalOptions>()
if (this.options.output && !OUTPUT_FORMATS.includes(this.options.output)) {
throw new UsageError(`--output must be one of ${OUTPUT_FORMATS.join(', ')}`)
}
}
/** The server origin from --server, QQQ_SERVER or the last sign-in. */
get server(): string {
return resolveServer(this.options.server)
}
/** The authenticated typed client. */
get client(): UiApiClient {
this.clientCache ??= clientFor(this.server)
return this.clientCache
}
/** The output format in effect. */
get format(): OutputFormat {
if (this.options.output) return this.options.output
if (this.options.json) return 'json'
return defaultFormat(Boolean(process.stdout.isTTY))
}
/** `--fields a,b,c` as a list, or undefined. */
get fields(): string[] | undefined {
return this.options.fields ? this.options.fields.split(',').map((f) => f.trim()).filter(Boolean) : undefined
}
/** Prints a result in the chosen format, after --query. */
print(printable: Printable): void {
process.stdout.write(render(printable, this.format, this.options.query) + '\n')
}
/** Reshapes data with --query without printing (for callers that print themselves). */
shape<T>(data: T): unknown {
return applyQuery(data, this.options.query)
}
/** Calls an operation by id (the generic escape hatch). */
call(id: string, input: CallInput): Promise<CallResult> {
return callOperation(this.server, id, input, authenticatedFetch(this.server))
}
/** Unwraps a typed client call. */
unwrap<T>(result: Promise<{ data?: T; error?: unknown; response: Response }>): Promise<T> {
return unwrap(result)
}
/** Asks on the terminal unless --yes; without a terminal and without --yes, refuses. */
async confirm(question: string): Promise<boolean> {
if (this.options.yes) return true
if (!process.stdin.isTTY) throw new UsageError(`${question} Pass --yes to confirm without a terminal.`)
const { createInterface } = await import('node:readline/promises')
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
const answer = await rl.question(`${question} [y/N] `)
return /^y(es)?$/i.test(answer.trim())
} finally {
rl.close()
}
}
/** A note for people, on stderr so pipes stay clean. */
note(text: string): void {
process.stderr.write(text + '\n')
}
}
/** A mistake in how the command was called (exit 2). */
export class UsageError extends Error {}
/** Maps an error to its exit code and the stderr text. */
export function describeError(error: unknown, trace = false): { code: number; text: string } {
if (error instanceof UsageError || error instanceof FilterSyntaxError) return { code: EXIT.usage, text: `Error: ${error.message}` }
if (error instanceof UiApiError) {
const lines = [`Error: ${error.message} (${error.code}, HTTP ${error.status}${error.traceId ? `, trace ${error.traceId}` : ''})`]
for (const fieldError of error.fieldErrors) lines.push(` ${fieldError.field}: ${fieldError.message}`)
if (error.code === 'UNAUTHENTICATED') lines.push('Sign in again with `qqq login`.')
const code = error.code === 'UNAUTHENTICATED' || error.code === 'PERMISSION_DENIED' ? EXIT.auth
: error.code === 'RATE_LIMITED' ? EXIT.transient
: error.code === 'NOT_FOUND' ? EXIT.notFound
: error.code === 'BAD_REQUEST' || error.code === 'CONFLICT' ? EXIT.validation
: error.status >= 500 ? EXIT.unexpected : EXIT.error
return { code, text: lines.join('\n') }
}
if (error instanceof TypeError && /fetch failed|ECONNREFUSED|ENOTFOUND|network/i.test(String((error as Error & { cause?: unknown }).cause ?? error.message))) {
return { code: EXIT.transient, text: `Error: cannot reach the server (${String((error as Error & { cause?: { message?: string } }).cause?.message ?? error.message)})` }
}
const message = error instanceof Error ? error.message : String(error)
return { code: EXIT.error, text: trace && error instanceof Error && error.stack ? error.stack : `Error: ${message}` }
}
-78
View File
@@ -1,78 +0,0 @@
/*
* 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')
}
+59 -274
View File
@@ -5,292 +5,77 @@
*/
/**
* qqq - a reference command-line client for QQQ applications. Every call is an operation of the
* QQQ UI API (/qqq/v1), through the generated @qrun-io/qqq-ui-api-client; nothing is hard-coded
* about an application: apps, tables, fields and processes all come from metadata.
* qqq - the command-line client for QQQ applications. Every call is an operation of the QQQ UI API
* (/qqq/v1); nothing is hard-coded about an application: apps, tables, fields and processes all
* come from metadata. Grammar: `qqq <noun> <verb>` (table, process, job, api), plus the flat
* commands of 0.1 (apps, tables, query, get, run, jobs, tail) as aliases.
*/
import { Command } from 'commander'
import {
UiApiError, createSession, decodeJwtClaims, deviceSignIn, getAuthenticationMetaData, openRealtimeStream, unwrap, UI_API_VERSION,
} from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, CLI_VERSION, clientFor, currentToken, getLogin, nativeConfig, removeLogin, resolveServer, saveLogin } from './session.js'
import { formatTable, parseCriteria, parseValues, valueText } from './format.js'
type Json = Record<string, unknown>
import { Command, CommanderError } from 'commander'
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
import { Context, EXIT, describeError } from './context.js'
import { CLI_VERSION } from './session.js'
import { registerApi } from './commands/api.js'
import { registerAuth } from './commands/auth.js'
import { registerJob } from './commands/job.js'
import { registerMisc } from './commands/misc.js'
import { registerProcess } from './commands/process.js'
import { registerTable } from './commands/table.js'
const program = new Command()
program
.name('qqq')
.description('Reference CLI for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
.description('Command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
.version(`${CLI_VERSION} (UI API client ${UI_API_VERSION})`)
.option('-s, --server <url>', 'the QQQ server (default: QQQ_SERVER, or the last one signed in to)')
.option('--json', 'print JSON instead of tables')
.option('-O, --output <format>', 'json, table, text, csv or yaml (default: table on a terminal, json in a pipe; QQQ_OUTPUT)')
.option('--json', 'same as --output json')
.option('-Q, --query <jmespath>', 'reshape the output with a JMESPath expression, as `aws --query`')
.option('--fields <names>', 'comma-separated columns to show in table, text and csv output')
.option('-y, --yes', 'answer yes to confirmations (delete, cancel)')
.option('--trace', 'print stack traces for unexpected errors')
.exitOverride()
.configureOutput({ writeErr: (text) => process.stderr.write(text) })
function server(): string {
return resolveServer(program.opts().server)
}
function json(): boolean {
return Boolean(program.opts().json)
}
function print(data: unknown, table?: () => string): void {
console.log(json() || !table ? JSON.stringify(data, null, 2) : table())
}
program.command('login')
.description('Sign in. OAUTH2 and AUTH_0 servers use the device-authorization grant (approve the code in any browser); MOCK and FULLY_ANONYMOUS servers get a server session.')
.option('--device', 'force the device-authorization grant')
.action(async (options: { device?: boolean }) => {
const origin = server()
const auth = await getAuthenticationMetaData(origin)
const type = String(auth.type ?? '').toUpperCase()
if (!options.device && (type === 'MOCK' || type === 'FULLY_ANONYMOUS')) {
const session = await createSession(origin, {}, CLIENT_CHANNEL)
const user = (session.values.user ?? {}) as Record<string, unknown>
saveLogin(origin, { kind: 'session', authType: type, sessionUUID: session.sessionUUID, user, createdAt: new Date().toISOString() })
console.error(`Signed in to ${origin} (${type.toLowerCase()} authentication${user.name || user.email ? ` as ${user.name ?? user.email}` : ''}). The session is sent as a cookie.`)
return
}
if (!options.device && type === 'TABLE_BASED') {
throw new Error('TABLE_BASED sign-in (username and password) is not implemented yet; set QQQ_TOKEN to a session UUID for now.')
}
if (!options.device && type !== 'OAUTH2' && type !== 'AUTH_0') {
throw new Error(`Unknown authentication type "${auth.type}"; this CLI knows OAUTH2, AUTH_0, MOCK and FULLY_ANONYMOUS.`)
}
const config = await nativeConfig(origin)
const tokens = await deviceSignIn(config, (prompt) => {
console.error(`To sign in to ${origin}, open:\n\n ${prompt.verificationUriComplete ?? prompt.verificationUri}\n`)
console.error(`and confirm the code ${prompt.userCode} (it expires in ${Math.round(prompt.expiresIn / 60)} minutes).`)
console.error('Waiting for approval...')
})
saveLogin(origin, { kind: 'oauth', config, tokens })
const claims = decodeJwtClaims(tokens.accessToken)
console.error(`Signed in to ${origin} as ${claims.email ?? claims.preferred_username ?? claims.sub}.`)
})
program.command('logout')
.description('Forget this server\'s tokens.')
.action(() => {
const origin = server()
console.error(removeLogin(origin) ? `Signed out of ${origin}.` : `Not signed in to ${origin}.`)
})
program.command('whoami')
.description('Show the signed-in user, the server version and its extensions.')
.action(async () => {
const origin = server()
const client = clientFor(origin)
const version = await unwrap(client.GET('/qqq/v1/version')) as Json
const token = await currentToken(origin)
const claims = token && token.includes('.') ? decodeJwtClaims(token) : {}
const metaData = await unwrap(client.GET('/qqq/v1/metaData', {})) as Json
const login = getLogin(origin)
const sessionUser = login?.kind === 'session' ? login.user ?? {} : {}
const result = {
server: origin,
auth: login?.kind === 'session' ? `${login.authType.toLowerCase()} session (cookie)` : login ? 'oauth2 bearer' : process.env.QQQ_TOKEN ? 'QQQ_TOKEN' : 'none',
user: claims.email ?? claims.preferred_username ?? claims.sub ?? sessionUser.email ?? sessionUser.username ?? sessionUser.name,
name: claims.name ?? sessionUser.displayName ?? sessionUser.name,
tokenExpires: login && login.kind !== 'session' ? new Date(login.tokens.expiresAt).toISOString() : undefined,
apiVersion: version.apiVersion,
extensions: (version.extensions as Json[] | undefined)?.map((e) => `${e.name} ${e.version}`),
apps: Object.keys((metaData.apps ?? {}) as Json).length,
tables: Object.keys((metaData.tables ?? {}) as Json).length,
processes: Object.keys((metaData.processes ?? {}) as Json).length,
}
print(result, () => Object.entries(result).map(([k, v]) => `${k.padEnd(13)} ${Array.isArray(v) ? v.join(', ') : v ?? ''}`).join('\n'))
})
program.command('apps')
.description('List the apps you may use.')
.action(async () => {
const metaData = await unwrap(clientFor(server()).GET('/qqq/v1/metaData', {})) as Json
const apps = Object.values((metaData.apps ?? {}) as Record<string, Json>)
.map((a) => ({ name: a.name, label: a.label, items: (a.children as unknown[] | undefined)?.length ?? 0 }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
print(apps, () => formatTable(apps, ['name', 'label', 'items']))
})
program.command('tables')
.description('List the tables you may read (optionally of one app).')
.option('-a, --app <name>', 'only tables in this app')
.action(async (options: { app?: string }) => {
const metaData = await unwrap(clientFor(server()).GET('/qqq/v1/metaData', {})) as Json
let tables = Object.values((metaData.tables ?? {}) as Record<string, Json>).filter((t) => !t.isHidden && t.readPermission !== false)
if (options.app) {
const app = (metaData.apps as Record<string, Json> | undefined)?.[options.app]
if (!app) throw new Error(`No app named ${options.app} (see \`qqq apps\`)`)
const names = new Set<unknown>()
const visit = (node: Json | undefined, seen: Set<unknown>) => {
for (const child of (node?.children ?? []) as Json[]) {
if (child.type === 'TABLE') names.add(child.name)
else if (child.type === 'APP' && !seen.has(child.name)) visit((metaData.apps as Record<string, Json>)[String(child.name)], seen.add(child.name))
}
}
visit(app, new Set([options.app]))
tables = tables.filter((t) => names.has(t.name))
}
const rows = tables.map((t) => ({ name: t.name, label: t.label, insert: t.insertPermission ? 'yes' : '', edit: t.editPermission ? 'yes' : '', delete: t.deletePermission ? 'yes' : '' }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
print(rows, () => formatTable(rows, ['name', 'label', 'insert', 'edit', 'delete']))
})
program.command('query <table>')
.description('Query a table. Criteria: "field OPERATOR value[,value]", e.g. "status EQUALS active" or "id GREATER_THAN 10".')
.option('-w, --where <criteria...>', 'criteria (ANDed)')
.option('--or', 'OR the criteria instead')
.option('-o, --order <field>', 'order by a field (prefix with - for descending)')
.option('-l, --limit <n>', 'most records to return', '25')
.option('-f, --fields <names>', 'comma-separated fields to show (default: the first few)')
.action(async (table: string, options: { where?: string[]; or?: boolean; order?: string; limit: string; fields?: string }) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/table/{tableName}', { params: { path: { tableName: table } } })) as Json
const fieldMeta = (meta.fields ?? {}) as Record<string, Json>
const criteria = (options.where ?? []).map((text) => parseCriteria(text, fieldMeta))
const order = options.order ? [{ fieldName: options.order.replace(/^-/, ''), isAscending: !options.order.startsWith('-') }] : undefined
const body = { filter: { criteria, booleanOperator: options.or ? 'OR' as const : 'AND' as const, orderBys: order, limit: Number(options.limit) } }
const result = await unwrap(client.POST('/qqq/v1/table/{tableName}/query', { params: { path: { tableName: table } }, body })) as Json
const records = (result.records ?? []) as Json[]
const fields = options.fields ? options.fields.split(',') : defaultFields(meta)
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f)])))
print(json() ? records : rows, () => `${formatTable(rows, fields, fields.map((f) => String(fieldMeta[f]?.label ?? f)))}\n(${records.length} record${records.length === 1 ? '' : 's'})`)
let context: Context | undefined
const ctx = () => (context ??= new Context(program))
registerAuth(program, ctx)
registerMisc(program, ctx)
registerTable(program, ctx)
registerProcess(program, ctx)
registerJob(program, ctx)
registerApi(program, ctx)
/** Flat aliases from 0.1: forward to the noun-verb commands with the same options. */
function alias(flat: string, noun: string, verb: string, description: string): void {
const target = program.commands.find((c) => c.name() === noun)!.commands.find((c) => c.name() === verb)!
const cmd = program.command(`${flat} ${target.registeredArguments.map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)).join(' ')}`.trim(), { hidden: true }).description(`${description} (alias of \`qqq ${noun} ${verb}\`)`)
for (const option of target.options) cmd.addOption(option)
cmd.action(async (...args: unknown[]) => {
const commandArgs = args.slice(0, -2) as string[]
const options = args[args.length - 2] as Record<string, unknown>
const argv: string[] = [...commandArgs]
for (const option of target.options) {
const value = options[option.attributeName()]
if (value === undefined || value === false) continue
if (value === true) argv.push(option.long!)
else if (Array.isArray(value)) for (const v of value) argv.push(option.long!, String(v))
else argv.push(option.long!, String(value))
}
await target.parseAsync(argv, { from: 'user' })
})
program.command('get <table> <primaryKey>')
.description('View one record, with field labels and display values.')
.action(async (table: string, primaryKey: string) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/table/{tableName}', { params: { path: { tableName: table } } })) as Json
const result = await unwrap(client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: table, primaryKey } } })) as Json
const record = (result.record ?? result) as Json
const fieldMeta = (meta.fields ?? {}) as Record<string, Json>
const rows = Object.keys(fieldMeta).filter((f) => !fieldMeta[f].isHidden)
.map((f) => ({ field: String(fieldMeta[f].label ?? f), value: valueText(record, f) }))
print(record, () => `${record.recordLabel ?? `${meta.label} ${primaryKey}`}\n${formatTable(rows, ['field', 'value'], ['Field', 'Value'])}`)
})
program.command('run <process>')
.description('Run a process: init, then each step until it completes; prints its results.')
.option('-v, --value <name=value...>', 'process input values')
.option('-r, --records <ids>', 'comma-separated primary keys of the process table\'s records to run on')
.option('--timeout <ms>', 'how long each step may run synchronously', '60000')
.action(async (processName: string, options: { value?: string[]; records?: string; timeout: string }) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName } } })) as Json
const steps = ((meta.frontendSteps ?? []) as Json[]).map((s) => String(s.name))
const values = parseValues(options.value ?? [])
const form = (extra: Record<string, string>) => {
const data = new FormData()
data.append('values', JSON.stringify(values))
data.append('stepTimeoutMillis', options.timeout)
for (const [k, v] of Object.entries(extra)) data.append(k, v)
return data
}
const records: Record<string, string> = options.records ? { recordsParam: 'recordIds', recordIds: options.records } : {}
let response = await unwrap(client.POST('/qqq/v1/processes/{processName}/init', {
params: { path: { processName } }, body: {} as never, bodySerializer: () => form(records),
})) as Json
console.error(`${meta.label ?? processName}: started (${response.processUUID})`)
for (let guard = 0; guard < 50; guard++) {
response = await waitForJob(client, processName, response)
if (response.type === 'ERROR' || response.error) throw new Error(String(response.userFacingError ?? response.error))
const next = response.nextStep as string | undefined
if (!next) break
const isLast = steps.indexOf(next) === steps.length - 1
console.error(` step ${next}${isLast ? ' (results)' : ''}`)
if (isLast) break
response = await unwrap(client.POST('/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}', {
params: { path: { processName, processUUID: String(response.processUUID), stepName: next } }, body: {} as never, bodySerializer: () => form({}),
})) as Json
}
const out = (response.values ?? {}) as Json
const summary = (out.processResults ?? out.recordListSummary ?? []) as Json[]
if (json()) return print(response)
if (Array.isArray(summary) && summary.length) {
for (const line of summary) console.log(`[${line.status ?? 'INFO'}] ${line.count ?? ''} ${line.message ?? ''}`.replace(/\s+/g, ' ').trim())
} else {
const shown = Object.entries(out).filter(([, v]) => v !== null && typeof v !== 'object').slice(0, 20)
console.log(shown.length ? formatTable(shown.map(([k, v]) => ({ value: k, result: String(v) })), ['value', 'result']) : 'Done.')
}
})
program.command('jobs')
.description('List your jobs (durable and tracked processes).')
.option('--all', 'all states (default: active and recent)')
.action(async (options: { all?: boolean }) => {
let result: Json
try {
result = await unwrap(clientFor(server()).GET('/qqq/v1/jobs', { params: { query: { scope: 'mine', ...(options.all ? {} : { state: 'active' }) } as never } })) as Json
} catch (error) {
if (error instanceof UiApiError && error.status === 404) throw new Error('This server has no jobs operations (durable jobs are not enabled on it).')
throw error
}
const jobs = ((result.jobs ?? []) as Json[]).map((j) => ({ id: j.jobId, label: j.label ?? j.processName, status: j.status, percent: j.percent ?? '', message: j.message ?? '' }))
print(result, () => `${formatTable(jobs, ['id', 'label', 'status', 'percent', 'message'])}\n(${result.activeCount ?? 0} active)`)
})
program.command('tail')
.description('Follow your jobs and notifications live (realtime stream, channel user:me).')
.option('-c, --channel <channels...>', 'more channels, e.g. table:person or record:person:42')
.action(async (options: { channel?: string[] }) => {
const origin = server()
const client = clientFor(origin)
const controller = new AbortController()
process.on('SIGINT', () => controller.abort())
for await (const message of openRealtimeStream({ baseUrl: origin, token: () => currentToken(origin), clientChannel: CLIENT_CHANNEL, signal: controller.signal })) {
const data = (message.data ?? {}) as Json
if (message.event === 'hello') {
const connectionId = String(data.connectionId ?? (data.data as Json | undefined)?.connectionId ?? '')
const add = ['user:me', ...(options.channel ?? [])]
await unwrap(client.POST('/qqq/v1/realtime/subscriptions', { body: { connectionId, add } }))
console.error(`Following ${add.join(', ')} on ${origin} (Ctrl-C to stop).`)
continue
}
if (json()) {
console.log(JSON.stringify(message))
continue
}
const payload = (data.data ?? data) as Json
const time = new Date().toISOString().slice(11, 19)
const detail = payload.label ?? payload.title ?? payload.message ?? payload.jobId ?? ''
const progress = payload.percent !== undefined && payload.percent !== null ? ` ${payload.percent}%` : ''
console.log(`${time} ${message.event}${data.channel ? ` [${data.channel}]` : ''} ${detail}${progress}${payload.status ? ` ${payload.status}` : ''}`.trim())
}
})
/** The fields a query shows by default: the primary key and the next few visible fields. */
function defaultFields(meta: Json): string[] {
const fields = Object.values((meta.fields ?? {}) as Record<string, Json>).filter((f) => !f.isHidden && !f.isHeavy).map((f) => String(f.name))
const pk = String(meta.primaryKeyField ?? 'id')
return [pk, ...fields.filter((f) => f !== pk)].slice(0, 6)
}
/** Polls an asynchronous step (JOB_STARTED / RUNNING) until it completes. */
async function waitForJob(client: ReturnType<typeof clientFor>, processName: string, response: Json): Promise<Json> {
let current = response
while (current.type === 'JOB_STARTED' || current.type === 'RUNNING') {
const jobUUID = String(current.jobUUID ?? response.jobUUID)
await new Promise((resolve) => setTimeout(resolve, 1000))
current = await unwrap(client.GET('/qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}', {
params: { path: { processName, processUUID: String(response.processUUID), jobUUID } },
})) as Json
if (current.type === 'RUNNING') console.error(` running ${current.current ?? ''}/${current.total ?? ''} ${current.message ?? ''}`)
current = { ...current, jobUUID, processUUID: response.processUUID }
}
return current
}
alias('query', 'table', 'query', 'Query a table')
alias('get', 'table', 'get', 'View one record')
alias('run', 'process', 'run', 'Run a process')
program.parseAsync().catch((error: unknown) => {
if (error instanceof UiApiError) {
console.error(`Error: ${error.message} (${error.code}, HTTP ${error.status}${error.traceId ? `, trace ${error.traceId}` : ''})`)
for (const fieldError of error.fieldErrors) console.error(` ${fieldError.field}: ${fieldError.message}`)
if (error.code === 'UNAUTHENTICATED') console.error('Sign in again with `qqq login`.')
} else {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
if (error instanceof CommanderError) {
process.exitCode = error.exitCode === 0 ? 0 : EXIT.usage
return
}
process.exitCode = 1
const { code, text } = describeError(error, Boolean(program.opts().trace))
process.stderr.write(text + '\n')
process.exitCode = code
})
+16
View File
@@ -129,3 +129,19 @@ export function clientFor(server: string): UiApiClient {
},
})
}
/** A fetch that adds this server's authentication and client channel, for calls outside the typed client. */
export function authenticatedFetch(server: string): typeof fetch {
return async (input, init) => {
const request = new Request(input as RequestInfo, init)
request.headers.set('X-QQQ-Client', CLIENT_CHANNEL)
const session = sessionCookieFor(server)
if (session) {
request.headers.set('Cookie', `sessionUUID=${session}`)
} else {
const token = await currentToken(server)
if (token) request.headers.set('Authorization', `Bearer ${token}`)
}
return fetch(request)
}
}
-35
View File
@@ -1,35 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { formatTable, parseCriteria, parseValues, valueText } from '../format.js'
const fields = { id: {}, status: {}, lastName: {} }
test('criteria parse operators, shorthand and lists', () => {
assert.deepEqual(parseCriteria('status EQUALS active', fields), { fieldName: 'status', operator: 'EQUALS', values: ['active'] })
assert.deepEqual(parseCriteria('id >= 10', fields), { fieldName: 'id', operator: 'GREATER_THAN_OR_EQUALS', values: ['10'] })
assert.deepEqual(parseCriteria('id IN 1, 2,3', fields), { fieldName: 'id', operator: 'IN', values: ['1', '2', '3'] })
assert.deepEqual(parseCriteria('lastName ~ van der', fields), { fieldName: 'lastName', operator: 'CONTAINS', values: ['van der'] })
assert.deepEqual(parseCriteria('lastName IS_BLANK', fields), { fieldName: 'lastName', operator: 'IS_BLANK', values: [] })
assert.throws(() => parseCriteria('nope EQUALS 1', fields), /No field nope/)
assert.throws(() => parseCriteria('id ABOUT 1', fields), /Unknown operator/)
})
test('values parse name=value pairs', () => {
assert.deepEqual(parseValues(['a=1', 'b=x=y']), { a: '1', b: 'x=y' })
assert.throws(() => parseValues(['=1']))
})
test('display values win over raw values', () => {
assert.equal(valueText({ values: { s: 1 }, displayValues: { s: 'Active' } }, 's'), 'Active')
assert.equal(valueText({ values: { s: 1 } }, 's'), '1')
assert.equal(valueText({ values: {} }, 's'), '')
})
test('tables align columns', () => {
assert.equal(formatTable([{ a: 'x', b: 'long value' }], ['a', 'b']), 'a b\n- ----------\nx long value')
})
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { EXIT, UsageError, describeError } from '../context.js'
import { defaultFields, parsePairs, readJson, valueText } from '../commands/shared.js'
test('pairs parse name=value, keeping = inside the value', () => {
assert.deepEqual(parsePairs(['a=1', 'b=x=y'], '--set'), { a: '1', b: 'x=y' })
assert.throws(() => parsePairs(['=1'], '--set'), UsageError)
})
test('readJson takes inline JSON and reports bad JSON as a usage error', () => {
assert.deepEqual(readJson('{"a":1}', '--input-json'), { a: 1 })
assert.throws(() => readJson('{nope', '--input-json'), UsageError)
})
test('display values win over raw values', () => {
assert.equal(valueText({ values: { s: 1 }, displayValues: { s: 'Active' } }, 's'), 'Active')
assert.equal(valueText({ values: { s: 1 } }, 's'), '1')
assert.equal(valueText({ values: {} }, 's'), '')
})
test('default fields follow the primary key and section tiers', () => {
const meta = { primaryKeyField: 'id', fields: { id: {}, z: {}, a: {}, b: {}, h: { isHidden: true } }, sections: [{ tier: 'T2', fieldNames: ['b'] }, { tier: 'T1', fieldNames: ['id', 'a'] }] }
assert.deepEqual(defaultFields(meta), ['id', 'a', 'b', 'z'])
})
test('exit codes follow the error code', () => {
assert.equal(describeError(new UiApiError(401, { code: 'UNAUTHENTICATED', message: 'x' })).code, EXIT.auth)
assert.equal(describeError(new UiApiError(404, { code: 'NOT_FOUND', message: 'x' })).code, EXIT.notFound)
assert.equal(describeError(new UiApiError(400, { code: 'BAD_REQUEST', message: 'x', fieldErrors: [{ field: 'email', message: 'bad' }] })).text, 'Error: x (BAD_REQUEST, HTTP 400)\n email: bad')
assert.equal(describeError(new UiApiError(429, { code: 'RATE_LIMITED', message: 'x' })).code, EXIT.transient)
assert.equal(describeError(new UiApiError(500, { code: 'INTERNAL_ERROR', message: 'x' })).code, EXIT.unexpected)
assert.equal(describeError(new UsageError('bad')).code, EXIT.usage)
assert.equal(describeError(new Error('other')).code, EXIT.error)
})
+89
View File
@@ -0,0 +1,89 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Calls any UI API operation by id with plain inputs: the escape hatch that makes every operation
* reachable before it has a first-class command. Paths are filled from `path`, `query` becomes the
* query string, and the body goes as JSON, multipart or octet-stream depending on what the
* operation accepts and what was given.
*/
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { operation, type OperationInfo } from './operations.js'
export interface CallInput {
path?: Record<string, string>
query?: Record<string, string | undefined>
/** A JSON body (object or array); sent as application/json when the operation accepts it. */
json?: unknown
/** Form fields; sent as multipart/form-data. Values that are `File` objects are uploaded. */
form?: Record<string, string | Blob>
/** Raw bytes for application/octet-stream operations. */
bytes?: Uint8Array
headers?: Record<string, string>
}
export interface CallResult {
status: number
headers: Headers
/** Parsed JSON when the response was JSON, else the text (or empty for binary; see `bytes`). */
data: unknown
bytes?: Uint8Array
contentType: string
}
/** Builds the URL of an operation from its path parameters and query. */
export function buildUrl(baseUrl: string, op: OperationInfo, input: CallInput): string {
let path = op.path
for (const name of op.pathParams) {
const value = input.path?.[name]
if (value === undefined) throw new Error(`${op.id} needs path parameter "${name}" (--path ${name}=...)`)
path = path.replace(`{${name}}`, encodeURIComponent(value))
}
const unknownPath = Object.keys(input.path ?? {}).filter((k) => !op.pathParams.includes(k))
if (unknownPath.length) throw new Error(`${op.id} has no path parameter ${unknownPath.join(', ')}; it takes ${op.pathParams.join(', ') || 'none'}`)
const url = new URL(baseUrl.replace(/\/$/, '') + path)
for (const [k, v] of Object.entries(input.query ?? {})) if (v !== undefined) url.searchParams.set(k, v)
return url.toString()
}
/**
* Performs the call. `fetchImpl` must add authentication (the session manager's fetch does).
*/
export async function callOperation(baseUrl: string, id: string, input: CallInput, fetchImpl: typeof fetch): Promise<CallResult> {
const op = operation(id)
if (!op) throw new Error(`No UI API operation "${id}" (see \`qqq api --list\`)`)
const url = buildUrl(baseUrl, op, input)
const headers: Record<string, string> = { Accept: 'application/json, */*;q=0.5', ...(input.headers ?? {}) }
let body: BodyInit | undefined
if (input.json !== undefined) {
if (!op.bodyTypes.includes('application/json') && op.bodyTypes.length) throw new Error(`${op.id} takes ${op.bodyTypes.join(' or ')}, not JSON`)
headers['Content-Type'] = 'application/json'
body = JSON.stringify(input.json)
} else if (input.form) {
const form = new FormData()
for (const [k, v] of Object.entries(input.form)) form.append(k, v)
body = form
} else if (input.bytes) {
headers['Content-Type'] = 'application/octet-stream'
body = input.bytes as unknown as BodyInit
}
const response = await fetchImpl(url, { method: op.method, headers, body })
const contentType = response.headers.get('content-type') ?? ''
if (!response.ok) {
const text = await response.text()
let parsed: unknown = text
try {
parsed = text ? JSON.parse(text) : {}
} catch {
// keep text
}
throw new UiApiError(response.status, parsed, response.headers.get('X-QQQ-Trace-Id'))
}
if (/json/i.test(contentType)) return { status: response.status, headers: response.headers, data: await response.json(), contentType }
if (/^text\//i.test(contentType) || contentType === '') return { status: response.status, headers: response.headers, data: await response.text(), contentType }
const bytes = new Uint8Array(await response.arrayBuffer())
return { status: response.status, headers: response.headers, data: undefined, bytes, contentType }
}
+100
View File
@@ -0,0 +1,100 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
// Generated by scripts/gen-operations.mjs from the UI API 1.8.0 document. Do not edit.
export interface OperationInfo {
id: string
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
path: string
summary: string
tag: string
pathParams: string[]
queryParams: string[]
bodyTypes: string[]
}
export const UI_API_DOCUMENT_VERSION = "1.8.0"
export const OPERATIONS: OperationInfo[] = [
{"id":"addRelatedRecords","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/add","summary":"Add child or join rows for picked records (MULTI_CHILD, LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"cancelJob","method":"POST","path":"/qqq/v1/jobs/{jobId}/cancel","summary":"Jobs: cancel","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"cancelProcess","method":"POST","path":"/qqq/v1/processes/{processName}/{processUUID}/cancel","summary":"Cancel a running process","tag":"Processes","pathParams":["processName","processUUID"],"queryParams":["tableVariant"],"bodyTypes":[]},
{"id":"chooseDeliveryTargets","method":"POST","path":"/qqq/v1/delivery/destinations/{name}/targets","summary":"Describe the next delivery target","tag":"General","pathParams":["name"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"countTable","method":"POST","path":"/qqq/v1/table/{tableName}/count","summary":"Count records in a table","tag":"Tables","pathParams":["tableName"],"queryParams":["includeDistinct"],"bodyTypes":["application/json"]},
{"id":"deleteRecord","method":"DELETE","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Delete a record from a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"download","method":"GET","path":"/qqq/v1/download/{file}","summary":"Download a file","tag":"General","pathParams":["file"],"queryParams":["filePath","storageTableName","storageReference"],"bodyTypes":[]},
{"id":"downloadRecordFieldFile","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/{fieldName}/{filename}","summary":"Download a field value from a record","tag":"Tables","pathParams":["tableName","primaryKey","fieldName","filename"],"queryParams":[],"bodyTypes":[]},
{"id":"exportTable","method":"POST","path":"/qqq/v1/table/{tableName}/export","summary":"Export records from a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"getAssociatedScriptLogs","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/associatedScript/{fieldName}/{scriptRevisionId}/logs","summary":"Get the logs of a record's associated script revision (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey","fieldName","scriptRevisionId"],"queryParams":[],"bodyTypes":[]},
{"id":"getAuthenticationMetaData","method":"GET","path":"/qqq/v1/metaData/authentication","summary":"Get authentication metaData","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"getAvatar","method":"GET","path":"/qqq/v1/avatars/{avatarId}","summary":"Get an avatar","tag":"Authentication","pathParams":["avatarId"],"queryParams":["size","v"],"bodyTypes":[]},
{"id":"getBrandingAsset","method":"GET","path":"/qqq/v1/branding/{role}","summary":"Get a branding image","tag":"Authentication","pathParams":["role"],"queryParams":[],"bodyTypes":[]},
{"id":"getBulkLoadUploadProfile","method":"GET","path":"/qqq/v1/bulkLoad/uploads/{uploadRef}/profile","summary":"Profile an uploaded file for a bulk load","tag":"Bulk Load","pathParams":["uploadRef"],"queryParams":["tableName","sampleCount"],"bodyTypes":[]},
{"id":"getDeliveryOutput","method":"GET","path":"/qqq/v1/delivery/output/{logId}","summary":"Download stored output as its owner or delivery administrator","tag":"General","pathParams":["logId"],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbDeadLettersByTrigger","method":"GET","path":"/qqq/v1/esb/deadLetters/{trigger}","summary":"Dead letters of a trigger","tag":"ESB","pathParams":["trigger"],"queryParams":["offset","limit"],"bodyTypes":[]},
{"id":"getEsbMessagesByDestination","method":"GET","path":"/qqq/v1/esb/messages/{destination}","summary":"Messages on a destination","tag":"ESB","pathParams":["destination"],"queryParams":["trigger","offset","limit"],"bodyTypes":[]},
{"id":"getEsbOverview","method":"GET","path":"/qqq/v1/esb/overview","summary":"ESB overview","tag":"ESB","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbProcessByProcess","method":"GET","path":"/qqq/v1/esb/process/{process}","summary":"ESB of a process","tag":"ESB","pathParams":["process"],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbTableByTable","method":"GET","path":"/qqq/v1/esb/table/{table}","summary":"ESB of a table","tag":"ESB","pathParams":["table"],"queryParams":[],"bodyTypes":[]},
{"id":"getHelpCoverage","method":"GET","path":"/qqq/v1/help/coverage","summary":"Inspect application help coverage","tag":"General","pathParams":[],"queryParams":["kind","missingOnly","parentKey"],"bodyTypes":[]},
{"id":"getJob","method":"GET","path":"/qqq/v1/jobs/{jobId}","summary":"Jobs: detail","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"getJobProgress","method":"GET","path":"/qqq/v1/jobs/{jobId}/progress","summary":"Jobs: progress","tag":"Processes","pathParams":["jobId"],"queryParams":["afterLogId","logLimit"],"bodyTypes":[]},
{"id":"getMetaData","method":"GET","path":"/qqq/v1/metaData","summary":"Get instance metaData","tag":"General","pathParams":[],"queryParams":["frontendName","frontendVersion","applicationName","applicationVersion"],"bodyTypes":[]},
{"id":"getPreferenceSection","method":"GET","path":"/qqq/v1/preferences/{sectionName}","summary":"Read one visible section for the signed-in user","tag":"General","pathParams":["sectionName"],"queryParams":[],"bodyTypes":[]},
{"id":"getProcessMetaData","method":"GET","path":"/qqq/v1/metaData/process/{processName}","summary":"Get process metaData","tag":"Processes","pathParams":["processName"],"queryParams":[],"bodyTypes":[]},
{"id":"getProcessRecords","method":"GET","path":"/qqq/v1/processes/{processName}/{processUUID}/records","summary":"Get records from a process","tag":"Processes","pathParams":["processName","processUUID"],"queryParams":["skip","limit","tableVariant"],"bodyTypes":[]},
{"id":"getProcessStatus","method":"GET","path":"/qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}","summary":"Get job status","tag":"Processes","pathParams":["processName","processUUID","jobUUID"],"queryParams":["tableVariant"],"bodyTypes":[]},
{"id":"getRecord","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Get a record from a table by primary key","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["includeAssociations","queryJoins","tableVariant"],"bodyTypes":[]},
{"id":"getRecordDeveloperMode","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer","summary":"Get a record's associated scripts (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"getRecordFeatures","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/features","summary":"Get record collaboration features","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"getTableApiDocument","method":"GET","path":"/qqq/v1/table/{tableName}/apis/{apiName}/{version}/openapi","summary":"Get a table's OpenAPI document in an application API","tag":"Tables","pathParams":["tableName","apiName","version"],"queryParams":[],"bodyTypes":[]},
{"id":"getTableMetaData","method":"GET","path":"/qqq/v1/metaData/table/{tableName}","summary":"Get table metaData","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
{"id":"getVersion","method":"GET","path":"/qqq/v1/version","summary":"Get the UI API version","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"getWidgetData","method":"POST","path":"/qqq/v1/widget/{widgetName}","summary":"Render a widget","tag":"Widgets","pathParams":["widgetName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"initProcess","method":"POST","path":"/qqq/v1/processes/{processName}/init","summary":"Initialize a process","tag":"Processes","pathParams":["processName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data"]},
{"id":"insertRecord","method":"POST","path":"/qqq/v1/table/{tableName}","summary":"Insert a record into a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json","multipart/form-data"]},
{"id":"launchProcessJob","method":"POST","path":"/qqq/v1/processes/{processName}/jobs","summary":"Run a process as a durable background job","tag":"Processes","pathParams":["processName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"listDeliveryDestinations","method":"GET","path":"/qqq/v1/delivery/destinations","summary":"List authorized output destinations","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"listJobs","method":"GET","path":"/qqq/v1/jobs","summary":"Jobs: list","tag":"Processes","pathParams":[],"queryParams":["scope","state","limit","processName"],"bodyTypes":[]},
{"id":"listPreferenceSections","method":"GET","path":"/qqq/v1/preferences","summary":"List visible sections and the signed-in user's preferences","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"listRecordDeveloperPanels","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/panels","summary":"Get a record's developer panels","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"listTableApis","method":"GET","path":"/qqq/v1/table/{tableName}/apis","summary":"List the APIs that expose a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
{"id":"listTableVariants","method":"GET","path":"/qqq/v1/table/{tableName}/variants","summary":"List a table's backend variants","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
{"id":"logout","method":"POST","path":"/qqq/v1/logout","summary":"Logout and invalidate session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"lookupAvatars","method":"POST","path":"/qqq/v1/avatars/lookup","summary":"Look up users' avatars","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"manageSession","method":"POST","path":"/qqq/v1/manageSession","summary":"Create a session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"oidcBackChannelLogout","method":"POST","path":"/qqq/v1/oidc/backchannel-logout","summary":"OIDC Back-Channel Logout","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"openRealtimeStream","method":"GET","path":"/qqq/v1/realtime/stream","summary":"Open the realtime event stream","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"prepareRelatedRecordPicker","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/prepare","summary":"Prepare child rows for picked records (MULTI_CHILD, LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"queryRelatedRecordPicker","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/query","summary":"Query the records a related-record picker offers","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"queryTable","method":"POST","path":"/qqq/v1/table/{tableName}/query","summary":"Query for records from a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"removeRelatedRecords","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/remove","summary":"Unlink picked records (LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"retryJob","method":"POST","path":"/qqq/v1/jobs/{jobId}/retry","summary":"Jobs: retry","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"runFormAdjuster","method":"POST","path":"/qqq/v1/table/{tableName}/formAdjuster/{identifier}/{event}","summary":"Run a form adjuster","tag":"Tables","pathParams":["tableName","identifier","event"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"runPreferenceSectionAction","method":"POST","path":"/qqq/v1/preferences/{sectionName}/actions/{actionName}","summary":"Run an action on the signed-in user's preferences","tag":"General","pathParams":["sectionName","actionName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"runReport","method":"GET","path":"/qqq/v1/reports/{reportName}","summary":"Run a report and download the file","tag":"Reports","pathParams":["reportName"],"queryParams":["format"],"bodyTypes":[]},
{"id":"searchHelp","method":"GET","path":"/qqq/v1/help/search","summary":"Search visible application help","tag":"General","pathParams":[],"queryParams":["q","limit"],"bodyTypes":[]},
{"id":"searchPossibleValues","method":"POST","path":"/qqq/v1/possibleValues/{possibleValueSourceName}","summary":"Search a standalone possible value source","tag":"General","pathParams":["possibleValueSourceName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"searchProcessPossibleValues","method":"POST","path":"/qqq/v1/processes/{processName}/possibleValues/{fieldName}","summary":"Search possible values for a process field","tag":"Processes","pathParams":["processName","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"searchRecords","method":"POST","path":"/qqq/v1/search","summary":"Search records across tables","tag":"Tables","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"searchTablePossibleValues","method":"POST","path":"/qqq/v1/table/{tableName}/possibleValues/{fieldName}","summary":"Search possible values for a table field","tag":"Tables","pathParams":["tableName","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"stepProcess","method":"POST","path":"/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}","summary":"Run a step in a process","tag":"Processes","pathParams":["processName","processUUID","stepName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data"]},
{"id":"storeAssociatedScript","method":"POST","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/associatedScript/{fieldName}","summary":"Store a new revision of a record's associated script (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"suggestBulkLoadMapping","method":"POST","path":"/qqq/v1/bulkLoad/{tableName}/suggestMapping","summary":"Suggest a bulk load mapping for an uploaded file","tag":"Bulk Load","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"trackJob","method":"POST","path":"/qqq/v1/jobs/track","summary":"Jobs: track","tag":"Processes","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"updatePreferenceSection","method":"PATCH","path":"/qqq/v1/preferences/{sectionName}","summary":"Partially update the signed-in user's preferences","tag":"General","pathParams":["sectionName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"updateRealtimeSubscriptions","method":"POST","path":"/qqq/v1/realtime/subscriptions","summary":"Change realtime subscriptions","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"updateRecord","method":"PATCH","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Update a record in a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["expectedVersion"],"bodyTypes":["application/json","multipart/form-data"]},
{"id":"upload","method":"POST","path":"/qqq/v1/uploads","summary":"Upload a file for a job or a bulk load","tag":"Processes","pathParams":[],"queryParams":["fileName"],"bodyTypes":["multipart/form-data","application/octet-stream"]},
{"id":"validateBulkLoad","method":"POST","path":"/qqq/v1/bulkLoad/{tableName}/validate","summary":"Dry-run a bulk load profile over an uploaded file","tag":"Bulk Load","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
]
const byId = new Map(OPERATIONS.map((o) => [o.id, o]))
/** The operation with this id, or undefined. */
export function operation(id: string): OperationInfo | undefined {
return byId.get(id)
}
+3 -2
View File
@@ -277,8 +277,9 @@ export function buildFilter(options: { where?: string[]; group?: string[]; or?:
const criteria = (options.where ?? []).map((w) => parseCriterion(w, options.knownFields))
const subFilters = (options.group ?? []).map((g) => parseFilter(g, options.knownFields))
const orderBys = (options.order ?? []).map((o) => ({ fieldName: o.replace(/^-/, ''), isAscending: !o.startsWith('-') }))
const filter: QueryFilter = { criteria, booleanOperator: options.or ? 'OR' : 'AND' }
if (subFilters.length) filter.subFilters = subFilters
// one --group and no --where is the filter itself, not a wrapper around it
const filter: QueryFilter = criteria.length === 0 && subFilters.length === 1 && !options.or ? { ...subFilters[0]! } : { criteria, booleanOperator: options.or ? 'OR' : 'AND' }
if (subFilters.length && filter !== subFilters[0] && !(criteria.length === 0 && subFilters.length === 1 && !options.or)) filter.subFilters = subFilters
if (orderBys.length) filter.orderBys = orderBys
if (options.skip !== undefined) filter.skip = options.skip
if (options.limit !== undefined) filter.limit = options.limit
+2
View File
@@ -5,3 +5,5 @@
export * from './render/output.js'
export * from './filter/grammar.js'
export * from './api/operations.js'
export * from './api/call.js'
+38
View File
@@ -0,0 +1,38 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildUrl, callOperation } from '../api/call.js'
import { OPERATIONS, operation } from '../api/operations.js'
test('the operations index knows every core operation', () => {
assert.ok(OPERATIONS.length >= 60)
const q = operation('queryTable')!
assert.deepEqual([q.method, q.path, q.pathParams, q.bodyTypes], ['POST', '/qqq/v1/table/{tableName}/query', ['tableName'], ['application/json']])
})
test('buildUrl fills path parameters and the query string, and rejects unknown ones', () => {
const op = operation('getRecord')!
assert.equal(buildUrl('http://h/', op, { path: { tableName: 'person', primaryKey: 'a b' }, query: { includeAssociations: 'true' } }), 'http://h/qqq/v1/table/person/a%20b?includeAssociations=true')
assert.throws(() => buildUrl('http://h', op, { path: { tableName: 'person' } }), /needs path parameter "primaryKey"/)
assert.throws(() => buildUrl('http://h', op, { path: { tableName: 'p', primaryKey: '1', nope: 'x' } }), /no path parameter nope/)
})
test('callOperation sends JSON bodies and throws UiApiError on failures', async () => {
const seen: Request[] = []
const fetchImpl: typeof fetch = async (input, init) => {
const request = new Request(input as RequestInfo, init)
seen.push(request)
if (request.url.endsWith('/count')) return new Response(JSON.stringify({ count: 3 }), { status: 200, headers: { 'content-type': 'application/json' } })
return new Response(JSON.stringify({ code: 'NOT_FOUND', message: 'nope' }), { status: 404, headers: { 'content-type': 'application/json' } })
}
const result = await callOperation('http://h', 'countTable', { path: { tableName: 'person' }, json: { filter: { criteria: [] } } }, fetchImpl)
assert.deepEqual(result.data, { count: 3 })
assert.equal(seen[0]!.method, 'POST')
assert.equal(seen[0]!.headers.get('content-type'), 'application/json')
await assert.rejects(callOperation('http://h', 'getRecord', { path: { tableName: 'person', primaryKey: '9' } }, fetchImpl), (e: Error & { code?: string }) => e.code === 'NOT_FOUND')
assert.throws(() => buildUrl('http://h', operation('bogus') as never, {}), TypeError)
})
+3
View File
@@ -63,4 +63,7 @@ test('buildFilter assembles where, group, or, order, skip and limit', () => {
assert.equal(filter.limit, 25)
assert.equal(printFilter(filter), 'status EQUALS active AND ownerId EQUALS me() AND (x IS_BLANK OR y IS_BLANK)')
assert.equal(buildFilter({ where: ['a = 1', 'b = 2'], or: true }).booleanOperator, 'OR')
const only = buildFilter({ group: ['(a = 1 OR b = 2) AND c IS_BLANK'] })
assert.equal(only.criteria.length, 1)
assert.equal(only.subFilters?.length, 1)
})
+3 -1
View File
@@ -17,6 +17,8 @@ export interface RealtimeStreamOptions {
clientChannel?: string
lastEventId?: string
signal?: AbortSignal
/** Extra headers, e.g. a `Cookie: sessionUUID=...` for session-based sign-ins. */
headers?: Record<string, string>
fetch?: typeof fetch
}
@@ -31,7 +33,7 @@ export interface RealtimeStreamOptions {
* @param options - Server, token and optional Last-Event-ID.
*/
export async function* openRealtimeStream(options: RealtimeStreamOptions): AsyncGenerator<RealtimeMessage> {
const headers: Record<string, string> = { Accept: 'text/event-stream' }
const headers: Record<string, string> = { Accept: 'text/event-stream', ...(options.headers ?? {}) }
const token = await options.token?.()
if (token) headers.Authorization = `Bearer ${token}`
if (options.clientChannel) headers['X-QQQ-Client'] = options.clientChannel
+73
View File
@@ -0,0 +1,73 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Writes packages/core/src/api/operations.ts from the merged OpenAPI document: one entry per
* operation (id, method, path, path and query parameters, body content types), so the kernel can
* call any operation by id without the generated types, and the CLI can complete operation ids.
*
* Usage: node scripts/gen-operations.mjs [spec.json] [out.ts]
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
const [specPath = 'packages/ui-api-client/openapi/qqq-v1.json', outPath = 'packages/core/src/api/operations.ts'] = process.argv.slice(2)
const spec = JSON.parse(readFileSync(specPath, 'utf8'))
const operations = []
for (const [path, item] of Object.entries(spec.paths)) {
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const op = item[method]
if (!op) continue
const params = [...(item.parameters ?? []), ...(op.parameters ?? [])]
operations.push({
id: op.operationId,
method: method.toUpperCase(),
path,
summary: (op.summary ?? op.description ?? '').split('\n')[0].slice(0, 120),
tag: (op.tags ?? [])[0] ?? '',
pathParams: params.filter((p) => p.in === 'path').map((p) => p.name),
queryParams: params.filter((p) => p.in === 'query').map((p) => p.name),
bodyTypes: Object.keys(op.requestBody?.content ?? {}),
})
}
}
operations.sort((a, b) => a.id.localeCompare(b.id))
const lines = [
'/*',
' * Copyright 2026 QRun.IO, Inc.',
' * Licensed under the Apache License, Version 2.0 (see LICENSE).',
' */',
'',
`// Generated by scripts/gen-operations.mjs from the UI API ${spec.info.version} document. Do not edit.`,
'',
'export interface OperationInfo {',
' id: string',
" method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'",
' path: string',
' summary: string',
' tag: string',
' pathParams: string[]',
' queryParams: string[]',
' bodyTypes: string[]',
'}',
'',
`export const UI_API_DOCUMENT_VERSION = ${JSON.stringify(spec.info.version)}`,
'',
'export const OPERATIONS: OperationInfo[] = [',
...operations.map((o) => ` ${JSON.stringify(o)},`),
']',
'',
'const byId = new Map(OPERATIONS.map((o) => [o.id, o]))',
'',
'/** The operation with this id, or undefined. */',
'export function operation(id: string): OperationInfo | undefined {',
' return byId.get(id)',
'}',
'',
]
mkdirSync(dirname(outPath), { recursive: true })
writeFileSync(outPath, lines.join('\n'))
console.log(`${operations.length} operations -> ${outPath}`)