From f4202b40fc0bc8c050ddf67b0508c4d02233b758 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 18:39:04 +0200 Subject: [PATCH 1/5] feat(cli): gh-style api fields, pagination, jq and templates -f/--raw-field and typed -F/--field with nesting, arrays and @file, --paginate with --slurp and --max-pages, embedded jq via jq-wasm, and a Go-style --template. --- README.md | 60 ++++++++- package-lock.json | 9 ++ package.json | 1 + src/api/fields.ts | 219 +++++++++++++++++++++++++++++++ src/api/jq.ts | 22 ++++ src/api/paginate.ts | 72 ++++++++++ src/api/request.ts | 32 ++--- src/api/template.ts | 246 +++++++++++++++++++++++++++++++++++ src/commands/api.ts | 144 ++++++++++++++++++-- test/api/fields.test.ts | 88 +++++++++++++ test/api/template.test.ts | 51 ++++++++ test/commands/api-gh.test.ts | 167 ++++++++++++++++++++++++ 12 files changed, 1073 insertions(+), 38 deletions(-) create mode 100644 src/api/fields.ts create mode 100644 src/api/jq.ts create mode 100644 src/api/paginate.ts create mode 100644 src/api/template.ts create mode 100644 test/api/fields.test.ts create mode 100644 test/api/template.test.ts create mode 100644 test/commands/api-gh.test.ts diff --git a/README.md b/README.md index 641c23e1..dd470f80 100644 --- a/README.md +++ b/README.md @@ -1054,9 +1054,10 @@ polymorfa api /v1/projects --surface platform polymorfa api /v1/campaigns \ --surface platform \ - --method POST \ - --field projectId=project_123 \ - --field name='August launch' \ + -X POST \ + -f projectId=project_123 \ + -f name='August launch' \ + -F dryRun=true \ --json polymorfa api /v1/campaigns \ @@ -1072,8 +1073,57 @@ cat campaign.json | polymorfa api /v1/campaigns \ GET and HEAD fields become query parameters. Other methods encode fields as a JSON object. `--input` reads one JSON value from a file or standard input and -is capped at 10 MiB. Use `--header name:value` for additional headers and -`--include` to include response metadata in human output. +is capped at 10 MiB. Use `-H/--header name:value` for additional headers and +`-i/--include` to include response metadata in human output. `-X` is short for +`--method`. + +### Fields + +- `-f/--raw-field name=value` always sends a string. +- `-F/--field name=value` converts `true`, `false`, `null`, and integers to JSON + values. `@path` reads a file and `@-` reads standard input as a string. + Anything else stays a string. +- Both accept `name[key]=value` to build nested objects and `name[]=value` to + append to an array. GET and HEAD query parameters accept values and `name[]` + arrays but not nested objects. + +`--field` previously sent every value as a string. Use `-f` where a value such +as `10` or `true` must stay a string. + +### Pagination and output filters + +```shell +# Every page, one ID per line +polymorfa api /platform/customers --surface platform --paginate --jq '.data[].id' + +# All pages as one array, then one jq program over it +polymorfa api /platform/customers --surface platform --paginate --slurp \ + --jq 'map(.data | length) | add' + +# Go-style template +polymorfa api /platform/customers --surface platform \ + --template '{{range .data}}{{.id}}{{"\t"}}{{.name}}{{"\n"}}{{end}}' +``` + +- `--paginate` (GET only) follows `page.nextCursor` by sending it back as the + `cursor` query parameter until the cursor is null or `hasMore` is false. It + stops with an error after `--max-pages` pages (100 by default) and when the + API returns the same cursor twice. Without a filter, human output prints each + page as it arrives; `--json` prints one document whose `data` is the array of + page bodies and whose `metadata.pages` is the page count. +- `--slurp` requires `--paginate` and combines the page bodies into one array + before `--jq` or `--template` runs. Without `--slurp`, filters run on each page. +- `-q/--jq` runs a jq 1.8 program with an embedded WebAssembly jq, so no `jq` + binary is required. Strings print without quotes. The jq runtime has its own + environment, so `$ENV` does not expose your shell variables or credentials. +- `-t/--template` renders a Go-style template: `{{.path.to.value}}`, + `{{range .list}}…{{else}}…{{end}}`, `{{if .value}}…{{else}}…{{end}}`, + `{{$.root}}` inside a range, `{{json .x}}`, `{{len .x}}`, `{{upper .x}}`, + `{{lower .x}}`, `{{join ", " .list}}`, string literals such as `{{"\n"}}`, + comments, and `{{-`/`-}}` whitespace trimming. Missing values render as an + empty string. +- `--jq`, `--template`, and `--json` are mutually exclusive, as are `--include` + and a filter. `--json` prints exactly one JSON document on stdout. `--quiet` suppresses successful output without hiding failures. Diagnostics and errors go to stderr; diff --git a/package-lock.json b/package-lock.json index 1bccc14f..489c34a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", + "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, "bin": { @@ -2514,6 +2515,14 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/jq-wasm": { + "version": "3.0.0-jq-1.8.2", + "resolved": "https://registry.npmjs.org/jq-wasm/-/jq-wasm-3.0.0-jq-1.8.2.tgz", + "integrity": "sha512-jgWSEBJSd0lYR4Q5Fw8333MxQS5jCRI+g9KwAGL7yK1spwzTJy8C5uOS08wbpKE0Gz8oQYLlRpNLE/W70Ksp2g==", + "engines": { + "node": ">=20" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", diff --git a/package.json b/package.json index ce99677a..fd309572 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", + "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, "devDependencies": { diff --git a/src/api/fields.ts b/src/api/fields.ts new file mode 100644 index 00000000..83341b84 --- /dev/null +++ b/src/api/fields.ts @@ -0,0 +1,219 @@ +import {readFile} from 'node:fs/promises' + +import {PolymorfaValidationError} from '@polymorfa/sdk' + +const MAX_FIELD_FILE_BYTES = 10 * 1024 * 1024 + +export type FieldValue = + string | number | boolean | null | FieldValue[] | {[key: string]: FieldValue} + +export interface FieldInput { + /** `raw` for -f (always a string), `typed` for -F. */ + readonly kind: 'raw' | 'typed' + readonly value: string +} + +export interface FieldReaders { + readonly readFile?: (path: string) => Promise + readonly readStdin?: () => Promise +} + +/** + * Parse `gh api`-style fields into one object. + * + * -f/--raw-field keeps the value as a string. -F/--field converts `true`, + * `false`, `null` and integers to JSON, and reads `@file` (or `@-` for stdin) + * as a string. Keys accept `name[sub]` nesting and `name[]` array appends. + */ +export async function parseFields( + fields: readonly FieldInput[], + readers: FieldReaders = {}, +): Promise | undefined> { + if (fields.length === 0) return undefined + const root: Record = {} + let stdinUsed = false + for (const field of fields) { + const separator = field.value.indexOf('=') + if (separator <= 0) { + throw new PolymorfaValidationError( + 'API fields must use the name=value form with a non-empty name.', + {code: 'invalid_field'}, + ) + } + const key = field.value.slice(0, separator) + const raw = field.value.slice(separator + 1) + let value: FieldValue + if (field.kind === 'raw') { + value = raw + } else if (raw.startsWith('@')) { + if (raw === '@-') { + if (stdinUsed) { + throw new PolymorfaValidationError( + 'Only one field can read standard input.', + {code: 'invalid_field'}, + ) + } + stdinUsed = true + value = await (readers.readStdin ?? readStandardInput)() + } else { + value = await (readers.readFile ?? readFieldFile)(raw.slice(1)) + } + } else { + value = typedValue(raw) + } + assign(root, parseKey(key), value, key) + } + return root +} + +/** GET and HEAD fields become query parameters, which cannot nest objects. */ +export function queryFromFields( + fields: Record, +): Record { + const query: Record< + string, + string | number | boolean | (string | number | boolean)[] + > = {} + for (const [key, value] of Object.entries(fields)) { + if (value === null) { + query[key] = 'null' + } else if (Array.isArray(value)) { + query[key] = value.map((item) => { + if (item === null) return 'null' + if (typeof item === 'object') throw nestedQuery(key) + return item + }) + } else if (typeof value === 'object') { + throw nestedQuery(key) + } else { + query[key] = value + } + } + return query +} + +function nestedQuery(key: string): PolymorfaValidationError { + return new PolymorfaValidationError( + `Field ${key} is nested; GET and HEAD query parameters accept only values and name[] arrays.`, + {code: 'invalid_field'}, + ) +} + +function typedValue(raw: string): FieldValue { + if (raw === 'true') return true + if (raw === 'false') return false + if (raw === 'null') return null + if (/^-?(0|[1-9]\d*)$/.test(raw)) { + const parsed = Number(raw) + if (Number.isSafeInteger(parsed)) return parsed + } + return raw +} + +type KeySegment = string | typeof APPEND +const APPEND = Symbol('append') + +function parseKey(key: string): KeySegment[] { + const match = /^([^[\]]+)((?:\[[^[\]]*\])*)$/.exec(key) + if (match === null) { + throw new PolymorfaValidationError( + `Invalid field name ${key}. Use name, name[sub], or name[].`, + {code: 'invalid_field'}, + ) + } + const segments: KeySegment[] = [match[1] as string] + for (const [, part] of (match[2] ?? '').matchAll(/\[([^[\]]*)\]/g)) { + segments.push(part === '' ? APPEND : (part as string)) + } + for (let index = 0; index < segments.length - 1; index++) { + if (segments[index] === APPEND) { + throw new PolymorfaValidationError( + `Invalid field name ${key}. [] may appear only at the end.`, + {code: 'invalid_field'}, + ) + } + } + return segments +} + +function assign( + root: Record, + segments: readonly KeySegment[], + value: FieldValue, + key: string, +): void { + let target: Record = root + for (let index = 0; index < segments.length; index++) { + const segment = segments[index] as KeySegment + const last = index === segments.length - 1 + const next = segments[index + 1] + if (segment === APPEND) return // handled by the previous segment + if (last) { + target[segment] = value + return + } + if (next === APPEND) { + const existing = target[segment] + if (existing === undefined) target[segment] = [value] + else if (Array.isArray(existing)) existing.push(value) + else throw conflict(key) + return + } + const existing = target[segment] + if (existing === undefined) { + const child: Record = {} + target[segment] = child + target = child + } else if ( + typeof existing === 'object' && + existing !== null && + !Array.isArray(existing) + ) { + target = existing + } else { + throw conflict(key) + } + } +} + +function conflict(key: string): PolymorfaValidationError { + return new PolymorfaValidationError( + `Field ${key} conflicts with another field of a different shape.`, + {code: 'invalid_field'}, + ) +} + +async function readFieldFile(path: string): Promise { + try { + const contents = await readFile(path) + if (contents.byteLength > MAX_FIELD_FILE_BYTES) { + throw new PolymorfaValidationError( + 'Field files must not exceed 10 MiB.', + {code: 'input_too_large'}, + ) + } + return contents.toString('utf8') + } catch (error) { + if (error instanceof PolymorfaValidationError) throw error + throw new PolymorfaValidationError(`Could not read field file: ${path}`, { + code: 'input_read_failed', + cause: error, + }) + } +} + +async function readStandardInput(): Promise { + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of process.stdin as AsyncIterable) { + total += chunk.byteLength + if (total > MAX_FIELD_FILE_BYTES) { + throw new PolymorfaValidationError( + 'Field input must not exceed 10 MiB.', + {code: 'input_too_large'}, + ) + } + chunks.push(Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString('utf8') +} diff --git a/src/api/jq.ts b/src/api/jq.ts new file mode 100644 index 00000000..9ae03de8 --- /dev/null +++ b/src/api/jq.ts @@ -0,0 +1,22 @@ +import {PolymorfaValidationError} from '@polymorfa/sdk' + +/** + * Run a jq filter with the embedded WebAssembly jq (no native binary). + * Strings print raw, like `gh api --jq`; other values print as JSON. + * The WebAssembly runtime has its own environment, so `$ENV` never exposes + * host credentials. + */ +export async function applyJq(value: unknown, filter: string): Promise { + const {raw} = await import('jq-wasm') + const result = await raw(value as Parameters[0], filter, ['-r']) + if (result.exitCode !== 0) { + const detail = result.stderr + .trim() + .replace(/^jq: error(?: \(at [^)]*\))?: /, '') + throw new PolymorfaValidationError( + `Invalid --jq filter: ${detail || `jq exited with ${result.exitCode}`}`, + {code: 'invalid_jq'}, + ) + } + return result.stdout +} diff --git a/src/api/paginate.ts b/src/api/paginate.ts new file mode 100644 index 00000000..81f6c048 --- /dev/null +++ b/src/api/paginate.ts @@ -0,0 +1,72 @@ +import { + PolymorfaValidationError, + type RawClient, + type RawRequest, +} from '@polymorfa/sdk' + +import {assertCursorAdvanced} from '../developer/support.js' +import type {RawApiResult} from './execute.js' +import {executeRawRequest} from './execute.js' + +export const DEFAULT_MAX_PAGES = 100 + +/** + * Follow Polymorfa cursor pagination: `page.nextCursor` (or a top-level + * `nextCursor`) is sent back as the `cursor` query parameter until it is + * null or `hasMore` is false. + */ +export async function paginateRawRequest( + client: RawClient, + request: RawRequest, + options: { + readonly maxPages?: number + readonly onPage?: (page: RawApiResult) => Promise | void + } = {}, +): Promise { + if (request.method !== 'GET') { + throw new PolymorfaValidationError('--paginate works only with GET.', { + code: 'paginate_method', + }) + } + if (request.query?.cursor !== undefined) { + throw new PolymorfaValidationError( + '--paginate manages the cursor field; remove it or omit --paginate.', + {code: 'paginate_cursor'}, + ) + } + const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES + const pages: RawApiResult[] = [] + let cursor: string | undefined + for (;;) { + const page = await executeRawRequest(client, { + ...request, + ...(cursor === undefined ? {} : {query: {...request.query, cursor}}), + }) + pages.push(page) + await options.onPage?.(page) + const next = nextCursor(page.data) + if (next === undefined) return pages + if (pages.length >= maxPages) { + throw new PolymorfaValidationError( + `Stopped after ${maxPages} pages; more remain. Raise --max-pages or narrow the request.`, + {code: 'paginate_limit'}, + ) + } + assertCursorAdvanced(cursor, next) + cursor = next + } +} + +function nextCursor(body: unknown): string | undefined { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return undefined + } + const record = body as Record + const page = + record.page !== null && typeof record.page === 'object' + ? (record.page as Record) + : record + if (page.hasMore === false) return undefined + const next = page.nextCursor + return typeof next === 'string' && next.length > 0 ? next : undefined +} diff --git a/src/api/request.ts b/src/api/request.ts index 526af472..b029017d 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -2,12 +2,15 @@ import {readFile, stat} from 'node:fs/promises' import {PolymorfaValidationError, type RawRequest} from '@polymorfa/sdk' +import {queryFromFields, type FieldValue} from './fields.js' + const MAX_INPUT_BYTES = 10 * 1024 * 1024 export interface BuildRawRequestInput { readonly body?: unknown readonly endpoint: string - readonly fields?: readonly string[] | undefined + /** Already-parsed `-f/-F` fields; see parseFields in fields.ts. */ + readonly fields?: Record | undefined readonly headers?: readonly string[] | undefined readonly method: RawRequest['method'] } @@ -20,7 +23,7 @@ export function buildRawRequest(input: BuildRawRequestInput): RawRequest { ) } - const fields = parseFields(input.fields ?? []) + const fields = input.fields const bodyMethod = input.method !== 'GET' && input.method !== 'HEAD' if (!bodyMethod && input.body !== undefined) { throw new PolymorfaValidationError( @@ -35,7 +38,9 @@ export function buildRawRequest(input: BuildRawRequestInput): RawRequest { ...(input.headers === undefined ? {} : {headers: parseHeaders(input.headers)}), - ...(!bodyMethod && fields !== undefined ? {query: fields} : {}), + ...(!bodyMethod && fields !== undefined + ? {query: queryFromFields(fields)} + : {}), ...(bodyMethod && fields !== undefined ? {body: fields} : {}), ...(input.body === undefined ? {} : {body: input.body}), } @@ -103,27 +108,6 @@ async function readBoundedInput( return Buffer.concat(chunks).toString('utf8') } -function parseFields( - fields: readonly string[], -): Readonly> | undefined { - if (fields.length === 0) return undefined - - const body: Record = {} - for (const field of fields) { - const separator = field.indexOf('=') - if (separator <= 0) { - throw new PolymorfaValidationError( - 'API fields must use the name=value form with a non-empty name.', - {code: 'invalid_field'}, - ) - } - - body[field.slice(0, separator)] = field.slice(separator + 1) - } - - return body -} - function parseHeaders( headers: readonly string[], ): Readonly> { diff --git a/src/api/template.ts b/src/api/template.ts new file mode 100644 index 00000000..9eaa4344 --- /dev/null +++ b/src/api/template.ts @@ -0,0 +1,246 @@ +import {PolymorfaValidationError} from '@polymorfa/sdk' + +/** + * A small Go-template subset for `polymorfa api --template`: + * + * {{.}} {{.data.id}} {{.items.0.name}} + * {{range .data}}...{{end}} (. is each element; {{else}} runs when empty) + * {{if .page.hasMore}}...{{else}}...{{end}} + * {{json .data}} {{len .data}} {{upper .name}} {{lower .name}} + * {{join ", " .tags}} {{"literal"}} + * {{/* comment *\/}} and {{- / -}} to trim adjacent whitespace + * + * `$` refers to the root value inside range blocks. + */ +export function renderTemplate(template: string, data: unknown): string { + const nodes = parse(tokenize(template)) + return renderNodes(nodes, data, data) +} + +type Token = + | {readonly kind: 'text'; readonly value: string} + | {readonly kind: 'action'; readonly value: string} + +type Node = + | {readonly kind: 'text'; readonly value: string} + | {readonly kind: 'expr'; readonly expr: string} + | { + readonly kind: 'if' | 'range' + readonly expr: string + readonly body: Node[] + readonly otherwise: Node[] + } + +function tokenize(template: string): Token[] { + const tokens: Token[] = [] + let index = 0 + while (index < template.length) { + const open = template.indexOf('{{', index) + if (open < 0) { + tokens.push({kind: 'text', value: template.slice(index)}) + break + } + let text = template.slice(index, open) + let start = open + 2 + if (template[start] === '-' && /\s/.test(template[start + 1] ?? '')) { + text = text.replace(/\s+$/, '') + start += 1 + } + if (text.length > 0) tokens.push({kind: 'text', value: text}) + const close = template.indexOf('}}', start) + if (close < 0) throw templateError('unclosed action "{{"') + let end = close + let trimRight = false + if (template[close - 1] === '-' && /\s/.test(template[close - 2] ?? '')) { + end = close - 1 + trimRight = true + } + tokens.push({kind: 'action', value: template.slice(start, end).trim()}) + index = close + 2 + if (trimRight) { + while (index < template.length && /\s/.test(template[index] as string)) + index++ + } + } + return tokens +} + +function parse(tokens: Token[]): Node[] { + let position = 0 + + function block(terminators: readonly string[]): { + nodes: Node[] + terminator: string | undefined + } { + const nodes: Node[] = [] + while (position < tokens.length) { + const token = tokens[position++] as Token + if (token.kind === 'text') { + nodes.push({kind: 'text', value: token.value}) + continue + } + const action = token.value + if (action.startsWith('/*') && action.endsWith('*/')) continue + const keyword = action.split(/\s+/, 1)[0] as string + if (terminators.includes(keyword)) return {nodes, terminator: keyword} + if (keyword === 'if' || keyword === 'range') { + const expr = action.slice(keyword.length).trim() + if (expr.length === 0) throw templateError(`${keyword} needs a value`) + const body = block(['else', 'end']) + let otherwise: Node[] = [] + if (body.terminator === 'else') { + const rest = block(['end']) + if (rest.terminator !== 'end') throw templateError('missing {{end}}') + otherwise = rest.nodes + } else if (body.terminator !== 'end') { + throw templateError('missing {{end}}') + } + nodes.push({kind: keyword, expr, body: body.nodes, otherwise}) + continue + } + if (keyword === 'else' || keyword === 'end') { + throw templateError(`unexpected {{${keyword}}}`) + } + nodes.push({kind: 'expr', expr: action}) + } + return {nodes, terminator: undefined} + } + + const result = block([]) + return result.nodes +} + +function renderNodes( + nodes: readonly Node[], + dot: unknown, + root: unknown, +): string { + let output = '' + for (const node of nodes) { + switch (node.kind) { + case 'text': + output += node.value + break + case 'expr': + output += stringify(evaluate(node.expr, dot, root)) + break + case 'if': + output += truthy(evaluate(node.expr, dot, root)) + ? renderNodes(node.body, dot, root) + : renderNodes(node.otherwise, dot, root) + break + case 'range': { + const value = evaluate(node.expr, dot, root) + const items = Array.isArray(value) + ? value + : value !== null && typeof value === 'object' + ? Object.values(value) + : value === undefined || value === null + ? [] + : (() => { + throw templateError(`cannot range over ${node.expr}`) + })() + output += + items.length === 0 + ? renderNodes(node.otherwise, dot, root) + : items.map((item) => renderNodes(node.body, item, root)).join('') + break + } + } + } + return output +} + +const FUNCTIONS: Readonly unknown>> = { + join: (separator, list) => { + if (!Array.isArray(list)) throw templateError('join needs a list') + return list.map(stringify).join(String(separator)) + }, + json: (value) => JSON.stringify(value), + len: (value) => { + if (typeof value === 'string' || Array.isArray(value)) return value.length + if (value !== null && typeof value === 'object') + return Object.keys(value).length + throw templateError('len needs a string, list, or object') + }, + lower: (value) => stringify(value).toLowerCase(), + upper: (value) => stringify(value).toUpperCase(), +} + +function evaluate(expression: string, dot: unknown, root: unknown): unknown { + const words = splitWords(expression) + if (words.length === 0) throw templateError('empty action') + const [head, ...rest] = words as [string, ...string[]] + const fn = FUNCTIONS[head] + if (fn !== undefined) { + return fn(...rest.map((word) => operand(word, dot, root))) + } + if (rest.length > 0) throw templateError(`unknown function ${head}`) + return operand(head, dot, root) +} + +function operand(word: string, dot: unknown, root: unknown): unknown { + if (word.startsWith('"')) { + try { + return JSON.parse(word) as unknown + } catch { + throw templateError(`invalid string ${word}`) + } + } + if (/^-?\d+(\.\d+)?$/.test(word)) return Number(word) + if (word === 'true' || word === 'false') return word === 'true' + let base: unknown + let path: string + if (word === '.' || word === '$') return word === '.' ? dot : root + if (word.startsWith('$.')) { + base = root + path = word.slice(2) + } else if (word.startsWith('.')) { + base = dot + path = word.slice(1) + } else { + throw templateError(`unknown value ${word}`) + } + return path + .split('.') + .reduce( + (value, key) => + value !== null && typeof value === 'object' + ? (value as Record)[key] + : undefined, + base, + ) +} + +function splitWords(expression: string): string[] { + const words: string[] = [] + const pattern = /"(?:[^"\\]|\\.)*"|\S+/g + for (const match of expression.matchAll(pattern)) words.push(match[0]) + return words +} + +function truthy(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0 + if (value !== null && typeof value === 'object') + return Object.keys(value).length > 0 + return Boolean(value) +} + +function stringify(value: unknown): string { + if (value === undefined || value === null) return '' + if (typeof value === 'string') return value + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return String(value) + } + return JSON.stringify(value) ?? '' +} + +function templateError(message: string): PolymorfaValidationError { + return new PolymorfaValidationError(`Invalid --template: ${message}.`, { + code: 'invalid_template', + }) +} diff --git a/src/commands/api.ts b/src/commands/api.ts index 9c7f264f..5a758f42 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1,8 +1,12 @@ import {Args, Flags} from '@oclif/core' import {PolymorfaValidationError, type HttpMethod} from '@polymorfa/sdk' -import {executeRawRequest} from '../api/execute.js' +import {executeRawRequest, type RawApiResult} from '../api/execute.js' +import {parseFields, type FieldInput} from '../api/fields.js' +import {applyJq} from '../api/jq.js' +import {DEFAULT_MAX_PAGES, paginateRawRequest} from '../api/paginate.js' import {buildRawRequest, readJsonInput} from '../api/request.js' +import {renderTemplate} from '../api/template.js' import {BaseCommand} from '../base-command.js' import {resolveCommandEnvironment} from '../context/environment.js' import {createRawClient} from '../sdk/client.js' @@ -28,42 +32,103 @@ export default class Api extends BaseCommand { static override description = 'Make an authenticated request through the Polymorfa SDK.' + static override examples = [ + '<%= config.bin %> api /platform/projects --surface platform', + '<%= config.bin %> api /v1/campaigns --surface platform -X POST -f name=Launch -F dryRun=true -F audience[]=vip', + "<%= config.bin %> api /platform/customers --surface platform --paginate --jq '.data[].id'", + "<%= config.bin %> api /platform/customers --surface platform --paginate --slurp --jq 'map(.data | length) | add'", + `<%= config.bin %> api /platform/projects --surface platform --template '{{range .data}}{{.slug}}{{"\\t"}}{{.name}}{{"\\n"}}{{end}}'`, + ] + static override flags = { field: Flags.string({ - description: 'Add a string field using name=value.', + char: 'F', + description: + 'Add a typed field using name=value: true, false, null and integers become JSON; @file or @- reads a file or stdin. Use name[sub]=v to nest and name[]=v to append.', multiple: true, }), header: Flags.string({ + char: 'H', description: 'Add a request header using name:value.', multiple: true, }), include: Flags.boolean({ + char: 'i', description: 'Include response metadata in human output.', + exclusive: ['jq', 'template'], }), input: Flags.string({ description: 'Read a complete JSON body from a file or standard input (-).', }), + jq: Flags.string({ + char: 'q', + description: + 'Filter the response with a jq expression (embedded jq 1.8). Strings print without quotes.', + exclusive: ['json', 'template'], + }), + 'max-pages': Flags.integer({ + dependsOn: ['paginate'], + description: `Fail instead of fetching more than this many pages. Defaults to ${DEFAULT_MAX_PAGES}.`, + min: 1, + }), method: Flags.option({ + char: 'X', default: 'GET', description: 'HTTP method.', options: HTTP_METHODS, })(), + paginate: Flags.boolean({ + description: + 'Fetch every page by following page.nextCursor as the cursor query parameter (GET only).', + }), + 'raw-field': Flags.string({ + char: 'f', + description: 'Add a string field using name=value.', + multiple: true, + }), + slurp: Flags.boolean({ + dependsOn: ['paginate'], + description: + 'With --paginate, combine all pages into one JSON array before --jq or --template.', + }), surface: Flags.option({ description: 'API credential surface.', options: ['messaging', 'platform'] as const, required: true, })(), + template: Flags.string({ + char: 't', + description: + 'Format the response with a Go-style template, such as {{range .data}}{{.id}}{{"\\n"}}{{end}}.', + exclusive: ['json', 'jq'], + }), } async run(): Promise { const {args, flags} = await this.parse(Api) - if (flags.input !== undefined && flags.field !== undefined) { + const fieldInputs: FieldInput[] = [ + ...(flags['raw-field'] ?? []).map((value) => ({ + kind: 'raw' as const, + value, + })), + ...(flags.field ?? []).map((value) => ({kind: 'typed' as const, value})), + ] + if (flags.input !== undefined && fieldInputs.length > 0) { throw new PolymorfaValidationError( 'Use either --input or --field to provide a request body, not both.', {code: 'conflicting_body_input'}, ) } + if ( + flags.input === '-' && + (flags.field ?? []).some((field) => field.includes('=@-')) + ) { + throw new PolymorfaValidationError( + 'Standard input can be read only once.', + {code: 'conflicting_body_input'}, + ) + } const environment = await resolveCommandEnvironment({ env: process.env, @@ -77,20 +142,81 @@ export default class Api extends BaseCommand { this.registerSecret(environment.credential.value) const body = flags.input === undefined ? undefined : await readJsonInput(flags.input) + const fields = await parseFields(fieldInputs) const request = buildRawRequest({ endpoint: args.endpoint, method: flags.method, - ...(flags.field === undefined ? {} : {fields: flags.field}), + ...(fields === undefined ? {} : {fields}), ...(flags.header === undefined ? {} : {headers: flags.header}), ...(body === undefined ? {} : {body}), }) - const result = await executeRawRequest( - createRawClient(environment), - request, + const client = createRawClient(environment) + const format = this.formatter(flags) + const mode = this.outputMode(flags) + + if (!flags.paginate) { + const result = await executeRawRequest(client, request) + if (format !== undefined) { + this.writeText(await format(result.data), mode) + return + } + this.writeValue( + result, + formatHuman(flags.include ? result : result.data), + flags, + ) + return + } + + const streaming = !flags.slurp && mode === 'human' + const pages = await paginateRawRequest(client, request, { + maxPages: flags['max-pages'] ?? DEFAULT_MAX_PAGES, + ...(streaming + ? { + onPage: async (page: RawApiResult) => { + this.writeText( + format === undefined + ? formatHuman(flags.include ? page : page.data) + : await format(page.data), + mode, + ) + }, + } + : {}), + }) + if (streaming) return + + const bodies = pages.map((page) => page.data) + if (format !== undefined) { + this.writeText(await format(bodies), mode) + return + } + const last = pages[pages.length - 1] as RawApiResult + this.writeValue( + {data: bodies, metadata: {...last.metadata, pages: pages.length}}, + formatHuman(bodies), + flags, ) - const humanValue = flags.include ? result : result.data + } + + private formatter(flags: { + readonly jq?: string | undefined + readonly template?: string | undefined + }): ((value: unknown) => Promise) | undefined { + if (flags.jq !== undefined) { + const filter = flags.jq + return (value) => applyJq(value, filter) + } + if (flags.template !== undefined) { + const template = flags.template + return (value) => Promise.resolve(renderTemplate(template, value)) + } + return undefined + } - this.writeValue(result, formatHuman(humanValue), flags) + private writeText(text: string, mode: 'human' | 'json' | 'quiet'): void { + if (mode === 'quiet' || text.length === 0) return + process.stdout.write(text.endsWith('\n') ? text : `${text}\n`) } } diff --git a/test/api/fields.test.ts b/test/api/fields.test.ts new file mode 100644 index 00000000..c4002c55 --- /dev/null +++ b/test/api/fields.test.ts @@ -0,0 +1,88 @@ +import {describe, expect, test} from 'vitest' + +import {parseFields, queryFromFields} from '../../src/api/fields.js' + +describe('api fields', () => { + test('keeps raw fields as strings and types -F values', async () => { + await expect( + parseFields([ + {kind: 'raw', value: 'count=10'}, + {kind: 'typed', value: 'limit=10'}, + {kind: 'typed', value: 'enabled=true'}, + {kind: 'typed', value: 'off=false'}, + {kind: 'typed', value: 'gone=null'}, + {kind: 'typed', value: 'ratio=1.5'}, + {kind: 'typed', value: 'big=99999999999999999999'}, + {kind: 'typed', value: 'name=Launch=Day'}, + ]), + ).resolves.toEqual({ + big: '99999999999999999999', + count: '10', + enabled: true, + gone: null, + limit: 10, + name: 'Launch=Day', + off: false, + ratio: '1.5', + }) + }) + + test('nests objects and appends arrays', async () => { + await expect( + parseFields([ + {kind: 'typed', value: 'audience[tags][]=vip'}, + {kind: 'typed', value: 'audience[tags][]=beta'}, + {kind: 'typed', value: 'audience[size]=3'}, + {kind: 'raw', value: 'ids[]=1'}, + ]), + ).resolves.toEqual({ + audience: {size: 3, tags: ['vip', 'beta']}, + ids: ['1'], + }) + }) + + test('reads @file and @- values for typed fields only', async () => { + const readers = { + readFile: (path: string) => Promise.resolve(`file:${path}`), + readStdin: () => Promise.resolve('stdin'), + } + await expect( + parseFields( + [ + {kind: 'typed', value: 'body=@message.txt'}, + {kind: 'typed', value: 'note=@-'}, + {kind: 'raw', value: 'literal=@kept'}, + ], + readers, + ), + ).resolves.toEqual({ + body: 'file:message.txt', + literal: '@kept', + note: 'stdin', + }) + }) + + test('rejects malformed names and shape conflicts', async () => { + await expect(parseFields([{kind: 'raw', value: '=x'}])).rejects.toThrow( + 'name=value', + ) + await expect(parseFields([{kind: 'raw', value: 'a[]b=x'}])).rejects.toThrow( + 'Invalid field name', + ) + await expect( + parseFields([ + {kind: 'raw', value: 'a=x'}, + {kind: 'raw', value: 'a[b]=y'}, + ]), + ).rejects.toThrow('conflicts') + }) + + test('query parameters accept scalars and arrays but not objects', () => { + expect(queryFromFields({ids: [1, 'b'], q: 'x', flag: true})).toEqual({ + flag: true, + ids: [1, 'b'], + q: 'x', + }) + expect(() => queryFromFields({filter: {a: 1}})).toThrow('nested') + }) +}) diff --git a/test/api/template.test.ts b/test/api/template.test.ts new file mode 100644 index 00000000..7999788f --- /dev/null +++ b/test/api/template.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, test} from 'vitest' + +import {renderTemplate} from '../../src/api/template.js' + +describe('api template', () => { + const data = { + data: [ + {id: 'a', name: 'Alpha', tags: ['x', 'y']}, + {id: 'b', name: 'Beta', tags: []}, + ], + page: {hasMore: false, nextCursor: null}, + title: 'Projects', + } + + test('renders paths, ranges, conditionals, and functions', () => { + expect( + renderTemplate( + '{{upper .title}} ({{len .data}}){{"\\n"}}{{range .data}}{{.id}}={{.name}} [{{join "," .tags}}]{{if .tags}} tagged{{else}} untagged{{end}} in {{$.title}}{{"\\n"}}{{end}}', + data, + ), + ).toBe( + 'PROJECTS (2)\na=Alpha [x,y] tagged in Projects\nb=Beta [] untagged in Projects\n', + ) + }) + + test('supports else on empty ranges, json, comments, and trimming', () => { + expect( + renderTemplate( + '{{/* note */}}{{range .missing}}x{{else}}none{{end}} {{- json .page -}} !', + data, + ), + ).toBe('none{"hasMore":false,"nextCursor":null}!') + }) + + test('prints missing values as empty strings and objects as JSON', () => { + expect(renderTemplate('[{{.nope.deeper}}] {{.page}}', data)).toBe( + '[] {"hasMore":false,"nextCursor":null}', + ) + }) + + test('rejects malformed templates', () => { + expect(() => renderTemplate('{{range .data}}', data)).toThrow( + 'missing {{end}}', + ) + expect(() => renderTemplate('{{end}}', data)).toThrow('unexpected') + expect(() => renderTemplate('{{.data', data)).toThrow('unclosed') + expect(() => renderTemplate('{{bogus .data}}', data)).toThrow( + 'unknown function', + ) + }) +}) diff --git a/test/commands/api-gh.test.ts b/test/commands/api-gh.test.ts new file mode 100644 index 00000000..82883aef --- /dev/null +++ b/test/commands/api-gh.test.ts @@ -0,0 +1,167 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +describe('api gh-style options', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + const pages = [ + { + body: { + data: [{id: 'c1'}, {id: 'c2'}], + page: {hasMore: true, nextCursor: 'n1'}, + }, + }, + {body: {data: [{id: 'c3'}], page: {hasMore: false, nextCursor: null}}}, + ] + + test('sends typed -F fields as JSON and -f as strings', async () => { + server = await startHttpServer({body: {data: {ok: true}}}) + + const result = await run( + 'api /v1/things --surface platform -X POST -f label=10 -F count=10 -F live=true -F tags[]=a -F tags[]=b -F meta[owner]=me --json', + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + count: 10, + label: '10', + live: true, + meta: {owner: 'me'}, + tags: ['a', 'b'], + }) + }) + + test('turns GET fields into query parameters', async () => { + server = await startHttpServer({body: {data: []}}) + + const result = await run( + 'api /platform/customers --surface platform -F limit=5 -f q=ada --json', + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(server.requests[0]?.url).toBe('/platform/customers?q=ada&limit=5') + }) + + test('follows cursors with --paginate and prints one JSON document', async () => { + server = await startHttpSequenceServer(pages) + + const result = await run( + 'api /platform/customers --surface platform --paginate -F limit=2 --json', + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(server.requests.map((request) => request.url)).toEqual([ + '/platform/customers?limit=2', + '/platform/customers?limit=2&cursor=n1', + ]) + expect(JSON.parse(result.stdout)).toMatchObject({ + data: [pages[0]?.body, pages[1]?.body], + metadata: {pages: 2, status: 200}, + }) + }) + + test('applies --jq to each page when paginating', async () => { + server = await startHttpSequenceServer(pages) + + const result = await run( + 'api /platform/customers --surface platform --paginate --jq .data[].id', + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toBe('c1\nc2\nc3\n') + }) + + test('applies --jq once to all pages with --slurp', async () => { + server = await startHttpSequenceServer(pages) + + const result = await run( + 'api /platform/customers --surface platform --paginate --slurp --jq "map(.data | length) | add"', + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toBe('3\n') + }) + + test('renders --template against the response', async () => { + server = await startHttpServer(pages[1] as {body: unknown}) + + const result = await run( + `api /platform/customers --surface platform --template "{{range .data}}id={{.id}}{{end}}"`, + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toBe('id=c3\n') + }) + + test('reports jq errors as validation failures', async () => { + server = await startHttpServer({body: {data: []}}) + + const result = await run( + 'api /platform/customers --surface platform --jq ".data | bogus("', + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('Invalid --jq filter') + }) + + test('rejects --slurp without --paginate and --paginate for writes', async () => { + server = await startHttpServer({body: {data: []}}) + + const slurp = await run( + 'api /platform/customers --surface platform --slurp', + ) + const write = await run( + 'api /platform/customers --surface platform -X POST --paginate --json', + ) + + expect(exitCode(slurp.error)).toBe(2) + expect(exitCode(write.error)).toBe(2) + expect(write.stderr).toContain('only with GET') + expect(server.requests).toHaveLength(0) + }) + + test('stops at --max-pages instead of looping', async () => { + server = await startHttpServer({ + body: {data: [], page: {hasMore: true, nextCursor: 'again'}}, + }) + + const result = await run( + 'api /platform/customers --surface platform --paginate --max-pages 1 --json', + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('Stopped after 1 pages') + expect(server.requests).toHaveLength(1) + }) + + async function run(command: string) { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => runCommand(command), + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} From a352980f4d2a8cf31bea194e648433d7eec6dd1f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 20:46:10 +0200 Subject: [PATCH 2/5] fix(cli): harden api field parsing and pagination Reject prototype keys and object overwrites in fields, stop on any repeated cursor, and drop a dead stdin guard. --- src/api/fields.ts | 24 ++++++++++++++++++++++-- src/api/paginate.ts | 8 ++++++++ src/commands/api.ts | 10 ---------- test/api/fields.test.ts | 13 +++++++++++++ 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/api/fields.ts b/src/api/fields.ts index 83341b84..46b74e75 100644 --- a/src/api/fields.ts +++ b/src/api/fields.ts @@ -30,7 +30,7 @@ export async function parseFields( readers: FieldReaders = {}, ): Promise | undefined> { if (fields.length === 0) return undefined - const root: Record = {} + const root = Object.create(null) as Record let stdinUsed = false for (const field of fields) { const separator = field.value.indexOf('=') @@ -113,6 +113,8 @@ function typedValue(raw: string): FieldValue { type KeySegment = string | typeof APPEND const APPEND = Symbol('append') +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + function parseKey(key: string): KeySegment[] { const match = /^([^[\]]+)((?:\[[^[\]]*\])*)$/.exec(key) if (match === null) { @@ -125,6 +127,16 @@ function parseKey(key: string): KeySegment[] { for (const [, part] of (match[2] ?? '').matchAll(/\[([^[\]]*)\]/g)) { segments.push(part === '' ? APPEND : (part as string)) } + if ( + segments.some( + (segment) => typeof segment === 'string' && FORBIDDEN_KEYS.has(segment), + ) + ) { + throw new PolymorfaValidationError( + `Invalid field name ${key}. __proto__, constructor, and prototype are not allowed.`, + {code: 'invalid_field'}, + ) + } for (let index = 0; index < segments.length - 1; index++) { if (segments[index] === APPEND) { throw new PolymorfaValidationError( @@ -149,6 +161,14 @@ function assign( const next = segments[index + 1] if (segment === APPEND) return // handled by the previous segment if (last) { + const existing = target[segment] + if ( + existing !== undefined && + existing !== null && + typeof existing === 'object' + ) { + throw conflict(key) + } target[segment] = value return } @@ -161,7 +181,7 @@ function assign( } const existing = target[segment] if (existing === undefined) { - const child: Record = {} + const child = Object.create(null) as Record target[segment] = child target = child } else if ( diff --git a/src/api/paginate.ts b/src/api/paginate.ts index 81f6c048..bd13d525 100644 --- a/src/api/paginate.ts +++ b/src/api/paginate.ts @@ -37,6 +37,7 @@ export async function paginateRawRequest( const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES const pages: RawApiResult[] = [] let cursor: string | undefined + const seen = new Set() for (;;) { const page = await executeRawRequest(client, { ...request, @@ -53,6 +54,13 @@ export async function paginateRawRequest( ) } assertCursorAdvanced(cursor, next) + if (seen.has(next)) { + throw new PolymorfaValidationError( + 'The API returned a cursor it had already returned; stopping to avoid a loop.', + {code: 'pagination_cursor_repeated'}, + ) + } + seen.add(next) cursor = next } } diff --git a/src/commands/api.ts b/src/commands/api.ts index 5a758f42..7c648e49 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -120,16 +120,6 @@ export default class Api extends BaseCommand { {code: 'conflicting_body_input'}, ) } - if ( - flags.input === '-' && - (flags.field ?? []).some((field) => field.includes('=@-')) - ) { - throw new PolymorfaValidationError( - 'Standard input can be read only once.', - {code: 'conflicting_body_input'}, - ) - } - const environment = await resolveCommandEnvironment({ env: process.env, surface: flags.surface, diff --git a/test/api/fields.test.ts b/test/api/fields.test.ts index c4002c55..8be83be9 100644 --- a/test/api/fields.test.ts +++ b/test/api/fields.test.ts @@ -77,6 +77,19 @@ describe('api fields', () => { ).rejects.toThrow('conflicts') }) + test('rejects prototype keys and overwriting nested values', async () => { + await expect( + parseFields([{kind: 'raw', value: 'a[__proto__][polluted]=1'}]), + ).rejects.toThrow('not allowed') + expect(({} as Record).polluted).toBeUndefined() + await expect( + parseFields([ + {kind: 'raw', value: 'a[b]=x'}, + {kind: 'raw', value: 'a=y'}, + ]), + ).rejects.toThrow('conflicts') + }) + test('query parameters accept scalars and arrays but not objects', () => { expect(queryFromFields({ids: [1, 'b'], q: 'x', flag: true})).toEqual({ flag: true, From b1c7f30b098fbed12268b081b8176857ccead1a8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 09:04:39 +0200 Subject: [PATCH 3/5] fix(cli): correct --jq string bodies, cursor fallback and template lookups - jq-wasm parses a string argument as JSON text, so a JSON string body failed with a parse error blamed on the user's filter. Serialize first. - nextCursor never fell back to the top level once a page envelope was present, silently stopping after one page. - Template function lookup resolved inherited names such as toString and constructor; restrict it to own properties. - {{else if .cond}} dropped the condition and rendered unconditionally; reject it with a clear message instead. --- src/api/jq.ts | 5 ++++- src/api/paginate.ts | 18 ++++++++++++------ src/api/template.ts | 15 +++++++++++++-- test/api/template.test.ts | 14 ++++++++++++++ test/commands/api-gh.test.ts | 24 ++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/api/jq.ts b/src/api/jq.ts index 9ae03de8..e0c11a59 100644 --- a/src/api/jq.ts +++ b/src/api/jq.ts @@ -8,7 +8,10 @@ import {PolymorfaValidationError} from '@polymorfa/sdk' */ export async function applyJq(value: unknown, filter: string): Promise { const {raw} = await import('jq-wasm') - const result = await raw(value as Parameters[0], filter, ['-r']) + // jq-wasm parses a string argument as JSON *text*, so a decoded JSON string + // body would be re-parsed and fail. Serialize first so every value arrives + // as the JSON it stands for. + const result = await raw(JSON.stringify(value) ?? 'null', filter, ['-r']) if (result.exitCode !== 0) { const detail = result.stderr .trim() diff --git a/src/api/paginate.ts b/src/api/paginate.ts index bd13d525..9af7e76d 100644 --- a/src/api/paginate.ts +++ b/src/api/paginate.ts @@ -70,11 +70,17 @@ function nextCursor(body: unknown): string | undefined { return undefined } const record = body as Record - const page = - record.page !== null && typeof record.page === 'object' + const nested = + record.page !== null && + typeof record.page === 'object' && + !Array.isArray(record.page) ? (record.page as Record) - : record - if (page.hasMore === false) return undefined - const next = page.nextCursor - return typeof next === 'string' && next.length > 0 ? next : undefined + : undefined + // Prefer the page envelope, then fall back to the top level. + for (const scope of nested === undefined ? [record] : [nested, record]) { + if (scope.hasMore === false) return undefined + const next = scope.nextCursor + if (typeof next === 'string' && next.length > 0) return next + } + return undefined } diff --git a/src/api/template.ts b/src/api/template.ts index 9eaa4344..8cfb42d1 100644 --- a/src/api/template.ts +++ b/src/api/template.ts @@ -82,7 +82,16 @@ function parse(tokens: Token[]): Node[] { const action = token.value if (action.startsWith('/*') && action.endsWith('*/')) continue const keyword = action.split(/\s+/, 1)[0] as string - if (terminators.includes(keyword)) return {nodes, terminator: keyword} + if (terminators.includes(keyword)) { + // {{else if .cond}} chains are not supported; without this the + // condition is dropped and the else branch renders unconditionally. + if (action.length > keyword.length) { + throw templateError( + `{{${keyword}}} takes no arguments; nest {{if}}…{{end}} instead of "${action}"`, + ) + } + return {nodes, terminator: keyword} + } if (keyword === 'if' || keyword === 'range') { const expr = action.slice(keyword.length).trim() if (expr.length === 0) throw templateError(`${keyword} needs a value`) @@ -171,7 +180,9 @@ function evaluate(expression: string, dot: unknown, root: unknown): unknown { const words = splitWords(expression) if (words.length === 0) throw templateError('empty action') const [head, ...rest] = words as [string, ...string[]] - const fn = FUNCTIONS[head] + // Own properties only, so inherited names such as toString or constructor + // fall through to the unknown-function path instead of being called. + const fn = Object.hasOwn(FUNCTIONS, head) ? FUNCTIONS[head] : undefined if (fn !== undefined) { return fn(...rest.map((word) => operand(word, dot, root))) } diff --git a/test/api/template.test.ts b/test/api/template.test.ts index 7999788f..5356f32c 100644 --- a/test/api/template.test.ts +++ b/test/api/template.test.ts @@ -48,4 +48,18 @@ describe('api template', () => { 'unknown function', ) }) + + test('does not resolve inherited object properties as functions', () => { + for (const name of ['toString', 'constructor', 'hasOwnProperty']) { + expect(() => renderTemplate(`{{${name} .title}}`, data)).toThrow( + 'unknown function', + ) + } + }) + + test('rejects {{else if}} instead of dropping the condition', () => { + expect(() => + renderTemplate('{{if .missing}}a{{else if .title}}b{{end}}', data), + ).toThrow('takes no arguments') + }) }) diff --git a/test/commands/api-gh.test.ts b/test/commands/api-gh.test.ts index 82883aef..915b3e7c 100644 --- a/test/commands/api-gh.test.ts +++ b/test/commands/api-gh.test.ts @@ -75,6 +75,30 @@ describe('api gh-style options', () => { }) }) + test('follows a top-level nextCursor alongside a page envelope', async () => { + server = await startHttpSequenceServer([ + {body: {data: [{id: 'c1'}], nextCursor: 'n1', page: {size: 1}}}, + {body: {data: [{id: 'c2'}], nextCursor: null, page: {size: 1}}}, + ]) + + const result = await run( + 'api /platform/customers --surface platform --paginate --jq .data[].id', + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(server.requests).toHaveLength(2) + expect(result.stdout).toBe('c1\nc2\n') + }) + + test('applies --jq to a JSON string body', async () => { + server = await startHttpServer({body: 'plain text'}) + + const result = await run('api /v1/thing --surface platform --jq .') + + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toBe('plain text\n') + }) + test('applies --jq to each page when paginating', async () => { server = await startHttpSequenceServer(pages) From fedb3fe57719d709fbc2bbb473cfbbf118929664 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:20:05 +0200 Subject: [PATCH 4/5] fix(cli): keep --quiet from changing --jq semantics and bound field file reads --quiet disabled per-page streaming, so a paginated --jq filter ran once over an array of page bodies instead of each body, changing the exit code for filters like .data[].id. Only --json aggregates now. Also stat field files before reading, so the 10 MiB limit rejects rather than buffers. --- src/api/fields.ts | 11 ++++++++++- src/commands/api.ts | 4 +++- test/commands/api-gh.test.ts | 11 +++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/api/fields.ts b/src/api/fields.ts index 46b74e75..678bf98b 100644 --- a/src/api/fields.ts +++ b/src/api/fields.ts @@ -1,4 +1,4 @@ -import {readFile} from 'node:fs/promises' +import {readFile, stat} from 'node:fs/promises' import {PolymorfaValidationError} from '@polymorfa/sdk' @@ -205,6 +205,15 @@ function conflict(key: string): PolymorfaValidationError { async function readFieldFile(path: string): Promise { try { + // Check the size before reading so an oversized file is rejected instead + // of being buffered in full. The post-read check still covers a race. + const {size} = await stat(path) + if (size > MAX_FIELD_FILE_BYTES) { + throw new PolymorfaValidationError( + 'Field files must not exceed 10 MiB.', + {code: 'input_too_large'}, + ) + } const contents = await readFile(path) if (contents.byteLength > MAX_FIELD_FILE_BYTES) { throw new PolymorfaValidationError( diff --git a/src/commands/api.ts b/src/commands/api.ts index 7c648e49..c6681efe 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -158,7 +158,9 @@ export default class Api extends BaseCommand { return } - const streaming = !flags.slurp && mode === 'human' + // --quiet only suppresses output; it must not change how the filter sees + // the pages. Only --json aggregates, because it prints one document. + const streaming = !flags.slurp && mode !== 'json' const pages = await paginateRawRequest(client, request, { maxPages: flags['max-pages'] ?? DEFAULT_MAX_PAGES, ...(streaming diff --git a/test/commands/api-gh.test.ts b/test/commands/api-gh.test.ts index 915b3e7c..8e484f27 100644 --- a/test/commands/api-gh.test.ts +++ b/test/commands/api-gh.test.ts @@ -110,6 +110,17 @@ describe('api gh-style options', () => { expect(result.stdout).toBe('c1\nc2\nc3\n') }) + test('--quiet suppresses output without changing how --jq sees pages', async () => { + server = await startHttpSequenceServer(pages) + + const result = await run( + 'api /platform/customers --surface platform --paginate --jq .data[].id --quiet', + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toBe('') + }) + test('applies --jq once to all pages with --slurp', async () => { server = await startHttpSequenceServer(pages) From f35f6903fbc39f88413abdaba09398f6a2d2ed56 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:24:14 +0200 Subject: [PATCH 5/5] fix(cli): guard endpoint cursors and silent pagination truncation --paginate only rejected a cursor passed via query fields, so a cursor already in the endpoint path produced a duplicate parameter. A response claiming more pages with no cursor also ended pagination successfully, hiding an incomplete result; it now fails with a clear message. Name the field flags accurately in the conflicting-body error. --- src/api/paginate.ts | 17 ++++++++++++++++- src/commands/api.ts | 2 +- test/commands/api-gh.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/api/paginate.ts b/src/api/paginate.ts index 9af7e76d..e3914e11 100644 --- a/src/api/paginate.ts +++ b/src/api/paginate.ts @@ -28,7 +28,13 @@ export async function paginateRawRequest( code: 'paginate_method', }) } - if (request.query?.cursor !== undefined) { + const pathQuery = request.path.includes('?') + ? new URLSearchParams(request.path.slice(request.path.indexOf('?') + 1)) + : undefined + if ( + request.query?.cursor !== undefined || + pathQuery?.has('cursor') === true + ) { throw new PolymorfaValidationError( '--paginate manages the cursor field; remove it or omit --paginate.', {code: 'paginate_cursor'}, @@ -77,10 +83,19 @@ function nextCursor(body: unknown): string | undefined { ? (record.page as Record) : undefined // Prefer the page envelope, then fall back to the top level. + let more = false for (const scope of nested === undefined ? [record] : [nested, record]) { if (scope.hasMore === false) return undefined const next = scope.nextCursor if (typeof next === 'string' && next.length > 0) return next + if (scope.hasMore === true) more = true + } + // Claiming more pages without a cursor would truncate the result silently. + if (more) { + throw new PolymorfaValidationError( + 'The API reported more pages but returned no cursor; the result would be incomplete.', + {code: 'pagination_cursor_missing'}, + ) } return undefined } diff --git a/src/commands/api.ts b/src/commands/api.ts index c6681efe..c5763f03 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -116,7 +116,7 @@ export default class Api extends BaseCommand { ] if (flags.input !== undefined && fieldInputs.length > 0) { throw new PolymorfaValidationError( - 'Use either --input or --field to provide a request body, not both.', + 'Use either --input or the field flags (-f/--raw-field, -F/--field) to provide a request body, not both.', {code: 'conflicting_body_input'}, ) } diff --git a/test/commands/api-gh.test.ts b/test/commands/api-gh.test.ts index 8e484f27..639168d4 100644 --- a/test/commands/api-gh.test.ts +++ b/test/commands/api-gh.test.ts @@ -121,6 +121,29 @@ describe('api gh-style options', () => { expect(result.stdout).toBe('') }) + test('rejects a cursor already present in the endpoint path', async () => { + server = await startHttpServer({body: {data: []}}) + + const result = await run( + 'api "/platform/customers?cursor=abc" --surface platform --paginate', + ) + + expect(result.stderr).toContain('--paginate manages the cursor field') + expect(server.requests).toHaveLength(0) + }) + + test('fails loudly when the API claims more pages but sends no cursor', async () => { + server = await startHttpServer({ + body: {data: [{id: 'c1'}], page: {hasMore: true, nextCursor: null}}, + }) + + const result = await run( + 'api /platform/customers --surface platform --paginate --json', + ) + + expect(result.stderr).toContain('returned no cursor') + }) + test('applies --jq once to all pages with --slurp', async () => { server = await startHttpSequenceServer(pages)