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:
1 parent
a0cc4d8be2
commit
8ed694cd2f
25 files changed
+1273
-407
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Generated
+1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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'] })
|
||||
})
|
||||
}
|
||||
@@ -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' }) })
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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' }) })
|
||||
}
|
||||
@@ -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}` }
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,3 +5,5 @@
|
||||
|
||||
export * from './render/output.js'
|
||||
export * from './filter/grammar.js'
|
||||
export * from './api/operations.js'
|
||||
export * from './api/call.js'
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
Reference in new issue
Block a user