diff --git a/TRY-IT.md b/TRY-IT.md index d3baa22..bb3b809 100644 --- a/TRY-IT.md +++ b/TRY-IT.md @@ -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 `; 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 `, 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 diff --git a/package-lock.json b/package-lock.json index f79af3c..7e9c7d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index cf8075f..3c6f01e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/cli/package.json b/packages/cli/package.json index 027f816..feca983 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.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" } } diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts new file mode 100644 index 0000000..5dd964e --- /dev/null +++ b/packages/cli/src/commands/api.ts @@ -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 ', 'path parameters') + .option('-q, --param ', 'query parameters') + .option('-b, --body-json ', 'JSON request body (inline, a file, or - for stdin)') + .option('-F, --form ', 'multipart form fields; @file uploads a file') + .option('--bytes ', 'raw request body (application/octet-stream)') + .option('-o, --out ', '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 --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 | 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 `) + 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 }) + }) +} diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts new file mode 100644 index 0000000..87f5ca1 --- /dev/null +++ b/packages/cli/src/commands/auth.ts @@ -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 + 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'] }) + }) +} diff --git a/packages/cli/src/commands/job.ts b/packages/cli/src/commands/job.ts new file mode 100644 index 0000000..874965f --- /dev/null +++ b/packages/cli/src/commands/job.ts @@ -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 ', '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 ') + .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 ') + .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 ').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 ').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' }) }) +} diff --git a/packages/cli/src/commands/misc.ts b/packages/cli/src/commands/misc.ts new file mode 100644 index 0000000..93af1de --- /dev/null +++ b/packages/cli/src/commands/misc.ts @@ -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) + .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 ') + .description('Search records across tables (the global search of the web UI).') + .option('-t, --table ', 'only this table') + .option('-l, --limit ', '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 ', '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()) + } + }) +} diff --git a/packages/cli/src/commands/process.ts b/packages/cli/src/commands/process.ts new file mode 100644 index 0000000..0999acc --- /dev/null +++ b/packages/cli/src/commands/process.ts @@ -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 { + 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 { + 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 ', '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) + .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 ') + .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 ') + .description('Run a process to completion: init, then each step with the given values, polling asynchronous jobs.') + .option('-v, --values ', 'input values for form fields (any step)') + .option('--values-json ', 'input values as a JSON object') + .option('-r, --records ', 'comma-separated primary keys of the process table to run on') + .option('--filter-json ', 'a QueryFilter selecting the records to run on') + .option('--timeout ', '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) => { + 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 = 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.' }) + } + }) +} diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts new file mode 100644 index 0000000..07b8f19 --- /dev/null +++ b/packages/cli/src/commands/shared.ts @@ -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 + +/** Parses `name=value` pairs (`--set`, `--values`). */ +export function parsePairs(pairs: string[] | undefined, flag: string): Record { + const values: Record = {} + 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 + 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 + return names.map((n) => String(fields[n]?.label ?? n)) +} diff --git a/packages/cli/src/commands/table.ts b/packages/cli/src/commands/table.ts new file mode 100644 index 0000000..d493581 --- /dev/null +++ b/packages/cli/src/commands/table.ts @@ -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 { + 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 ', '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).filter((t) => !t.isHidden && t.readPermission !== false) + if (options.app) { + const app = (metaData.apps as Record | undefined)?.[options.app] + if (!app) throw new UsageError(`No app named ${options.app} (see \`qqq apps\`)`) + const names = new Set() + const visit = (node: Json | undefined, seen: Set) => { + 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(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 ') + .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).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
') + .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, ANDed (or --or)') + .option('-g, --group ', '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 ', 'order by fields; prefix - for descending') + .option('-l, --limit ', 'most records to return', '25') + .option('--skip ', 'records to skip') + .option('--all', 'fetch every page (with --page-size)') + .option('--page-size ', 'page size for --all', '500') + .option('-f, --fields ', 'comma-separated fields to show') + .option('--filter-json ', '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
') + .description('Count the records matching criteria.') + .option('-w, --where ', 'criteria, ANDed (or --or)') + .option('-g, --group ', 'a group with AND/OR and parentheses') + .option('--or', 'OR the --where criteria') + .option('--filter-json ', '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
') + .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 + 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
') + .description('Insert a record from --set name=value pairs or --input-json.') + .option('-s, --set ', 'field values') + .option('--input-json ', '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 + 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
') + .description('Update fields of a record from --set name=value pairs or --input-json.') + .option('-s, --set ', 'field values (an empty value clears the field)') + .option('--input-json ', 'field values as a JSON object') + .option('--expected-version ', '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 + 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
') + .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
') + .description('Export matching records to a file (csv, xlsx, json, tsv).') + .option('-w, --where ', 'criteria, ANDed (or --or)') + .option('-g, --group ', 'a group with AND/OR and parentheses') + .option('--or', 'OR the --where criteria') + .option('-o, --order ', 'order by fields; prefix - for descending') + .option('--filter-json ', 'a QueryFilter as JSON') + .option('--format ', 'csv, xlsx, json or tsv', 'csv') + .option('-f, --fields ', 'comma-separated fields to include') + .option('-l, --limit ', 'most records') + .option('--out ', 'output file (default:
.)') + .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 ').action(async (options: { app?: string }) => { await table.commands.find((x) => x.name() === 'list')!.parseAsync([...(options.app ? ['--app', options.app] : [])], { from: 'user' }) }) +} diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts new file mode 100644 index 0000000..c03275f --- /dev/null +++ b/packages/cli/src/context.ts @@ -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() + 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(data: T): unknown { + return applyQuery(data, this.options.query) + } + + /** Calls an operation by id (the generic escape hatch). */ + call(id: string, input: CallInput): Promise { + return callOperation(this.server, id, input, authenticatedFetch(this.server)) + } + + /** Unwraps a typed client call. */ + unwrap(result: Promise<{ data?: T; error?: unknown; response: Response }>): Promise { + return unwrap(result) + } + + /** Asks on the terminal unless --yes; without a terminal and without --yes, refuses. */ + async confirm(question: string): Promise { + 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}` } +} diff --git a/packages/cli/src/format.ts b/packages/cli/src/format.ts deleted file mode 100644 index 4680d41..0000000 --- a/packages/cli/src/format.ts +++ /dev/null @@ -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 -type Operator = NonNullable - -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 = { - '=': '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): 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 { - const values: Record = {} - 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>, 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') -} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 89544c8..b2fa208 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -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 ` (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 +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 ', 'the QQQ server (default: QQQ_SERVER, or the last one signed in to)') - .option('--json', 'print JSON instead of tables') + .option('-O, --output ', '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 ', 'reshape the output with a JMESPath expression, as `aws --query`') + .option('--fields ', '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 - 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) - .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 ', '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).filter((t) => !t.isHidden && t.readPermission !== false) - if (options.app) { - const app = (metaData.apps as Record | undefined)?.[options.app] - if (!app) throw new Error(`No app named ${options.app} (see \`qqq apps\`)`) - const names = new Set() - const visit = (node: Json | undefined, seen: Set) => { - 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(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
') - .description('Query a table. Criteria: "field OPERATOR value[,value]", e.g. "status EQUALS active" or "id GREATER_THAN 10".') - .option('-w, --where ', 'criteria (ANDed)') - .option('--or', 'OR the criteria instead') - .option('-o, --order ', 'order by a field (prefix with - for descending)') - .option('-l, --limit ', 'most records to return', '25') - .option('-f, --fields ', '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 - 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 + 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
') - .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 - 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 ') - .description('Run a process: init, then each step until it completes; prints its results.') - .option('-v, --value ', 'process input values') - .option('-r, --records ', 'comma-separated primary keys of the process table\'s records to run on') - .option('--timeout ', '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) => { - 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 = 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 ', '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).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, processName: string, response: Json): Promise { - 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 }) diff --git a/packages/cli/src/session.ts b/packages/cli/src/session.ts index a0d251a..b8c4d44 100644 --- a/packages/cli/src/session.ts +++ b/packages/cli/src/session.ts @@ -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) + } +} diff --git a/packages/cli/src/test/format.test.ts b/packages/cli/src/test/format.test.ts deleted file mode 100644 index 3153492..0000000 --- a/packages/cli/src/test/format.test.ts +++ /dev/null @@ -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') -}) diff --git a/packages/cli/src/test/shared.test.ts b/packages/cli/src/test/shared.test.ts new file mode 100644 index 0000000..cac8bd7 --- /dev/null +++ b/packages/cli/src/test/shared.test.ts @@ -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) +}) diff --git a/packages/core/src/api/call.ts b/packages/core/src/api/call.ts new file mode 100644 index 0000000..de46724 --- /dev/null +++ b/packages/core/src/api/call.ts @@ -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 + query?: Record + /** 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 + /** Raw bytes for application/octet-stream operations. */ + bytes?: Uint8Array + headers?: Record +} + +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 { + 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 = { 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 } +} diff --git a/packages/core/src/api/operations.ts b/packages/core/src/api/operations.ts new file mode 100644 index 0000000..088f6f9 --- /dev/null +++ b/packages/core/src/api/operations.ts @@ -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) +} diff --git a/packages/core/src/filter/grammar.ts b/packages/core/src/filter/grammar.ts index 5572b6b..da4aa58 100644 --- a/packages/core/src/filter/grammar.ts +++ b/packages/core/src/filter/grammar.ts @@ -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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 951232f..1c6b5ed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,3 +5,5 @@ export * from './render/output.js' export * from './filter/grammar.js' +export * from './api/operations.js' +export * from './api/call.js' diff --git a/packages/core/src/test/call.test.ts b/packages/core/src/test/call.test.ts new file mode 100644 index 0000000..41cb404 --- /dev/null +++ b/packages/core/src/test/call.test.ts @@ -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) +}) diff --git a/packages/core/src/test/grammar.test.ts b/packages/core/src/test/grammar.test.ts index 0a38379..abae7a3 100644 --- a/packages/core/src/test/grammar.test.ts +++ b/packages/core/src/test/grammar.test.ts @@ -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) }) diff --git a/packages/ui-api-client/src/realtime.ts b/packages/ui-api-client/src/realtime.ts index c2c0877..6fb4dfa 100644 --- a/packages/ui-api-client/src/realtime.ts +++ b/packages/ui-api-client/src/realtime.ts @@ -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 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 { - const headers: Record = { Accept: 'text/event-stream' } + const headers: Record = { 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 diff --git a/scripts/gen-operations.mjs b/scripts/gen-operations.mjs new file mode 100644 index 0000000..e3ccfcd --- /dev/null +++ b/scripts/gen-operations.mjs @@ -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}`)