From 2fd69de502413d376e5d7817b972f8f0fefb95fa Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 12:10:59 -0300 Subject: [PATCH 01/15] chore: actualizar el SDK de Fintoc a 1.30.1 --- package-lock.json | 11 ++++++----- package.json | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb8e38d..1f1e4d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@inquirer/prompts": "^8.4.2", "commander": "^14.0.3", "es-toolkit": "^1.46.1", - "fintoc": "^1.21.0", + "fintoc": "^1.30.1", "smol-toml": "^1.6.1", "ws": "^8.20.0", "zod": "^4.4.3" @@ -4461,13 +4461,14 @@ } }, "node_modules/fintoc": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/fintoc/-/fintoc-1.21.0.tgz", - "integrity": "sha512-6KIjvsbTbB7u3TX5L26dbgYVxfcRjlBVec9evrUB62jLZVnaaTm88+lspM1+kAK1CGnijN8XLKUp/VUx2kKDAQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/fintoc/-/fintoc-1.30.1.tgz", + "integrity": "sha512-e0E/9Es6UEyH1A4lmryTXx61L9zEg/j/YWRNYYVn5D4llgRpsFHUZVa5QEzS+fZTjmGkJtwH8D7yIph5IEDGXA==", "license": "BSD-3-Clause", "dependencies": { "@types/node": "^22.15.18", - "axios": "^1.10.0" + "axios": "^1.10.0", + "form-data": "^4.0.0" }, "engines": { "node": ">=10" diff --git a/package.json b/package.json index 79ee15c..1730168 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@inquirer/prompts": "^8.4.2", "commander": "^14.0.3", "es-toolkit": "^1.46.1", - "fintoc": "^1.21.0", + "fintoc": "^1.30.1", "smol-toml": "^1.6.1", "ws": "^8.20.0", "zod": "^4.4.3" From 588a5da5f71a250382dcec0f9903daa7b41eb213 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 12:11:26 -0300 Subject: [PATCH 02/15] feat: centralizar errores de uso y sugerencias de comandos --- src/lib/__tests__/commands.test.ts | 23 ++++++++++++++ src/lib/__tests__/errors.test.ts | 26 ++++++++++++++-- src/lib/commands.ts | 26 +++++++++++++++- src/lib/constants.ts | 16 ++++++++++ src/lib/errors.ts | 49 ++++++++++++++++++++++++++++-- src/lib/json-body.ts | 26 ++++++++++------ 6 files changed, 151 insertions(+), 15 deletions(-) diff --git a/src/lib/__tests__/commands.test.ts b/src/lib/__tests__/commands.test.ts index 75c1b9e..a926215 100644 --- a/src/lib/__tests__/commands.test.ts +++ b/src/lib/__tests__/commands.test.ts @@ -56,6 +56,29 @@ describe('addDefaultAction', () => { }) }) + describe('when the unknown subcommand looks like a known one', () => { + test('suggests the closest command', () => { + const cmd = new Command().name('test') + cmd.command('list').action(() => {}) + cmd + .command('submit_for_review') + .alias('submit-for-review') + .action(() => {}) + addDefaultAction(cmd) + const errorSpy = vi.spyOn(cmd, 'error').mockImplementation((msg) => { + throw new CommanderError(1, 'commander.unknownCommand', msg) + }) + + parseAndCatch(cmd, ['lsit']) + parseAndCatch(cmd, ['submit-for-reveiw']) + + expect(errorSpy.mock.calls.map(([msg]) => msg)).toEqual([ + "unknown command 'lsit'\n(Did you mean list?)", + "unknown command 'submit-for-reveiw'\n(Did you mean submit-for-review?)", + ]) + }) + }) + describe('when called with a valid subcommand', () => { test('executes the subcommand action', () => { const cmd = new Command().name('test') diff --git a/src/lib/__tests__/errors.test.ts b/src/lib/__tests__/errors.test.ts index e479225..1144266 100644 --- a/src/lib/__tests__/errors.test.ts +++ b/src/lib/__tests__/errors.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { handleError } from '../errors.js' +import { handleError, UsageError } from '../errors.js' import { error, hint } from '../output.js' vi.mock('../output.js', () => ({ @@ -89,7 +89,7 @@ describe('handleError', () => { expect(() => handleError(err)).toThrow('process.exit') - expect(error).toHaveBeenCalledWith('JWS private key required for transfer operations') + expect(error).toHaveBeenCalledWith('JWS private key required to sign this request') expect(hint).toHaveBeenCalledWith(' More info: https://docs.fintoc.com/docs/transfers') }) }) @@ -309,7 +309,7 @@ describe('handleError', () => { expect(() => handleError(err)).toThrow('process.exit') - expect(error).toHaveBeenCalledWith('JWS private key required for transfer operations') + expect(error).toHaveBeenCalledWith('JWS private key required to sign this request') }) }) @@ -393,11 +393,31 @@ describe('handleError', () => { error: { type: 'invalid_request_error', code: 'missing_resource', + param: 'id', message: 'No such payment_intent: pi_invalid', }, }) }) + test('emits JSON for usage errors with code and param', () => { + const err = new UsageError('Missing required flag: --amount', { + code: 'missing_flag', + param: 'amount', + hints: ['Usage: fintoc charges create --amount '], + }) + + expect(() => handleError(err, { json: true })).toThrow('process.exit') + + expect(JSON.parse(stderrSpy.mock.calls[0][0] as string)).toEqual({ + error: { + type: 'usage_error', + code: 'missing_flag', + param: 'amount', + message: 'Missing required flag: --amount', + }, + }) + }) + test('emits JSON for unknown error', () => { const err = new Error('Something broke') diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 81d349f..38c0b81 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1,10 +1,34 @@ import type { Command } from 'commander' +const editDistance = (a: string, b: string) => { + let previous = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + const current = [i] + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost) + } + previous = current + } + return previous[b.length] +} + +export const suggestCommand = (cmd: Command, name: string) => { + const candidates = cmd.commands.flatMap((c) => [c.name(), ...c.aliases()]) + const scored = candidates + .map((candidate) => ({ candidate, distance: editDistance(name, candidate) })) + .filter(({ candidate, distance }) => distance <= Math.max(2, Math.floor(candidate.length / 3))) + .sort((a, b) => a.distance - b.distance) + return scored[0]?.candidate +} + export const addDefaultAction = (cmd: Command) => { cmd.allowExcessArguments(true).action((_opts: unknown, actionCmd: Command) => { const args = actionCmd.args as string[] if (args.length > 0) { - cmd.error(`unknown command '${args[0]}'`, { exitCode: 1, code: 'commander.unknownCommand' }) + const suggestion = suggestCommand(cmd, args[0]) + const message = `unknown command '${args[0]}'${suggestion ? `\n(Did you mean ${suggestion}?)` : ''}` + cmd.error(message, { exitCode: 1, code: 'commander.unknownCommand' }) } cmd.help() }) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index eb6047e..27984d6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,6 +6,8 @@ export const IP_ALLOWLIST_ERROR_CODES = new Set([ 'missing_allowed_cidr_block', ]) export const DEFAULT_LIST_LIMIT = 10 +export const JWS_COMMANDS = + 'v2 transfers create and return, v2 transfer_batches create and v2 account_verifications create' export const NPM_CHECK_TIMEOUT_MS = 10_000 export const CONFIG_FILE_PERMISSIONS = 0o600 export const CONFIG_DIR_PERMISSIONS = 0o700 @@ -26,3 +28,17 @@ export const DOCS_ACCOUNT_VERIFICATIONS_URL = 'https://docs.fintoc.com/reference/account-verification-object.md' export const DOCS_ACCOUNT_NUMBERS_URL = 'https://docs.fintoc.com/reference/account-number-object.md' export const DOCS_MOVEMENTS_URL = 'https://docs.fintoc.com/reference/movement-object.md' +export const DOCS_ACCOUNTS_URL = 'https://docs.fintoc.com/reference/account-object.md' +export const DOCS_REFUNDS_URL = 'https://docs.fintoc.com/reference/refund-object.md' +export const DOCS_DISPUTES_URL = 'https://docs.fintoc.com/reference/dispute-object.md' +export const DOCS_FISCAL_INVOICES_URL = + 'https://docs.fintoc.com/reference/fiscal-invoices-object.md' +export const DOCS_CHECKOUT_SESSIONS_URL = + 'https://docs.fintoc.com/reference/checkout-session-object.md' +export const DOCS_ENTITIES_URL = 'https://docs.fintoc.com/reference/entity-object.md' +export const DOCS_RECIPIENTS_URL = + 'https://docs.fintoc.com/api/business-accounts-api/recipients/recipient-object.md' +export const DOCS_PAYOUTS_URL = 'https://docs.fintoc.com/reference/payout-object.md' +export const DOCS_EVENTS_URL = 'https://docs.fintoc.com/reference/events-object.md' +export const DOCS_TRANSFER_BATCHES_URL = + 'https://docs.fintoc.com/api/business-accounts-api/transfer-batches/transfer-batch-object.md' diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 95817f9..b98a4b8 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -4,6 +4,7 @@ import { DOCS_LINKS_URL, DOCS_TRANSFERS_URL, IP_ALLOWLIST_ERROR_CODES, + JWS_COMMANDS, } from './constants.js' import { error, hint } from './output.js' @@ -78,6 +79,27 @@ export const parseFintocError = (err: unknown): FintocErrorFields | undefined => return undefined } +export class UsageError extends Error { + readonly code: string + readonly param?: string + readonly hints: string[] + + constructor( + message: string, + { + code = 'invalid_usage', + param, + hints = [], + }: { code?: string; param?: string; hints?: string[] } = {}, + ) { + super(message) + this.name = 'UsageError' + this.code = code + this.param = param + this.hints = hints + } +} + const isNoAuthError = (err: unknown): boolean => err instanceof Error && err.message.includes('No API key found') @@ -94,12 +116,33 @@ const printNextSteps = (steps: string[]) => { steps.forEach((step) => hint(` ${step}`)) } -const exitJsonError = (fields: { type: string; code?: string; message: string }): never => { +const exitJsonError = (fields: { + type: string + code?: string + param?: string + message: string +}): never => { console.error(JSON.stringify({ error: fields })) return process.exit(1) } export const handleError = (err: unknown, context?: ErrorContext): never => { + if (err instanceof UsageError) { + if (context?.json) { + return exitJsonError({ + type: 'usage_error', + code: err.code, + param: err.param, + message: err.message, + }) + } + error(err.message) + if (err.hints.length > 0) { + printNextSteps(err.hints) + } + return process.exit(1) + } + if (isNoAuthError(err)) { if (context?.json) { return exitJsonError({ type: 'auth_error', message: 'No API key found' }) @@ -134,15 +177,17 @@ export const handleError = (err: unknown, context?: ErrorContext): never => { return exitJsonError({ type: fields.type, code: fields.code, + param: fields.param, message: fields.message ?? (err as Error).message, }) } if (fields.code === 'missing_jws_signature_header') { - error('JWS private key required for transfer operations') + error('JWS private key required to sign this request') printNextSteps([ 'Pass: --jws-private-key ', 'Or set in: ~/.fintoc/config.toml (jws_private_key = "")', + `Needed for: ${JWS_COMMANDS}`, `More info: ${DOCS_TRANSFERS_URL}`, ]) return process.exit(1) diff --git a/src/lib/json-body.ts b/src/lib/json-body.ts index 44d3b9e..553a28a 100644 --- a/src/lib/json-body.ts +++ b/src/lib/json-body.ts @@ -1,11 +1,13 @@ import { readFileSync } from 'node:fs' import { isPlainObject } from 'es-toolkit/compat' -import { error } from './output.js' +import { UsageError } from './errors.js' export const readJsonBody = (fromJson: string): Record => { if (fromJson === '-' && process.stdin.isTTY) { - error('--from-json -: no input on stdin. Pipe a file or use a path instead') - process.exit(1) + throw new UsageError('--from-json -: no input on stdin. Pipe a file or use a path instead', { + code: 'missing_input', + param: 'from-json', + }) } let raw: string @@ -13,8 +15,10 @@ export const readJsonBody = (fromJson: string): Record => { raw = fromJson === '-' ? readFileSync(0, 'utf-8') : readFileSync(fromJson, 'utf-8') } catch (err) { if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { - error(`--from-json: file '${fromJson}' not found`) - process.exit(1) + throw new UsageError(`--from-json: file '${fromJson}' not found`, { + code: 'file_not_found', + param: 'from-json', + }) } throw err } @@ -23,13 +27,17 @@ export const readJsonBody = (fromJson: string): Record => { try { parsed = JSON.parse(raw) } catch { - error(`--from-json: invalid JSON${fromJson !== '-' ? ` in ${fromJson}` : ''}`) - process.exit(1) + throw new UsageError(`--from-json: invalid JSON${fromJson !== '-' ? ` in ${fromJson}` : ''}`, { + code: 'invalid_json', + param: 'from-json', + }) } if (!isPlainObject(parsed)) { - error('--from-json must contain a JSON object') - process.exit(1) + throw new UsageError('--from-json must contain a JSON object', { + code: 'invalid_json', + param: 'from-json', + }) } return parsed as Record From b729be5e024ca0d3de493930cf210a142f5b2b90 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 12:12:08 -0300 Subject: [PATCH 03/15] feat: ampliar el motor de recursos con acciones y archivos --- .../__tests__/factory-actions.test.ts | 963 ++++++++++++++++++ src/resources/__tests__/factory.test.ts | 85 +- src/resources/__tests__/registry.test.ts | 11 +- src/resources/factory.ts | 932 ++++++++++++----- src/resources/registry.ts | 33 +- src/types.ts | 68 +- 6 files changed, 1761 insertions(+), 331 deletions(-) create mode 100644 src/resources/__tests__/factory-actions.test.ts diff --git a/src/resources/__tests__/factory-actions.test.ts b/src/resources/__tests__/factory-actions.test.ts new file mode 100644 index 0000000..9c93adf --- /dev/null +++ b/src/resources/__tests__/factory-actions.test.ts @@ -0,0 +1,963 @@ +import type { ResourceDef } from '../../types.js' +import { Buffer } from 'node:buffer' +import { readFileSync } from 'node:fs' +import { confirm } from '@inquirer/prompts' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createClient, resolveAuth } from '../../lib/auth.js' +import { readConfig } from '../../lib/config.js' +import { error, hint, printDetail, printJson, printTable, success, warn } from '../../lib/output.js' +import { describeCommands, registerResourceCommands } from '../factory.js' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFileSync: vi.fn() } +}) + +vi.mock('../../lib/auth.js', () => ({ + resolveAuth: vi.fn(), + createClient: vi.fn(), +})) + +vi.mock('../../lib/config.js', () => ({ + readConfig: vi.fn(() => ({})), +})) + +vi.mock('../../lib/output.js', () => ({ + log: vi.fn(), + hint: vi.fn(), + success: vi.fn(), + error: vi.fn(), + printTable: vi.fn(), + printJson: vi.fn(), + printDetail: vi.fn(), + warn: vi.fn(), +})) + +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), +})) + +const mockManager = { + create: vi.fn(), + list: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + expire: vi.fn(), + cancel: vi.fn(), + voucherUrl: vi.fn(), + listResources: vi.fn(), + exchange: vi.fn(), + uploadDocument: vi.fn(), + deleteDocument: vi.fn(), + removeLines: vi.fn(), +} + +const mockClient = { widgets: mockManager } + +const result = (data: Record) => ({ serialize: () => data }) + +const asyncGenerator = (items: Record[]) => + (async function* () { + for (const item of items) { + yield result(item) + } + })() + +const baseResource: ResourceDef = { + name: 'widgets', + displayName: 'widget', + cliCommand: 'widgets', + sdkMethod: 'widgets', + sdkNamespace: 'v1', + verbs: [], + priorityColumns: ['id', 'status'], +} + +const createProgram = (resource: ResourceDef) => { + const program = new Command() + program.exitOverride().configureOutput({ writeErr: () => {} }) + program.option('--api-key ', 'Override API key').option('--json', 'Output as JSON') + registerResourceCommands(program, [resource]) + return program +} + +const run = (resource: ResourceDef, args: string[]) => + createProgram(resource).parseAsync(args, { from: 'user' }) + +const rejectsWith = (promise: Promise, code: string, message: string) => + expect(promise).rejects.toMatchObject({ code, message: expect.stringContaining(message) }) + +const mockProcessExit = () => + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + +const setTTY = (value: boolean | undefined) => + Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }) + +describe('factory: update, actions and files', () => { + let originalIsTTY: boolean | undefined + + beforeEach(() => { + vi.clearAllMocks() + originalIsTTY = process.stdin.isTTY + vi.mocked(resolveAuth).mockReturnValue({ secretKey: 'sk_test_123', source: 'config' }) + vi.mocked(createClient).mockReturnValue( + mockClient as unknown as ReturnType, + ) + vi.mocked(confirm).mockResolvedValue(true) + }) + + afterEach(() => { + setTTY(originalIsTTY) + vi.restoreAllMocks() + }) + + describe('update command', () => { + const resource: ResourceDef = { + ...baseResource, + verbs: ['update'], + updateFlags: [ + { name: 'description', type: 'string' }, + { name: 'max-amount', type: 'integer', nestedPath: 'options.max_amount' }, + { name: 'parent-id', type: 'string', pathParam: true }, + ], + } + + test('calls SDK update with id and body built from flags', async () => { + mockManager.update.mockResolvedValue(result({ id: 'w_1', description: 'new' })) + + await run(resource, [ + 'widgets', + 'update', + 'w_1', + '--parent-id', + 'p_1', + '--description', + 'new', + '--max-amount', + '10', + ]) + + expect(mockManager.update).toHaveBeenCalledWith('w_1', { + parent_id: 'p_1', + description: 'new', + options: { max_amount: 10 }, + }) + expect(success).toHaveBeenCalledWith("Widget 'w_1' updated") + expect(printDetail).toHaveBeenCalledWith({ id: 'w_1', description: 'new' }) + }) + + test('merges --from-json under flags', async () => { + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ description: 'json', metadata: { a: '1' } }), + ) + mockManager.update.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, [ + 'widgets', + 'update', + 'w_1', + '--parent-id', + 'p_1', + '--from-json', + 'body.json', + '--description', + 'flag', + ]) + + expect(mockManager.update).toHaveBeenCalledWith('w_1', { + description: 'flag', + metadata: { a: '1' }, + parent_id: 'p_1', + }) + }) + + test('exits when there is nothing to update', async () => { + const exitSpy = mockProcessExit() + + await expect( + run(resource, ['widgets', 'update', 'w_1', '--parent-id', 'p_1']), + ).rejects.toThrow('process.exit') + + expect(mockManager.update).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining('Nothing to update')) + exitSpy.mockRestore() + }) + + test('requires path params even with --from-json', async () => { + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ description: 'json' })) + const exitSpy = mockProcessExit() + + await expect( + run(resource, ['widgets', 'update', 'w_1', '--from-json', 'body.json']), + ).rejects.toThrow('process.exit') + + expect(mockManager.update).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith('Missing required flag: --parent-id') + exitSpy.mockRestore() + }) + + test('outputs JSON when --json flag is set', async () => { + mockManager.update.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, [ + '--json', + 'widgets', + 'update', + 'w_1', + '--parent-id', + 'p', + '--description', + 'd', + ]) + + expect(printJson).toHaveBeenCalledWith({ id: 'w_1' }) + expect(success).not.toHaveBeenCalled() + }) + }) + + describe('delete command with deleteFlags', () => { + test('passes the flags to SDK delete', async () => { + mockManager.delete.mockResolvedValue('w_1') + const resource: ResourceDef = { + ...baseResource, + verbs: ['delete'], + deleteFlags: [{ name: 'parent-id', type: 'string', pathParam: true }], + } + + await run(resource, ['widgets', 'delete', 'w_1', '--parent-id', 'p_1', '--yes']) + + expect(mockManager.delete).toHaveBeenCalledWith('w_1', { parent_id: 'p_1' }) + }) + }) + + describe('action command', () => { + const resource: ResourceDef = { + ...baseResource, + actions: [ + { + name: 'cancel', + sdkMethod: 'cancel', + description: 'Cancel a widget', + confirm: true, + successMessage: "{resource} '{id}' canceled", + }, + { name: 'voucher_url', sdkMethod: 'voucherUrl', description: 'Voucher', kind: 'read' }, + { + name: 'resources', + sdkMethod: 'listResources', + description: 'List resources', + kind: 'list', + flags: [{ name: 'resource-type', type: 'string', required: true }], + priorityColumns: ['id', 'amount'], + }, + { + name: 'exchange', + sdkMethod: 'exchange', + description: 'Exchange', + kind: 'read', + idArg: false, + flags: [{ name: 'exchange-token', type: 'string', required: true }], + }, + { + name: 'delete_document', + sdkMethod: 'deleteDocument', + description: 'Delete a document', + extraArgs: [{ name: 'slot_key' }, { name: 'document_id' }], + flags: [{ name: 'parent-id', type: 'string', pathParam: true }], + confirm: true, + }, + { + name: 'remove_lines', + sdkMethod: 'removeLines', + description: 'Remove lines', + fromJson: true, + flags: [{ name: 'lines', type: 'string[]', required: true }], + }, + ], + } + + test('registers one subcommand per action', () => { + const cmd = createProgram(resource).commands.find((c) => c.name() === 'widgets')! + expect(cmd.commands.map((c) => c.name())).toEqual([ + 'cancel', + 'voucher_url', + 'resources', + 'exchange', + 'delete_document', + 'remove_lines', + ]) + }) + + test('calls the SDK method with only the id when the action takes no body', async () => { + mockManager.cancel.mockResolvedValue(result({ id: 'w_1', status: 'canceled' })) + + await run(resource, ['widgets', 'cancel', 'w_1', '--yes']) + + expect(mockManager.cancel).toHaveBeenCalledWith('w_1') + expect(confirm).not.toHaveBeenCalled() + expect(success).toHaveBeenCalledWith("Widget 'w_1' canceled") + expect(printDetail).toHaveBeenCalledWith({ id: 'w_1', status: 'canceled' }) + }) + + describe('when the action requires confirmation', () => { + test('asks for confirmation in a TTY and runs when accepted', async () => { + setTTY(true) + mockManager.cancel.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['widgets', 'cancel', 'w_1']) + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: "Are you sure you want to cancel widget 'w_1'?" }), + { output: process.stderr }, + ) + expect(mockManager.cancel).toHaveBeenCalledWith('w_1') + }) + + test('aborts without calling the SDK when declined', async () => { + setTTY(true) + vi.mocked(confirm).mockResolvedValue(false) + + await run(resource, ['widgets', 'cancel', 'w_1']) + + expect(mockManager.cancel).not.toHaveBeenCalled() + expect(hint).toHaveBeenCalledWith('Aborted.') + }) + + test('JSON mode requires --yes even in a TTY', async () => { + setTTY(true) + mockProcessExit() + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(run(resource, ['--json', 'widgets', 'cancel', 'w_1'])).rejects.toThrow( + 'process.exit', + ) + + expect(confirm).not.toHaveBeenCalled() + expect(mockManager.cancel).not.toHaveBeenCalled() + expect(JSON.parse(stderr.mock.calls[0][0]).error.code).toBe('confirmation_required') + expect(hint).not.toHaveBeenCalled() + }) + + test('exits with error in non-TTY without --yes', async () => { + setTTY(false) + const exitSpy = mockProcessExit() + + await expect(run(resource, ['widgets', 'cancel', 'w_1'])).rejects.toThrow('process.exit') + + expect(mockManager.cancel).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining('--yes')) + exitSpy.mockRestore() + }) + }) + + test('read actions print the result without a success message', async () => { + mockManager.voucherUrl.mockResolvedValue(result({ url: 'https://voucher' })) + + await run(resource, ['widgets', 'voucher_url', 'w_1']) + + expect(mockManager.voucherUrl).toHaveBeenCalledWith('w_1') + expect(success).not.toHaveBeenCalled() + expect(printDetail).toHaveBeenCalledWith({ url: 'https://voucher' }) + }) + + test('list actions iterate the result with --limit and print a table', async () => { + mockManager.listResources.mockResolvedValue( + asyncGenerator([{ id: 'a' }, { id: 'b' }, { id: 'c' }]), + ) + + await run(resource, [ + 'widgets', + 'resources', + 'w_1', + '--resource-type', + 'payment_intent', + '--limit', + '2', + ]) + + expect(mockManager.listResources).toHaveBeenCalledWith('w_1', { + resource_type: 'payment_intent', + }) + expect(printTable).toHaveBeenCalledWith({ + columns: ['id', 'amount'], + rows: [{ id: 'a' }, { id: 'b' }], + }) + }) + + test('actions without id pass only the body', async () => { + mockManager.exchange.mockResolvedValue(result({ id: 'link_1' })) + + await run(resource, ['widgets', 'exchange', '--exchange-token', 'tok']) + + expect(mockManager.exchange).toHaveBeenCalledWith({ exchange_token: 'tok' }) + }) + + test('passes extra positional args in order before the body', async () => { + mockManager.deleteDocument.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, [ + 'widgets', + 'delete_document', + 'w_1', + 'slot', + 'doc_1', + '--parent-id', + 'p_1', + '--yes', + ]) + + expect(mockManager.deleteDocument).toHaveBeenCalledWith('w_1', 'slot', 'doc_1', { + parent_id: 'p_1', + }) + }) + + test('--from-json skips required body flags', async () => { + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ lines: ['li_1'] })) + mockManager.removeLines.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['widgets', 'remove_lines', 'w_1', '--from-json', 'body.json']) + + expect(mockManager.removeLines).toHaveBeenCalledWith('w_1', { lines: ['li_1'] }) + }) + + test('exits when a required action flag is missing', async () => { + const exitSpy = mockProcessExit() + + await expect(run(resource, ['widgets', 'remove_lines', 'w_1'])).rejects.toThrow( + 'process.exit', + ) + + expect(mockManager.removeLines).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining('--lines')) + expect(hint).toHaveBeenCalledWith( + expect.stringContaining('Usage: fintoc widgets remove_lines --lines'), + ) + exitSpy.mockRestore() + }) + + test('reads the JWS key from config for actions that need it', async () => { + vi.mocked(readConfig).mockReturnValueOnce({ jws_private_key: '/keys/jws.pem' }) + mockManager.exchange.mockResolvedValue(result({ id: 'x' })) + const jwsResource: ResourceDef = { + ...baseResource, + actions: [{ ...resource.actions![3], needsJws: true }], + } + + await run(jwsResource, ['widgets', 'exchange', '--exchange-token', 'tok']) + + expect(createClient).toHaveBeenCalledWith('sk_test_123', '/keys/jws.pem') + }) + + test('does not read the JWS key for actions that do not need it', async () => { + vi.mocked(readConfig).mockReturnValue({ jws_private_key: '/keys/jws.pem' }) + mockManager.voucherUrl.mockResolvedValue(result({ url: 'https://x' })) + + await run({ ...resource, needsJws: true }, ['widgets', 'voucher_url', 'w_1']) + + expect(createClient).toHaveBeenCalledWith('sk_test_123', undefined) + }) + }) + + describe('boolean flags', () => { + const resource: ResourceDef = { + ...baseResource, + verbs: ['update'], + updateFlags: [{ name: 'active', type: 'boolean' }], + } + + test.each([ + ['true', true], + ['False', false], + ])('parses %s', async (value, expected) => { + mockManager.update.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['widgets', 'update', 'w_1', '--active', value]) + + expect(mockManager.update).toHaveBeenCalledWith('w_1', { active: expected }) + }) + + test('rejects values other than true or false', async () => { + await rejectsWith( + run(resource, ['widgets', 'update', 'w_1', '--active', 'yes']), + 'commander.invalidArgument', + "argument 'yes' is invalid. Expected true or false.", + ) + + expect(mockManager.update).not.toHaveBeenCalled() + }) + }) + + describe('map flags', () => { + const resource: ResourceDef = { + ...baseResource, + verbs: ['create'], + createFlags: [ + { name: 'name', type: 'string', required: true }, + { name: 'metadata', type: 'map' }, + { name: 'customer-metadata', type: 'map', nestedPath: 'customer.metadata' }, + ], + } + + test('collects repeated key=value pairs into an object', async () => { + mockManager.create.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, [ + 'widgets', + 'create', + '--name', + 'x', + '--metadata', + 'order=123', + '--metadata', + 'note=a=b', + '--customer-metadata', + 'tier=gold', + ]) + + expect(mockManager.create).toHaveBeenCalledWith({ + name: 'x', + metadata: { order: '123', note: 'a=b' }, + customer: { metadata: { tier: 'gold' } }, + }) + }) + + test('rejects a value without a key', async () => { + await rejectsWith( + run(resource, ['widgets', 'create', '--name', 'x', '--metadata', '=1']), + 'commander.invalidArgument', + 'Expected =.', + ) + + expect(mockManager.create).not.toHaveBeenCalled() + }) + }) + + describe('file uploads', () => { + const pdf = Buffer.from('%PDF') + + beforeEach(() => { + vi.mocked(readFileSync).mockReturnValue(pdf) + }) + + const uploadResource: ResourceDef = { + ...baseResource, + verbs: ['create'], + createFlags: [{ name: 'type', type: 'string', required: true }], + createFiles: [ + { field: 'docs.contract', required: true }, + { field: 'docs.selfie', required: false }, + ], + actions: [ + { + name: 'upload_document', + sdkMethod: 'uploadDocument', + description: 'Upload', + extraArgs: [{ name: 'slot_key' }], + flags: [{ name: 'parent-id', type: 'string', pathParam: true }], + files: [{ field: 'file', required: true }], + fileAsArg: true, + }, + ], + } + + test('create puts each --file at its field path inside the body', async () => { + mockManager.create.mockResolvedValue(result({ id: 'w_1' })) + + await run(uploadResource, [ + 'widgets', + 'create', + '--type', + 'x', + '--file', + 'docs.contract=/tmp/contract.pdf', + '--file', + 'docs.selfie=/tmp/me.jpg', + ]) + + expect(readFileSync).toHaveBeenCalledWith('/tmp/contract.pdf') + expect(mockManager.create).toHaveBeenCalledWith({ + type: 'x', + docs: { + contract: { data: pdf, filename: 'contract.pdf' }, + selfie: { data: pdf, filename: 'me.jpg' }, + }, + }) + }) + + test('upload actions pass the file as a positional argument', async () => { + mockManager.uploadDocument.mockResolvedValue(result({ id: 'w_1' })) + + await run(uploadResource, [ + 'widgets', + 'upload_document', + 'w_1', + 'slot', + '--parent-id', + 'p_1', + '--file', + 'file=/tmp/doc.pdf', + ]) + + expect(mockManager.uploadDocument).toHaveBeenCalledWith( + 'w_1', + 'slot', + { data: pdf, filename: 'doc.pdf' }, + { parent_id: 'p_1' }, + ) + }) + + test('exits with error when a required file is missing', async () => { + const exitSpy = mockProcessExit() + + await expect( + run(uploadResource, [ + 'widgets', + 'create', + '--type', + 'x', + '--file', + 'docs.selfie=/tmp/me.jpg', + ]), + ).rejects.toThrow('process.exit') + + expect(mockManager.create).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith('Missing required file: docs.contract') + exitSpy.mockRestore() + }) + + test.each([ + ['docs.contract', 'Expected =.'], + ['other=/tmp/x.pdf', "Unknown field 'other'. Valid fields: docs.contract, docs.selfie."], + ])('rejects --file %s', async (value, message) => { + await rejectsWith( + run(uploadResource, ['widgets', 'create', '--type', 'x', '--file', value]), + 'commander.invalidArgument', + message, + ) + + expect(mockManager.create).not.toHaveBeenCalled() + }) + + test('exits with error when the same --file field is given twice', async () => { + await rejectsWith( + run(uploadResource, [ + 'widgets', + 'create', + '--type', + 'x', + '--file', + 'docs.contract=/tmp/a.pdf', + '--file', + 'docs.contract=/tmp/b.pdf', + ]), + 'commander.invalidArgument', + "Field 'docs.contract' was given more than once.", + ) + + expect(mockManager.create).not.toHaveBeenCalled() + }) + + test('reports unreadable files through the error handler in actions', async () => { + vi.mocked(readFileSync).mockImplementationOnce(() => { + throw Object.assign(new Error('EISDIR: illegal operation on a directory'), { + code: 'EISDIR', + }) + }) + const exitSpy = mockProcessExit() + + await expect( + run(uploadResource, [ + 'widgets', + 'upload_document', + 'w_1', + 'slot', + '--parent-id', + 'p_1', + '--file', + 'file=/tmp', + ]), + ).rejects.toThrow('process.exit') + + expect(mockManager.uploadDocument).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining('EISDIR')) + exitSpy.mockRestore() + }) + + test('exits with error when the file does not exist', async () => { + vi.mocked(readFileSync).mockImplementationOnce(() => { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }) + const exitSpy = mockProcessExit() + + await expect( + run(uploadResource, [ + 'widgets', + 'create', + '--type', + 'x', + '--file', + 'docs.contract=/nope.pdf', + ]), + ).rejects.toThrow('process.exit') + + expect(error).toHaveBeenCalledWith("--file: file '/nope.pdf' not found") + exitSpy.mockRestore() + }) + }) + + describe('help text', () => { + test('uses pluralDisplayName and the right article', () => { + const program = createProgram({ + ...baseResource, + name: 'entities', + cliCommand: 'entities', + displayName: 'entity', + pluralDisplayName: 'entities', + verbs: ['get', 'update'], + updateFlags: [{ name: 'name', type: 'string' }], + }) + const cmd = program.commands.find((c) => c.name() === 'entities')! + + expect(cmd.description()).toBe('Manage entities') + expect(cmd.commands.map((c) => c.description())).toEqual([ + 'Get an entity by ID', + 'Update an entity', + ]) + }) + }) + + describe('cli conventions', () => { + const resource: ResourceDef = { + ...baseResource, + name: 'payment_widgets', + cliCommand: 'payment_widgets', + sdkMethod: 'paymentWidgets', + verbs: ['create', 'list', 'delete'], + createFlags: [ + { name: 'lines', type: 'string[]' }, + { name: 'active', type: 'boolean' }, + { name: 'legacy', type: 'string', deprecated: 'it does nothing' }, + ], + createFiles: [], + actions: [ + { + name: 'submit_for_review', + sdkMethod: 'cancel', + description: 'Submit', + confirm: true, + }, + { + name: 'upload', + sdkMethod: 'uploadDocument', + description: 'Upload', + files: [{ field: 'file', required: true }], + fileAsArg: true, + }, + ], + } + const client = { paymentWidgets: mockManager } + + beforeEach(() => { + vi.mocked(createClient).mockReturnValue(client as unknown as ReturnType) + }) + + test('string[] flags accept repeated and comma-separated values', async () => { + mockManager.create.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['payment_widgets', 'create', '--lines', 'a,b', '--lines', 'c']) + + expect(mockManager.create).toHaveBeenCalledWith({ lines: ['a', 'b', 'c'] }) + }) + + test('a bare boolean flag means true', async () => { + mockManager.create.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['payment_widgets', 'create', '--active']) + + expect(mockManager.create).toHaveBeenCalledWith({ active: true }) + }) + + test('deprecated flags warn and are not sent', async () => { + mockManager.create.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['payment_widgets', 'create', '--legacy', 'x', '--active', 'false']) + + expect(warn).toHaveBeenCalledWith('--legacy is deprecated: it does nothing') + expect(mockManager.create).toHaveBeenCalledWith({ active: false }) + }) + + test('--idempotency-key is passed to the SDK on create', async () => { + mockManager.create.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['payment_widgets', 'create', '--active', '--idempotency-key', 'k_1']) + + expect(mockManager.create).toHaveBeenCalledWith({ active: true, idempotency_key: 'k_1' }) + }) + + test('resources and actions also answer to their kebab-case names', async () => { + mockManager.cancel.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['payment-widgets', 'submit-for-review', 'w_1', '--yes']) + + expect(mockManager.cancel).toHaveBeenCalledWith('w_1') + }) + + test('a single-field --file accepts just the path', async () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('%PDF')) + mockManager.uploadDocument.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['payment_widgets', 'upload', 'w_1', '--file', '/tmp/doc.pdf']) + + expect(mockManager.uploadDocument).toHaveBeenCalledWith('w_1', { + data: Buffer.from('%PDF'), + filename: 'doc.pdf', + }) + }) + + test('the confirmation says when the key is live', async () => { + setTTY(true) + vi.mocked(resolveAuth).mockReturnValue({ secretKey: 'sk_live_123', source: 'config' }) + mockManager.cancel.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['payment_widgets', 'submit_for_review', 'w_1']) + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Are you sure you want to submit for review widget 'w_1' in LIVE mode?", + }), + { output: process.stderr }, + ) + }) + + test('delete prints a JSON result with --json', async () => { + mockManager.delete.mockResolvedValue('w_1') + + await run(resource, ['--json', 'payment_widgets', 'delete', 'w_1', '--yes']) + + expect(printJson).toHaveBeenCalledWith({ id: 'w_1', deleted: true }) + }) + + test('list warns on stderr when there may be more results than --limit', async () => { + mockManager.list.mockResolvedValue(asyncGenerator([{ id: 'a' }, { id: 'b' }, { id: 'c' }])) + + await run(resource, ['payment_widgets', 'list', '--limit', '2']) + + expect(printTable).toHaveBeenCalledWith( + expect.objectContaining({ rows: [{ id: 'a' }, { id: 'b' }] }), + ) + expect(hint).toHaveBeenCalledWith( + 'More results may be available. Use --limit to show more than 2.', + ) + }) + + test('list keeps stderr clean with --json', async () => { + mockManager.list.mockResolvedValue(asyncGenerator([{ id: 'a' }, { id: 'b' }, { id: 'c' }])) + + await run(resource, ['--json', 'payment_widgets', 'list', '--limit', '2']) + + expect(printJson).toHaveBeenCalledWith([{ id: 'a' }, { id: 'b' }]) + expect(hint).not.toHaveBeenCalled() + }) + + test('list never reads past the limit and closes the iterator', async () => { + const extraRead = vi.fn() + const closed = vi.fn() + mockManager.list.mockResolvedValue( + (async function* () { + try { + yield result({ id: 'a' }) + extraRead() + throw new Error('429 Too Many Requests') + } finally { + closed() + } + })(), + ) + + await run(resource, ['--json', 'payment_widgets', 'list', '--limit', '1']) + + expect(printJson).toHaveBeenCalledWith([{ id: 'a' }]) + expect(extraRead).not.toHaveBeenCalled() + expect(closed).toHaveBeenCalledOnce() + }) + + test('deprecated flags do not warn with --json', async () => { + mockManager.create.mockResolvedValue(result({ id: 'w_1' })) + + await run(resource, ['--json', 'payment_widgets', 'create', '--legacy', 'x']) + + expect(warn).not.toHaveBeenCalled() + }) + + test('POST actions accept --idempotency-key even without a body', async () => { + mockManager.cancel.mockResolvedValue(result({ id: 'w_1' })) + const idempotent: ResourceDef = { + ...resource, + actions: [ + { name: 'cancel', sdkMethod: 'cancel', description: 'Cancel', idempotencyKey: true }, + ], + } + + await run(idempotent, ['payment_widgets', 'cancel', 'w_1', '--idempotency-key', 'k_1']) + + expect(mockManager.cancel).toHaveBeenCalledWith('w_1', { idempotency_key: 'k_1' }) + }) + + test('usage errors are JSON with --json', async () => { + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = mockProcessExit() + + await expect(run(resource, ['--json', 'payment_widgets', 'upload', 'w_1'])).rejects.toThrow( + 'process.exit', + ) + + expect(JSON.parse(stderr.mock.calls[0][0] as string)).toEqual({ + error: { + type: 'usage_error', + code: 'missing_file', + param: 'file', + message: 'Missing required file: file', + }, + }) + exitSpy.mockRestore() + }) + + test('describeCommands exposes every command with its API params', () => { + const commands = describeCommands([ + { + ...resource, + createFlags: [{ name: 'max-amount', type: 'integer', nestedPath: 'options.max_amount' }], + }, + ]) + + expect(commands.map((c) => c.command)).toEqual([ + 'fintoc payment_widgets create', + 'fintoc payment_widgets list', + 'fintoc payment_widgets delete', + 'fintoc payment_widgets submit_for_review', + 'fintoc payment_widgets upload', + ]) + expect(commands[0].flags).toEqual([ + { + name: 'max-amount', + type: 'integer', + required: false, + param: 'options.max_amount', + in: 'body', + }, + ]) + expect(commands[0].options).toEqual(['--from-json', '--idempotency-key']) + expect(commands[1].options).toEqual(['--limit']) + expect(commands[3]).toMatchObject({ + kind: 'write', + confirm: true, + args: [{ name: 'id' }], + options: ['--yes'], + aliases: ['fintoc payment-widgets submit-for-review'], + }) + }) + }) +}) diff --git a/src/resources/__tests__/factory.test.ts b/src/resources/__tests__/factory.test.ts index 5078be9..3556cbf 100644 --- a/src/resources/__tests__/factory.test.ts +++ b/src/resources/__tests__/factory.test.ts @@ -73,7 +73,7 @@ const testResource: ResourceDef = { const createProgram = (resource: ResourceDef = testResource) => { const program = new Command() - program.exitOverride() + program.exitOverride().configureOutput({ writeErr: () => {} }) program.option('--api-key ', 'Override API key').option('--json', 'Output as JSON') registerResourceCommands(program, [resource]) return program @@ -277,11 +277,10 @@ describe('factory', () => { from: 'user', }, ), - ).rejects.toThrow('process.exit') - - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Invalid value 'abc' for flag --amount: expected an integer"), - ) + ).rejects.toMatchObject({ + code: 'commander.invalidArgument', + message: expect.stringContaining("argument 'abc' is invalid. Expected an integer."), + }) expect(mockManager.create).not.toHaveBeenCalled() }) @@ -294,11 +293,10 @@ describe('factory', () => { from: 'user', }, ), - ).rejects.toThrow('process.exit') - - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Invalid value '1.5' for flag --amount: expected an integer"), - ) + ).rejects.toMatchObject({ + code: 'commander.invalidArgument', + message: expect.stringContaining("argument '1.5' is invalid. Expected an integer."), + }) expect(mockManager.create).not.toHaveBeenCalled() }) @@ -744,9 +742,10 @@ describe('factory', () => { const program = createProgram() await expect( program.parseAsync(['payment_intents', 'list', '--limit', value], { from: 'user' }), - ).rejects.toThrow('process.exit') - - expect(error).toHaveBeenCalledWith('--limit must be a positive integer') + ).rejects.toMatchObject({ + code: 'commander.invalidArgument', + message: expect.stringContaining(`argument '${value}' is invalid`), + }) expect(mockManager.list).not.toHaveBeenCalled() }) }) @@ -809,64 +808,6 @@ describe('factory', () => { }) }) - describe('test command', () => { - const testResourceWithTest: ResourceDef = { - name: 'webhook_endpoints', - displayName: 'webhook endpoint', - cliCommand: 'webhook_endpoints', - sdkMethod: 'paymentIntents', - sdkNamespace: 'v1', - verbs: ['test'], - priorityColumns: ['id'], - testFlags: [ - { name: 'type', type: 'string', required: true, description: 'Event type to test' }, - ], - } - - test('calls SDK test with id and parsed flags', async () => { - const mockResult = { serialize: () => ({ id: 'we_123', status: 'ok' }) } - mockManager.test.mockResolvedValue(mockResult) - - const program = createProgram(testResourceWithTest) - await program.parseAsync( - ['webhook_endpoints', 'test', 'we_123', '--type', 'payment_intent.succeeded'], - { from: 'user' }, - ) - - expect(mockManager.test).toHaveBeenCalledWith('we_123', { type: 'payment_intent.succeeded' }) - expect(success).toHaveBeenCalledWith(expect.stringContaining('we_123')) - expect(printDetail).toHaveBeenCalledWith({ id: 'we_123', status: 'ok' }) - }) - - test('exits with error when required flag is missing', async () => { - const exitSpy = mockProcessExit() - - const program = createProgram(testResourceWithTest) - await expect( - program.parseAsync(['webhook_endpoints', 'test', 'we_123'], { from: 'user' }), - ).rejects.toThrow('process.exit') - - expect(mockManager.test).not.toHaveBeenCalled() - expect(error).toHaveBeenCalledWith(expect.stringContaining('--type')) - - exitSpy.mockRestore() - }) - - test('outputs JSON when --json flag is set', async () => { - const mockResult = { serialize: () => ({ id: 'we_123', status: 'ok' }) } - mockManager.test.mockResolvedValue(mockResult) - - const program = createProgram(testResourceWithTest) - await program.parseAsync( - ['--json', 'webhook_endpoints', 'test', 'we_123', '--type', 'payment_intent.succeeded'], - { from: 'user' }, - ) - - expect(printJson).toHaveBeenCalledWith({ id: 'we_123', status: 'ok' }) - expect(success).not.toHaveBeenCalled() - }) - }) - describe('--jws-private-key flag', () => { describe('when resource.needsJws is true', () => { const jwsResource: ResourceDef = { diff --git a/src/resources/__tests__/registry.test.ts b/src/resources/__tests__/registry.test.ts index 303b398..0586477 100644 --- a/src/resources/__tests__/registry.test.ts +++ b/src/resources/__tests__/registry.test.ts @@ -65,7 +65,7 @@ describe('resource registry', () => { for (const flag of resource.getFlags ?? []) { expect(validTypes.has(flag.type)).toBe(true) } - for (const flag of resource.testFlags ?? []) { + for (const flag of resource.actions?.flatMap((a) => a.flags ?? []) ?? []) { expect(validTypes.has(flag.type)).toBe(true) } } @@ -166,10 +166,13 @@ describe('resource registry', () => { expect(eventsFlag.type).toBe('string[]') }) - test('webhook_endpoints supports test verb with required type flag', () => { + test('webhook_endpoints supports test action with required type flag', () => { const webhooks = resources.find((r) => r.name === 'webhook_endpoints')! - expect(webhooks.verbs).toContain('test') - const requiredTestFlags = webhooks.testFlags!.filter((f) => f.required).map((f) => f.name) + expect(webhooks.actions?.map((a) => a.name)).toContain('test') + const requiredTestFlags = webhooks + .actions!.find((a) => a.name === 'test')! + .flags!.filter((f) => f.required) + .map((f) => f.name) expect(requiredTestFlags).toEqual(['type']) }) diff --git a/src/resources/factory.ts b/src/resources/factory.ts index 42cf911..4f21385 100644 --- a/src/resources/factory.ts +++ b/src/resources/factory.ts @@ -1,26 +1,65 @@ import type { Command } from 'commander' import type { Fintoc } from 'fintoc' -import type { FlagDef, ResourceDef, SdkManager, Serializable } from '../types.js' +import type { + ActionDef, + ArgDef, + FileDef, + FlagDef, + ResourceDef, + SdkManager, + Serializable, + UploadFile, + Verb, +} from '../types.js' +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' import { confirm } from '@inquirer/prompts' +import { InvalidArgumentError, Option } from 'commander' import { set } from 'es-toolkit/compat' import { createClient, resolveAuth } from '../lib/auth.js' import { addDefaultAction } from '../lib/commands.js' import { readConfig } from '../lib/config.js' import { DEFAULT_LIST_LIMIT } from '../lib/constants.js' import { deepMerge } from '../lib/deep-merge.js' -import { handleError } from '../lib/errors.js' +import { handleError, UsageError } from '../lib/errors.js' import { readJsonBody } from '../lib/json-body.js' -import { error, hint, printDetail, printJson, printTable, success } from '../lib/output.js' +import { hint, printDetail, printJson, printTable, success, warn } from '../lib/output.js' type RootOpts = { apiKey?: string json?: boolean } +type BodyOpts = { + fromJson?: string + jwsPrivateKey?: string + file?: Record + yes?: boolean + idempotencyKey?: string +} + +const FROM_JSON_DESCRIPTION = + 'Read request body from a JSON file (use "-" for stdin). JSON keys must match the API body format (see https://docs.fintoc.com)' + +const IDEMPOTENCY_KEY_DESCRIPTION = + 'Idempotency key, so retrying the same request does not create a duplicate' + const toCamelCase = (str: string) => str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()) const toSnakeCase = (str: string) => str.replace(/-/g, '_') +const toKebabCase = (str: string) => str.replace(/_/g, '-') + +const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1) + +const fillTemplate = (template: string, resource: ResourceDef, id?: string) => + template.replace('{resource}', resource.displayName).replace(/ ?'\{id\}'/, id ? ` '${id}'` : '') + +const withArticle = (noun: string) => `${/^[aeiou]/i.test(noun) ? 'an' : 'a'} ${noun}` + +const pluralName = (resource: ResourceDef) => + resource.pluralDisplayName ?? `${resource.displayName}s` + const getRootOpts = (actionCmd: Command) => { let cmd = actionCmd while (cmd.parent) { @@ -45,83 +84,262 @@ const getManager = (client: Fintoc, resource: ResourceDef) => { return current as unknown as SdkManager } -const parseFlagValue = (value: string, flag: FlagDef) => { - if (flag.type === 'integer') { - const num = Number(value) - if (Number.isNaN(num)) { - throw new TypeError(`Invalid value '${value}' for flag --${flag.name}: expected an integer`) - } - if (!Number.isInteger(num)) { - throw new TypeError(`Invalid value '${value}' for flag --${flag.name}: expected an integer`) - } - return num +const PARAMETERS_GROUP = 'API parameters:' + +const parseInteger = (value: string) => { + const num = Number(value) + if (value.trim() === '' || !Number.isInteger(num)) { + throw new InvalidArgumentError('Expected an integer.') } + return num +} + +const parsePositiveInteger = (value: string) => { + const num = parseInteger(value) + if (num <= 0) { + throw new InvalidArgumentError('Expected a positive integer.') + } + return num +} + +const parseBoolean = (value: string) => { + const normalized = value.toLowerCase() + if (normalized !== 'true' && normalized !== 'false') { + throw new InvalidArgumentError('Expected true or false.') + } + return normalized === 'true' +} + +const parseList = (value: string, previous: string[] = []) => [ + ...previous, + ...value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), +] + +const parseMapEntry = (value: string, previous: Record = {}) => { + const separator = value.indexOf('=') + if (separator <= 0) { + throw new InvalidArgumentError('Expected =.') + } + return { ...previous, [value.slice(0, separator)]: value.slice(separator + 1) } +} + +const FLAG_PARSERS: Record< + FlagDef['type'], + ((value: string, previous: never) => unknown) | undefined +> = { + string: undefined, + integer: parseInteger, + boolean: parseBoolean, + 'string[]': parseList, + map: parseMapEntry, +} + +const FLAG_PLACEHOLDERS: Partial> = { + 'string[]': 'values', + map: 'key=value', +} + +const flagTerm = (flag: FlagDef) => { if (flag.type === 'boolean') { - return value === 'true' + return `--${flag.name} [boolean]` + } + return `--${flag.name} <${FLAG_PLACEHOLDERS[flag.type] ?? flag.type}>` +} + +const flagDescription = (flag: FlagDef) => { + const parts = [flag.description ?? ''] + if (flag.type === 'map') { + parts.push('repeatable') } if (flag.type === 'string[]') { - return value.split(',').map((s) => s.trim()) + parts.push('comma-separated or repeatable') } - return value + const desc = parts.filter(Boolean).join(', ') + return flag.required || flag.pathParam ? `${desc} (required)` : desc } const addFlags = (cmd: Command, flags: FlagDef[]) => { for (const flag of flags) { - const flagName = `--${flag.name} <${flag.type === 'string[]' ? 'values' : flag.type}>` - const desc = flag.description ?? '' - const fullDesc = flag.required ? `${desc} (required)` : desc - cmd.option(flagName, fullDesc) + const option = new Option(flagTerm(flag), flagDescription(flag)).helpGroup(PARAMETERS_GROUP) + const parser = FLAG_PARSERS[flag.type] + if (parser) { + option.argParser(parser as (value: string, previous: unknown) => unknown) + } + if (flag.deprecated) { + option.hideHelp() + } + cmd.addOption(option) } } +const addLimitOption = (cmd: Command) => + cmd.addOption( + new Option('--limit ', 'Max results to show') + .default(DEFAULT_LIST_LIMIT) + .argParser(parsePositiveInteger), + ) + +const parseFileValue = (files: FileDef[]) => { + const allowed = files.map((f) => f.field) + return (value: string, previous: Record = {}) => { + const separator = value.indexOf('=') + let field = separator > 0 ? value.slice(0, separator) : '' + let path = separator > 0 ? value.slice(separator + 1) : '' + if (files.length === 1 && !allowed.includes(field)) { + field = allowed[0] + path = value + } + if (!field || !path) { + throw new InvalidArgumentError('Expected =.') + } + if (!allowed.includes(field)) { + throw new InvalidArgumentError( + `Unknown field '${field}'. Valid fields: ${allowed.join(', ')}.`, + ) + } + if (previous[field]) { + throw new InvalidArgumentError(`Field '${field}' was given more than once.`) + } + return { ...previous, [field]: path } + } +} + +const addFileOption = (cmd: Command, files: FileDef[]) => { + if (!files.length) { + return + } + const fields = files + .map( + (f) => + `${f.field}${f.required ? ' (required)' : ''}${f.description ? `: ${f.description}` : ''}`, + ) + .join('; ') + const shorthand = files.length === 1 ? ' (or just )' : '' + cmd.addOption( + new Option( + '--file ', + `File to upload as multipart${shorthand}, repeatable. Fields: ${fields}`, + ) + .argParser(parseFileValue(files)) + .helpGroup(PARAMETERS_GROUP), + ) +} + const collectSetOptions = (cmd: Command, flags: FlagDef[]) => { const opts = cmd.opts() const result: Record = {} for (const flag of flags) { - const camelName = toCamelCase(flag.name) - if (opts[camelName] !== undefined) { - const value = parseFlagValue(String(opts[camelName]), flag) - if (flag.nestedPath) { - set(result, flag.nestedPath, value) - } else { - const snakeName = toSnakeCase(flag.name) - result[snakeName] = value + const value = opts[toCamelCase(flag.name)] + if (value === undefined) { + continue + } + if (flag.deprecated) { + if (!getRootOpts(cmd).json) { + warn(`--${flag.name} is deprecated: ${flag.deprecated}`) } + continue } + set(result, flag.nestedPath ?? toSnakeCase(flag.name), value) } return result } -const validateRequired = (cmd: Command, flags: FlagDef[], fullCliPath: string, verb: string) => { +const usageHints = (fullCliPath: string, verb: string, usage: string[]) => [ + `Usage: fintoc ${[fullCliPath, verb, ...usage].join(' ')}`, + `Help: fintoc ${fullCliPath} ${verb} --help`, +] + +const validateRequired = ( + cmd: Command, + flags: FlagDef[], + fullCliPath: string, + verb: string, + { onlyPathParams = false, args = [] as string[] } = {}, +) => { const opts = cmd.opts() - const missing = flags - .filter((f) => f.required && opts[toCamelCase(f.name)] === undefined) - .map((f) => `--${f.name}`) + const isRequired = (f: FlagDef) => f.pathParam || (!onlyPathParams && f.required) + const missing = flags.filter((f) => isRequired(f) && opts[toCamelCase(f.name)] === undefined) + + if (missing.length > 0) { + const names = missing.map((f) => `--${f.name}`) + throw new UsageError( + `Missing required ${missing.length === 1 ? 'flag' : 'flags'}: ${names.join(', ')}`, + { + code: 'missing_flag', + param: missing[0].name, + hints: usageHints(fullCliPath, verb, [ + ...args.map((a) => `<${a}>`), + ...flags.filter(isRequired).map(flagTerm), + ]), + }, + ) + } +} +const readUploadFile = (path: string): UploadFile => { + try { + return { data: readFileSync(path), filename: basename(path) } + } catch (err) { + if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { + throw new UsageError(`--file: file '${path}' not found`, { + code: 'file_not_found', + param: 'file', + }) + } + throw err + } +} + +const collectFiles = ( + paths: Record = {}, + files: FileDef[], + fullCliPath: string, + verb: string, +) => { + const missing = files.filter((f) => f.required && !paths[f.field]).map((f) => f.field) if (missing.length > 0) { - error(`Missing required ${missing.length === 1 ? 'flag' : 'flags'}: ${missing.join(', ')}`) - hint('') - hint( - ` Usage: fintoc ${fullCliPath} ${verb} ${flags - .filter((f) => f.required) - .map((f) => `--${f.name} <${f.type}>`) - .join(' ')}`, + throw new UsageError( + `Missing required ${missing.length === 1 ? 'file' : 'files'}: ${missing.join(', ')}`, + { + code: 'missing_file', + param: 'file', + hints: usageHints( + fullCliPath, + verb, + missing.map((f) => `--file ${f}=`), + ), + }, ) - hint(` Help: fintoc ${fullCliPath} ${verb} --help`) - process.exit(1) } + return Object.fromEntries( + Object.entries(paths).map(([field, path]) => [field, readUploadFile(path)]), + ) } // JWS precedence: --jws-private-key flag > config.toml -const resolveClient = (parentOpts: RootOpts, resource: ResourceDef, jwsKeyPath?: string) => { +const resolveClient = ( + parentOpts: RootOpts, + { needsJws, jwsKeyPath }: { needsJws?: boolean; jwsKeyPath?: string } = {}, +) => { const auth = resolveAuth(parentOpts) - const configJwsPath = resource.needsJws ? readConfig().jws_private_key : undefined + const configJwsPath = needsJws ? readConfig().jws_private_key : undefined const resolvedPath = jwsKeyPath ?? configJwsPath return createClient(auth.secretKey, resolvedPath) } +const isLiveMode = (rootOpts: RootOpts) => { + try { + return resolveAuth(rootOpts).secretKey.startsWith('sk_live_') + } catch { + return false + } +} + const isSerializable = (obj: unknown): obj is Serializable => typeof obj === 'object' && obj !== null && @@ -138,74 +356,158 @@ const serialize = (obj: unknown): Record => { return {} } -const registerCreate = (parent: Command, resource: ResourceDef) => { - const cmd = parent.command('create').description(`Create a new ${resource.displayName}`) - - cmd.option( - '--from-json ', - 'Read request body from a JSON file (use "-" for stdin). JSON keys must match the API body format (see https://docs.fintoc.com)', +const confirmOrAbort = async (rootOpts: RootOpts, yes: boolean | undefined, phrase: string) => { + if (yes) { + return true + } + const action = `${phrase}${isLiveMode(rootOpts) ? ' in LIVE mode' : ''}` + if (rootOpts.json || !process.stdin.isTTY) { + throw new UsageError(`Confirmation required to ${action}. Use --yes to skip.`, { + code: 'confirmation_required', + param: 'yes', + }) + } + const confirmed = await confirm( + { message: `Are you sure you want to ${action}?`, default: false }, + { output: process.stderr }, ) - if (resource.needsJws) { - cmd.option('--jws-private-key ', 'Path to JWS private key PEM file') + if (!confirmed) { + hint('Aborted.') } - addFlags(cmd, resource.createFlags ?? []) - - cmd.action(async (_opts: unknown, actionCmd: Command) => { - const rootOpts = getRootOpts(actionCmd) - const localOpts = actionCmd.opts<{ fromJson?: string; jwsPrivateKey?: string }>() + return confirmed +} - if (!localOpts.fromJson) { - validateRequired(actionCmd, resource.createFlags ?? [], resourceCliPath(resource), 'create') +const collectList = async ( + generator: AsyncIterable | Iterable, + limit: number, +) => { + const items: Record[] = [] + const iterator = (async function* () { + yield* generator + })() + while (items.length < limit) { + const { value, done } = await iterator.next() + if (done) { + return { items, hasMore: false } } + items.push(serialize(value)) + } + await iterator.return(undefined) + return { items, hasMore: true } +} - try { - const jsonBody = localOpts.fromJson ? readJsonBody(localOpts.fromJson) : {} - const flagBody = collectSetOptions(actionCmd, resource.createFlags ?? []) - const body = deepMerge(jsonBody, flagBody) +const printList = ( + rootOpts: RootOpts, + { items, hasMore }: Awaited>, + columns: string[], +) => { + if (rootOpts.json) { + printJson(items) + return + } + printTable({ columns, rows: items }) + if (hasMore) { + hint(`More results may be available. Use --limit to show more than ${items.length}.`) + } +} - const client = resolveClient(rootOpts, resource, localOpts.jwsPrivateKey) - const manager = getManager(client, resource) +const buildBody = (actionCmd: Command, flags: FlagDef[], files: Record) => { + const { fromJson, idempotencyKey } = actionCmd.opts() + const jsonBody = fromJson ? readJsonBody(fromJson) : {} + const body = deepMerge(jsonBody, collectSetOptions(actionCmd, flags)) as Record + for (const [field, file] of Object.entries(files)) { + set(body, field, file) + } + if (idempotencyKey) { + body.idempotency_key = idempotencyKey + } + return body +} - const result = await manager.create!(body) +const argDescription = (resource: ResourceDef) => + resource.getArg?.description ?? `ID of the ${resource.displayName}` + +const verbDescription = (resource: ResourceDef, verb: Verb) => { + const argName = (resource.getArg?.name ?? 'id').replace(/_/g, ' ') + return { + create: `Create a new ${resource.displayName}`, + get: `Get ${withArticle(resource.displayName)} by ${argName === 'id' ? 'ID' : argName}`, + list: `List ${pluralName(resource)}`, + update: `Update ${withArticle(resource.displayName)}`, + delete: `Delete ${withArticle(resource.displayName)}`, + }[verb] +} + +const runCommand = async ( + actionCmd: Command, + context: { cliPath: string; verb: string; id?: string; availableVerbs: string[] }, + run: (rootOpts: RootOpts) => Promise, +) => { + const rootOpts = getRootOpts(actionCmd) + try { + await run(rootOpts) + } catch (err) { + handleError(err, { ...context, json: rootOpts.json }) + } +} + +const registerCreate = (parent: Command, resource: ResourceDef) => { + const cliPath = resourceCliPath(resource) + const flags = resource.createFlags ?? [] + const cmd = parent.command('create').description(verbDescription(resource, 'create')) + + addFlags(cmd, flags) + addFileOption(cmd, resource.createFiles ?? []) + cmd.option('--from-json ', FROM_JSON_DESCRIPTION) + cmd.option('--idempotency-key ', IDEMPOTENCY_KEY_DESCRIPTION) + if (resource.needsJws) { + cmd.option('--jws-private-key ', 'Path to JWS private key PEM file') + } + + cmd.action(async (_opts: unknown, actionCmd: Command) => { + const context = { cliPath, verb: 'create', availableVerbs: resource.verbs } + await runCommand(actionCmd, context, async (rootOpts) => { + const localOpts = actionCmd.opts() + validateRequired(actionCmd, flags, cliPath, 'create', { + onlyPathParams: !!localOpts.fromJson, + }) + const files = collectFiles(localOpts.file, resource.createFiles ?? [], cliPath, 'create') + const body = buildBody(actionCmd, flags, files) + + const client = resolveClient(rootOpts, { + needsJws: resource.needsJws, + jwsKeyPath: localOpts.jwsPrivateKey, + }) + const result = await getManager(client, resource).create!(body) const data = serialize(result) if (rootOpts.json) { printJson(data) } else { - success(`${resource.displayName} created`) + success( + `${capitalize(resource.displayName)}${data.id ? ` '${String(data.id)}'` : ''} created`, + ) hint('') printDetail(data) } - } catch (err) { - handleError(err, { - cliPath: resourceCliPath(resource), - verb: 'create', - json: rootOpts.json, - availableVerbs: resource.verbs, - }) - } + }) }) } const registerGet = (parent: Command, resource: ResourceDef) => { + const cliPath = resourceCliPath(resource) const argName = resource.getArg?.name ?? 'id' - const cmd = parent - .command('get') - .description(`Get a ${resource.displayName} by ${argName.replace(/_/g, ' ')}`) - cmd.argument(`<${argName}>`, resource.getArg?.description) - addFlags(cmd, resource.getFlags ?? []) - cmd.action(async (identifier: string, _opts: unknown, actionCmd: Command) => { - const rootOpts = getRootOpts(actionCmd) - const flags = resource.getFlags ?? [] - - validateRequired(actionCmd, flags, resourceCliPath(resource), 'get') - - try { - const client = resolveClient(rootOpts, resource) - const manager = getManager(client, resource) + const flags = resource.getFlags ?? [] + const cmd = parent.command('get').description(verbDescription(resource, 'get')) + cmd.argument(`<${argName}>`, argDescription(resource)) + addFlags(cmd, flags) + cmd.action(async (identifier: string, _opts: unknown, actionCmd: Command) => { + const context = { cliPath, verb: 'get', id: identifier, availableVerbs: resource.verbs } + await runCommand(actionCmd, context, async (rootOpts) => { + validateRequired(actionCmd, flags, cliPath, 'get', { args: [argName] }) const params = flags.length ? collectSetOptions(actionCmd, flags) : undefined - const result = await manager.get!(identifier, params) + const result = await getManager(resolveClient(rootOpts), resource).get!(identifier, params) const data = serialize(result) if (rootOpts.json) { @@ -213,189 +515,231 @@ const registerGet = (parent: Command, resource: ResourceDef) => { } else { printDetail(data) } - } catch (err) { - handleError(err, { - cliPath: resourceCliPath(resource), - verb: 'get', - id: identifier, - json: rootOpts.json, - availableVerbs: resource.verbs, - }) - } + }) }) } const registerList = (parent: Command, resource: ResourceDef) => { - const cmd = parent - .command('list') - .description(`List ${resource.name.replace(/_/g, ' ')}`) - .option('--limit ', 'Max results to show', String(DEFAULT_LIST_LIMIT)) - - addFlags(cmd, resource.listFlags ?? []) + const cliPath = resourceCliPath(resource) + const flags = resource.listFlags ?? [] + const cmd = parent.command('list').description(verbDescription(resource, 'list')) + addFlags(cmd, flags) + addLimitOption(cmd) cmd.action(async (_opts: unknown, actionCmd: Command) => { - const rootOpts = getRootOpts(actionCmd) - - validateRequired(actionCmd, resource.listFlags ?? [], resourceCliPath(resource), 'list') + const context = { cliPath, verb: 'list', availableVerbs: resource.verbs } + await runCommand(actionCmd, context, async (rootOpts) => { + validateRequired(actionCmd, flags, cliPath, 'list') + const { limit } = actionCmd.opts<{ limit: number }>() + const filters = collectSetOptions(actionCmd, flags) + const manager = getManager(resolveClient(rootOpts), resource) - const localOpts = actionCmd.opts<{ limit: string }>() - const limit = Number(localOpts.limit) - if (Number.isNaN(limit) || !Number.isInteger(limit) || limit <= 0) { - error('--limit must be a positive integer') - process.exit(1) - } + const generator = (await manager.list!({ ...filters, lazy: true })) as AsyncIterable + printList(rootOpts, await collectList(generator, limit), resource.priorityColumns) + }) + }) +} - try { - const filters = collectSetOptions(actionCmd, resource.listFlags ?? []) - const client = resolveClient(rootOpts, resource) - const manager = getManager(client, resource) +const registerUpdate = (parent: Command, resource: ResourceDef) => { + const cliPath = resourceCliPath(resource) + const argName = resource.getArg?.name ?? 'id' + const flags = resource.updateFlags ?? [] + const cmd = parent + .command('update') + .description(verbDescription(resource, 'update')) + .argument(`<${argName}>`, argDescription(resource)) + addFlags(cmd, flags) + cmd.option('--from-json ', FROM_JSON_DESCRIPTION) - const generator = (await manager.list!({ ...filters, lazy: true })) as AsyncIterable - const items: Record[] = [] + cmd.action(async (identifier: string, _opts: unknown, actionCmd: Command) => { + const context = { cliPath, verb: 'update', id: identifier, availableVerbs: resource.verbs } + await runCommand(actionCmd, context, async (rootOpts) => { + const localOpts = actionCmd.opts() + validateRequired(actionCmd, flags, cliPath, 'update', { + onlyPathParams: !!localOpts.fromJson, + args: [argName], + }) - for await (const item of generator) { - items.push(serialize(item)) - if (items.length >= limit) { - break - } + const hasChanges = + !!localOpts.fromJson || + flags.some( + (f) => + !f.pathParam && !f.deprecated && actionCmd.opts()[toCamelCase(f.name)] !== undefined, + ) + if (!hasChanges) { + throw new UsageError('Nothing to update: pass at least one flag or --from-json', { + code: 'missing_flag', + hints: [`Help: fintoc ${cliPath} update --help`], + }) } + const body = buildBody(actionCmd, flags, {}) + const result = await getManager(resolveClient(rootOpts), resource).update!(identifier, body) + const data = serialize(result) + if (rootOpts.json) { - printJson(items) + printJson(data) } else { - printTable({ - columns: resource.priorityColumns, - rows: items, - }) + success(`${capitalize(resource.displayName)} '${identifier}' updated`) + hint('') + printDetail(data) } - } catch (err) { - handleError(err, { - cliPath: resourceCliPath(resource), - verb: 'list', - json: rootOpts.json, - availableVerbs: resource.verbs, - }) - } + }) }) } const registerDelete = (parent: Command, resource: ResourceDef) => { + const cliPath = resourceCliPath(resource) + const flags = resource.deleteFlags ?? [] + const argName = resource.getArg?.name ?? 'id' const cmd = parent - .command('delete ') - .description(`Delete a ${resource.displayName}`) - .option('--yes', 'Skip confirmation prompt') - cmd.action(async (id: string, opts: { yes?: boolean }, actionCmd: Command) => { - const rootOpts = getRootOpts(actionCmd) + .command('delete') + .description(verbDescription(resource, 'delete')) + .argument(`<${argName}>`, argDescription(resource)) + addFlags(cmd, flags) + cmd.option('--yes', 'Skip confirmation prompt') - if (!opts.yes) { - if (!process.stdin.isTTY) { - error(`Confirmation required to delete '${id}'. Use --yes to skip.`) - process.exit(1) - } - const confirmed = await confirm({ - message: `Are you sure you want to delete '${id}'?`, - default: false, - }) - if (!confirmed) { - hint('Aborted.') + cmd.action(async (id: string, opts: { yes?: boolean }, actionCmd: Command) => { + const context = { cliPath, verb: 'delete', id, availableVerbs: resource.verbs } + await runCommand(actionCmd, context, async (rootOpts) => { + validateRequired(actionCmd, flags, cliPath, 'delete', { args: [argName] }) + const phrase = fillTemplate("delete {resource} '{id}'", resource, id) + if (!(await confirmOrAbort(rootOpts, opts.yes, phrase))) { return } - } - try { - const client = resolveClient(rootOpts, resource) - const manager = getManager(client, resource) - - await manager.delete!(id) - success(`${resource.displayName} '${id}' deleted`) - } catch (err) { - handleError(err, { - cliPath: resourceCliPath(resource), - verb: 'delete', - id, - json: rootOpts.json, - availableVerbs: resource.verbs, - }) - } + const manager = getManager(resolveClient(rootOpts), resource) + if (flags.length) { + await manager.delete!(id, collectSetOptions(actionCmd, flags)) + } else { + await manager.delete!(id) + } + + if (rootOpts.json) { + printJson({ id, deleted: true }) + } else { + success(`${capitalize(resource.displayName)} '${id}' deleted`) + } + }) }) } -const registerExpire = (parent: Command, resource: ResourceDef) => { - const cmd = parent - .command('expire ') - .description(`Expire a ${resource.displayName}`) - .option('--yes', 'Skip confirmation prompt') - cmd.action(async (id: string, opts: { yes?: boolean }, actionCmd: Command) => { - const rootOpts = getRootOpts(actionCmd) +const actionPositional = (resource: ResourceDef, action: ActionDef) => { + const idArg = + action.idArg === false + ? undefined + : (action.idArg ?? { name: 'id', description: argDescription(resource) }) + return { idArg, positional: [...(idArg ? [idArg] : []), ...(action.extraArgs ?? [])] } +} - if (!opts.yes) { - if (!process.stdin.isTTY) { - error(`Confirmation required to expire '${id}'. Use --yes to skip.`) - process.exit(1) - } - const confirmed = await confirm({ - message: `Are you sure you want to expire '${id}'?`, - default: false, - }) - if (!confirmed) { - hint('Aborted.') - return - } - } +const registerAction = (parent: Command, resource: ResourceDef, action: ActionDef) => { + const kind = action.kind ?? 'write' + const { idArg, positional } = actionPositional(resource, action) + const flags = action.flags ?? [] + const files = action.files ?? [] + const cliPath = resourceCliPath(resource) - try { - const client = resolveClient(rootOpts, resource) - const manager = getManager(client, resource) - - await manager.expire!(id) - success(`${resource.displayName} '${id}' expired`) - } catch (err) { - handleError(err, { - cliPath: resourceCliPath(resource), - verb: 'expire', - id, - json: rootOpts.json, - availableVerbs: resource.verbs, - }) - } - }) -} + const cmd = parent.command(action.name).description(action.description) + if (toKebabCase(action.name) !== action.name) { + cmd.alias(toKebabCase(action.name)) + } + for (const arg of positional) { + cmd.argument(`<${arg.name}>`, arg.description) + } + addFlags(cmd, flags) + addFileOption(cmd, files) + if (kind === 'list') { + addLimitOption(cmd) + } + if (action.fromJson) { + cmd.option('--from-json ', FROM_JSON_DESCRIPTION) + } + if (action.needsJws) { + cmd.option('--jws-private-key ', 'Path to JWS private key PEM file') + } + if (action.idempotencyKey) { + cmd.option('--idempotency-key ', IDEMPOTENCY_KEY_DESCRIPTION) + } + if (action.confirm) { + cmd.option('--yes', 'Skip confirmation prompt') + } -const registerTest = (parent: Command, resource: ResourceDef) => { - const cmd = parent - .command('test ') - .description(`Send a test event to a ${resource.displayName}`) - addFlags(cmd, resource.testFlags ?? []) - cmd.action(async (id: string, _opts: unknown, actionCmd: Command) => { - const rootOpts = getRootOpts(actionCmd) - const flags = resource.testFlags ?? [] + cmd.action(async (...actionArgs: unknown[]) => { + const actionCmd = actionArgs.at(-1) as Command + const values = actionArgs.slice(0, positional.length) as string[] + const id = idArg ? values[0] : undefined + const context = { cliPath, verb: action.name, id, availableVerbs: resource.verbs } + + await runCommand(actionCmd, context, async (rootOpts) => { + const localOpts = actionCmd.opts() + validateRequired(actionCmd, flags, cliPath, action.name, { + onlyPathParams: !!localOpts.fromJson, + args: positional.map((a) => a.name), + }) + const uploads = collectFiles(localOpts.file, files, cliPath, action.name) + + if (action.confirm) { + const template = + action.confirmPhrase ?? `${action.name.replace(/_/g, ' ')} {resource} '{id}'` + if ( + !(await confirmOrAbort(rootOpts, localOpts.yes, fillTemplate(template, resource, id))) + ) { + return + } + } - validateRequired(actionCmd, flags, resourceCliPath(resource), 'test') + const { file: fileArg, ...bodyFiles } = uploads + const sdkArgs: unknown[] = values.filter((_, i) => !positional[i].bodyField) + const bodyArgs = Object.fromEntries( + positional.flatMap((arg, i) => (arg.bodyField ? [[arg.bodyField, values[i]]] : [])), + ) + if (action.fileAsArg) { + sdkArgs.push(fileArg) + } + const takesBody = + flags.length > 0 || + !!action.fromJson || + !!localOpts.idempotencyKey || + Object.keys(bodyArgs).length > 0 || + (!action.fileAsArg && files.length > 0) + if (takesBody) { + const body = buildBody(actionCmd, flags, action.fileAsArg ? bodyFiles : uploads) + sdkArgs.push({ ...body, ...bodyArgs }) + } - try { - const args = collectSetOptions(actionCmd, flags) - const client = resolveClient(rootOpts, resource) - const manager = getManager(client, resource) + const client = resolveClient(rootOpts, { + needsJws: action.needsJws, + jwsKeyPath: localOpts.jwsPrivateKey, + }) + const manager = getManager(client, resource) as unknown as Record + const method = manager[action.sdkMethod] + if (typeof method !== 'function') { + throw new TypeError(`SDK method "${resource.sdkMethod}.${action.sdkMethod}" not found`) + } + const result: unknown = await method.apply(manager, sdkArgs) + + if (kind === 'list') { + const columns = action.priorityColumns ?? resource.priorityColumns + printList( + rootOpts, + await collectList(result as AsyncIterable, localOpts.limit!), + columns, + ) + return + } - const result = await manager.test!(id, args) const data = serialize(result) - if (rootOpts.json) { printJson(data) - } else { - success(`Test event sent to ${resource.displayName} '${id}'`) + return + } + if (kind === 'write') { + const template = action.successMessage ?? `{resource} '{id}': ${action.name} done` + success(capitalize(fillTemplate(template, resource, id ?? (data.id as string | undefined)))) hint('') - printDetail(data) } - } catch (err) { - handleError(err, { - cliPath: resourceCliPath(resource), - verb: 'test', - id, - json: rootOpts.json, - availableVerbs: resource.verbs, - }) - } + printDetail(data) + }) }) } @@ -403,10 +747,9 @@ const verbRegistrars = { create: registerCreate, get: registerGet, list: registerList, + update: registerUpdate, delete: registerDelete, - expire: registerExpire, - test: registerTest, -} satisfies Record void> +} satisfies Record void> export const registerResourceCommands = (program: Command, resourceDefs: ResourceDef[]) => { resourceDefs.forEach((resource) => { @@ -416,14 +759,121 @@ export const registerResourceCommands = (program: Command, resourceDefs: Resourc const resourceCmd = program .command(resource.cliCommand) - .description(`Manage ${resource.displayName}s`) + .description(resource.description ?? `Manage ${pluralName(resource)}`) + if (toKebabCase(resource.cliCommand) !== resource.cliCommand) { + resourceCmd.alias(toKebabCase(resource.cliCommand)) + } resourceCmd.configureHelp({ showGlobalOptions: true }) - resource.verbs.forEach((verb) => { - verbRegistrars[verb]?.(resourceCmd, resource) - }) + resource.verbs.forEach((verb) => verbRegistrars[verb](resourceCmd, resource)) + resource.actions?.forEach((action) => registerAction(resourceCmd, resource, action)) addDefaultAction(resourceCmd) }) } + +type FlagSchema = { + name: string + type: FlagDef['type'] + required: boolean + param: string + in: 'path' | 'query' | 'body' + description?: string + deprecated?: string +} + +export type CommandSchema = { + command: string + aliases: string[] + description: string + kind: 'read' | 'list' | 'write' | 'utility' + args: ArgDef[] + flags: FlagSchema[] + files: FileDef[] + options: string[] + confirm: boolean +} + +const flagSchemas = (flags: FlagDef[], location: 'query' | 'body'): FlagSchema[] => + flags.map((f) => ({ + name: f.name, + type: f.type, + required: !!(f.required || f.pathParam), + param: f.nestedPath ?? toSnakeCase(f.name), + in: f.pathParam ? 'path' : location, + ...(f.description ? { description: f.description } : {}), + ...(f.deprecated ? { deprecated: f.deprecated } : {}), + })) + +const commandNames = (resource: ResourceDef, name: string) => { + const command = `fintoc ${resourceCliPath(resource)} ${name}` + const kebab = toKebabCase(command) + return { command, aliases: kebab === command ? [] : [kebab] } +} + +const verbSchema = (resource: ResourceDef, verb: Verb): CommandSchema => { + const argName = resource.getArg?.name ?? 'id' + const flags = { + create: resource.createFlags, + get: resource.getFlags, + list: resource.listFlags, + update: resource.updateFlags, + delete: resource.deleteFlags, + }[verb] + const options = { + create: [ + '--from-json', + '--idempotency-key', + ...(resource.needsJws ? ['--jws-private-key'] : []), + ], + get: [], + list: ['--limit'], + update: ['--from-json'], + delete: ['--yes'], + }[verb] + return { + ...commandNames(resource, verb), + description: verbDescription(resource, verb), + kind: verb === 'get' ? 'read' : verb === 'list' ? 'list' : 'write', + args: + verb === 'create' || verb === 'list' + ? [] + : [ + { + name: argName, + ...(resource.getArg ? { description: resource.getArg.description } : {}), + }, + ], + flags: flagSchemas(flags ?? [], verb === 'create' || verb === 'update' ? 'body' : 'query'), + files: verb === 'create' ? (resource.createFiles ?? []) : [], + options, + confirm: verb === 'delete', + } +} + +const actionSchema = (resource: ResourceDef, action: ActionDef): CommandSchema => { + const kind = action.kind ?? 'write' + return { + ...commandNames(resource, action.name), + description: action.description, + kind, + args: actionPositional(resource, action).positional, + flags: flagSchemas(action.flags ?? [], kind === 'write' ? 'body' : 'query'), + files: action.files ?? [], + options: [ + ...(kind === 'list' ? ['--limit'] : []), + ...(action.fromJson ? ['--from-json'] : []), + ...(action.idempotencyKey ? ['--idempotency-key'] : []), + ...(action.needsJws ? ['--jws-private-key'] : []), + ...(action.confirm ? ['--yes'] : []), + ], + confirm: !!action.confirm, + } +} + +export const describeCommands = (resourceDefs: ResourceDef[]): CommandSchema[] => + resourceDefs.flatMap((resource) => [ + ...resource.verbs.map((verb) => verbSchema(resource, verb)), + ...(resource.actions ?? []).map((action) => actionSchema(resource, action)), + ]) diff --git a/src/resources/registry.ts b/src/resources/registry.ts index dcbcc6d..75d69b6 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -137,7 +137,7 @@ export const resources: ResourceDef[] = [ cliCommand: 'webhook_endpoints', sdkMethod: 'webhookEndpoints', sdkNamespace: 'v1', - verbs: ['create', 'get', 'list', 'delete', 'test'], + verbs: ['create', 'get', 'list', 'delete'], priorityColumns: ['id', 'name', 'url', 'status', 'created_at'], createFlags: [ { @@ -155,12 +155,20 @@ export const resources: ResourceDef[] = [ { name: 'description', type: 'string', description: 'Description of the webhook endpoint' }, ], listFlags: [], - testFlags: [ - { - name: 'type', - type: 'string', - required: true, - description: 'Event type to test (e.g., payment_intent.succeeded)', + actions: [ + { + name: 'test', + sdkMethod: 'test', + description: 'Send a test event to a webhook endpoint', + successMessage: "Test event sent to {resource} '{id}'", + flags: [ + { + name: 'type', + type: 'string', + required: true, + description: 'Event type to test (e.g., payment_intent.succeeded)', + }, + ], }, ], }, @@ -269,7 +277,16 @@ export const resources: ResourceDef[] = [ cliCommand: 'checkout_sessions', sdkMethod: 'checkoutSessions', sdkNamespace: 'v1', - verbs: ['create', 'get', 'expire'], + verbs: ['create', 'get'], + actions: [ + { + name: 'expire', + sdkMethod: 'expire', + description: 'Expire a checkout session', + confirm: true, + successMessage: "{resource} '{id}' expired", + }, + ], priorityColumns: ['id', 'amount', 'currency', 'status', 'created_at'], createFlags: [ { diff --git a/src/types.ts b/src/types.ts index 89acb04..0fea428 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { Buffer } from 'node:buffer' + export type FintocMode = 'test' | 'live' export type FintocConfig = { @@ -8,9 +10,9 @@ export type FintocConfig = { color?: boolean } -export type Verb = 'create' | 'get' | 'list' | 'delete' | 'expire' | 'test' +export type Verb = 'create' | 'get' | 'list' | 'update' | 'delete' -export type FlagType = 'string' | 'integer' | 'boolean' | 'string[]' +export type FlagType = 'string' | 'integer' | 'boolean' | 'string[]' | 'map' export type FlagDef = { name: string @@ -18,11 +20,57 @@ export type FlagDef = { required?: boolean description?: string nestedPath?: string + // Fills a path placeholder of a nested manager (e.g. {entity_id}). Always required, even with --from-json + pathParam?: boolean + // Hidden from help and not sent; using it prints this message as a warning + deprecated?: string +} + +// A multipart file field, passed as `--file =`. `field` is the dotted body path +export type FileDef = { + field: string + required?: boolean + description?: string +} + +export type ArgDef = { + name: string + description?: string + // Sends the value in the request body under this key instead of as an SDK argument + bodyField?: string +} + +export type ActionDef = { + name: string + sdkMethod: string + description: string + // read: GET that returns an object. list: GET that returns a paginated list. write: anything else + kind?: 'read' | 'list' | 'write' + // Positional id passed as the first SDK argument. `false` when the SDK method takes no id + idArg?: ArgDef | false + extraArgs?: ArgDef[] + flags?: FlagDef[] + fromJson?: boolean + files?: FileDef[] + // Pass the `file` field as a positional SDK argument (after extraArgs) instead of inside the body + fileAsArg?: boolean + confirm?: boolean + // Confirmation phrase with {resource} and {id} placeholders. Defaults to " {resource} '{id}'" + confirmPhrase?: string + // Only for POST actions: the SDK sends it as the Idempotency-Key header + idempotencyKey?: boolean + needsJws?: boolean + // Supports {resource} and {id} placeholders + successMessage?: string + priorityColumns?: string[] } export type ResourceDef = { name: string displayName: string + pluralDisplayName?: string + // Defaults to "Manage " + description?: string cliCommand: string sdkMethod: string sdkNamespace: 'v1' | 'v2' @@ -30,19 +78,27 @@ export type ResourceDef = { needsJws?: boolean priorityColumns: string[] createFlags?: FlagDef[] + createFiles?: FileDef[] listFlags?: FlagDef[] getFlags?: FlagDef[] - testFlags?: FlagDef[] + updateFlags?: FlagDef[] + deleteFlags?: FlagDef[] getArg?: { name: string; description: string } + actions?: ActionDef[] } export type SdkManager = { create?: (body: Record) => Promise get?: (id: string, params?: Record) => Promise list?: (params: Record) => Promise - delete?: (id: string) => Promise - expire?: (id: string) => Promise - test?: (id: string, args: Record) => Promise + update?: (id: string, body: Record) => Promise + delete?: (id: string, params?: Record) => Promise +} + +export type UploadFile = { + data: Buffer + filename: string + contentType?: string } export type Serializable = { From 1a2b0990243bfe1867b57a9cbf6c4471e5451735 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 12:12:46 -0300 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20completar=20recursos=20y=20par?= =?UTF-8?q?=C3=A1metros=20de=20la=20API=20p=C3=BAblica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/factory-actions.test.ts | 101 + src/resources/__tests__/registry.test.ts | 487 ++- src/resources/registry.ts | 2922 ++++++++++++++++- 3 files changed, 3398 insertions(+), 112 deletions(-) diff --git a/src/resources/__tests__/factory-actions.test.ts b/src/resources/__tests__/factory-actions.test.ts index 9c93adf..e579236 100644 --- a/src/resources/__tests__/factory-actions.test.ts +++ b/src/resources/__tests__/factory-actions.test.ts @@ -8,6 +8,7 @@ import { createClient, resolveAuth } from '../../lib/auth.js' import { readConfig } from '../../lib/config.js' import { error, hint, printDetail, printJson, printTable, success, warn } from '../../lib/output.js' import { describeCommands, registerResourceCommands } from '../factory.js' +import { resources } from '../registry.js' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -114,6 +115,106 @@ describe('factory: update, actions and files', () => { vi.restoreAllMocks() }) + describe('public API parameter mappings', () => { + const resource = (name: string, namespace: 'v1' | 'v2'): ResourceDef => ({ + ...resources.find((r) => r.name === name && r.sdkNamespace === namespace)!, + sdkNamespace: 'v1', + sdkMethod: 'widgets', + }) + + test('cursor and page size options reach the SDK without changing the output limit', async () => { + mockManager.list.mockResolvedValue(asyncGenerator([{ id: 'a' }, { id: 'b' }])) + await run(resource('accounts', 'v2'), [ + 'accounts', + 'list', + '--starting-after', + 'acc_1', + '--page-size', + '30', + '--limit', + '1', + '--json', + ]) + expect(mockManager.list).toHaveBeenCalledWith({ + starting_after: 'acc_1', + limit: 30, + lazy: true, + }) + expect(printJson).toHaveBeenCalledWith([{ id: 'a' }]) + }) + + test('v1 page and per-page are sent as query parameters', async () => { + mockManager.list.mockResolvedValue(asyncGenerator([])) + await run(resource('payment_intents', 'v1'), [ + 'payment_intents', + 'list', + '--page', + '2', + '--per-page', + '20', + ]) + expect(mockManager.list).toHaveBeenCalledWith({ page: 2, per_page: 20, lazy: true }) + }) + + test('checkout restrictions and channels keep their nested API paths', async () => { + mockManager.create.mockResolvedValue(result({ id: 'cs_1' })) + await run(resource('checkout_sessions', 'v2'), [ + 'checkout_sessions', + 'create', + '--currency', + 'CLP', + '--cash-channels', + 'email,phone', + '--bank-transfer-sender-account-holder-id-value', + '11.111.111-1', + '--bank-transfer-sender-account-holder-id-editable', + 'false', + '--pac-sender-account-types', + 'checking_account', + ]) + expect(mockManager.create).toHaveBeenCalledWith({ + currency: 'CLP', + payment_method_options: { + cash: { notification_preferences: { channels: ['email', 'phone'] } }, + bank_transfer: { + sender_account: { holder_id: { value: '11.111.111-1', editable: false } }, + }, + pac: { sender_account: { types: ['checking_account'] } }, + }, + }) + }) + + test('onboarding flags merge with complex answers from JSON', async () => { + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ data: { shareholders: [{ name: 'Jane' }] } }), + ) + mockManager.create.mockResolvedValue(result({ id: 'onb_1' })) + await run(resource('onboardings', 'v2'), [ + 'onboardings', + 'create', + '--entity-id', + 'ent_1', + '--type', + 'account_holder', + '--plaft-uaf-obligated', + 'false', + '--transactional-profile-equity-origins', + 'retained_earnings', + '--from-json', + '/tmp/body.json', + ]) + expect(mockManager.create).toHaveBeenCalledWith({ + entity_id: 'ent_1', + type: 'account_holder', + data: { + shareholders: [{ name: 'Jane' }], + plaft: { uaf_obligated: false }, + transactional_profile: { equity_origins: ['retained_earnings'] }, + }, + }) + }) + }) + describe('update command', () => { const resource: ResourceDef = { ...baseResource, diff --git a/src/resources/__tests__/registry.test.ts b/src/resources/__tests__/registry.test.ts index 0586477..531f057 100644 --- a/src/resources/__tests__/registry.test.ts +++ b/src/resources/__tests__/registry.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from 'vitest' import { resources, v1Resources, v2Resources } from '../registry.js' +const find = (name: string, namespace: 'v1' | 'v2') => + resources.find((r) => r.name === name && r.sdkNamespace === namespace)! + describe('resource registry', () => { describe('schema validation', () => { - test('contains all 12 resources', () => { - expect(resources).toHaveLength(12) + test('contains all 42 resources', () => { + expect(resources).toHaveLength(42) const names = resources.map((r) => r.name) expect(names).toEqual([ @@ -20,23 +23,58 @@ describe('resource registry', () => { 'account_numbers', 'movements', 'api_keys', + 'refunds', + 'subscription_intents', + 'payment_links', + 'disputes', + 'accounts', + 'movements', + 'invoices', + 'tax_returns', + 'refresh_intents', + 'checkout_sessions', + 'customers', + 'entities', + 'onboardings', + 'payment_intents', + 'payment_methods', + 'products', + 'subscriptions', + 'invoices', + 'account_statements', + 'recipients', + 'transfer_batches', + 'payouts', + 'institutions', + 'link_intents', + 'tax_statements', + 'events', + 'setup_intents', + 'subscription_items', + 'invoice_lines', + 'simulate', ]) }) + test('names are unique within each namespace', () => { + const keys = resources.map((r) => `${r.sdkNamespace}:${r.name}`) + expect(new Set(keys).size).toBe(keys.length) + }) + test('every resource has required fields', () => { for (const resource of resources) { expect(resource.name).toBeTruthy() expect(resource.cliCommand).toBeTruthy() expect(resource.sdkMethod).toBeTruthy() expect(['v1', 'v2']).toContain(resource.sdkNamespace) - expect(resource.verbs.length).toBeGreaterThan(0) + expect(resource.verbs.length + (resource.actions?.length ?? 0)).toBeGreaterThan(0) expect(resource.priorityColumns.length).toBeGreaterThanOrEqual(1) expect(resource.priorityColumns.length).toBeLessThanOrEqual(6) } }) test('verbs are valid', () => { - const validVerbs = new Set(['create', 'get', 'list', 'delete', 'expire', 'test']) + const validVerbs = new Set(['create', 'get', 'list', 'update', 'delete']) resources.forEach((resource) => { resource.verbs.forEach((verb) => { expect(validVerbs.has(verb)).toBe(true) @@ -54,7 +92,7 @@ describe('resource registry', () => { }) test('all flag types are valid', () => { - const validTypes = new Set(['string', 'integer', 'boolean', 'string[]']) + const validTypes = new Set(['string', 'integer', 'boolean', 'string[]', 'map']) for (const resource of resources) { for (const flag of resource.createFlags ?? []) { expect(validTypes.has(flag.type)).toBe(true) @@ -65,39 +103,83 @@ describe('resource registry', () => { for (const flag of resource.getFlags ?? []) { expect(validTypes.has(flag.type)).toBe(true) } - for (const flag of resource.actions?.flatMap((a) => a.flags ?? []) ?? []) { + for (const flag of [ + ...(resource.updateFlags ?? []), + ...(resource.deleteFlags ?? []), + ...(resource.actions ?? []).flatMap((a) => a.flags ?? []), + ]) { expect(validTypes.has(flag.type)).toBe(true) } } }) }) + describe('flag names', () => { + test('are unique within each command', () => { + for (const resource of resources) { + const groups = [ + resource.createFlags, + resource.listFlags, + resource.getFlags, + resource.updateFlags, + resource.deleteFlags, + ...(resource.actions ?? []).map((a) => a.flags), + ] + for (const flags of groups) { + const names = (flags ?? []).map((f) => f.name) + expect(new Set(names).size, `${resource.sdkNamespace} ${resource.name}`).toBe( + names.length, + ) + } + } + }) + + test('never start with no-, which commander reads as a negated boolean', () => { + const allFlags = resources.flatMap((r) => [ + ...(r.createFlags ?? []), + ...(r.listFlags ?? []), + ...(r.getFlags ?? []), + ...(r.updateFlags ?? []), + ...(r.deleteFlags ?? []), + ...(r.actions ?? []).flatMap((a) => a.flags ?? []), + ]) + expect(allFlags.filter((f) => f.name.startsWith('no-')).map((f) => f.name)).toEqual([]) + }) + }) + describe('v2 resources', () => { test('transfers and accounts use v2 namespace', () => { - const transfers = resources.find((r) => r.name === 'transfers')! - const accounts = resources.find((r) => r.name === 'accounts')! + const transfers = find('transfers', 'v2') + const accounts = find('accounts', 'v2') expect(transfers.sdkNamespace).toBe('v2') expect(accounts.sdkNamespace).toBe('v2') }) - test('all other resources use v1 namespace', () => { - const v2Names = new Set([ - 'transfers', + test('v1Resources contains only v1 namespace resources', () => { + expect(v1Resources.every((r) => r.sdkNamespace === 'v1')).toBe(true) + expect(v1Resources.map((r) => r.name)).toEqual([ + 'payment_intents', + 'webhook_endpoints', + 'charges', + 'subscriptions', + 'links', + 'checkout_sessions', + 'api_keys', + 'refunds', + 'subscription_intents', + 'payment_links', + 'disputes', 'accounts', - 'account_verifications', - 'account_numbers', 'movements', + 'invoices', + 'tax_returns', + 'refresh_intents', + 'payouts', + 'institutions', + 'link_intents', + 'tax_statements', ]) - const nonV2 = resources.filter((r) => !v2Names.has(r.name)) - for (const resource of nonV2) { - expect(resource.sdkNamespace).toBe('v1') - } - }) - - test('v1Resources contains only v1 namespace resources', () => { - expect(v1Resources.every((r) => r.sdkNamespace === 'v1')).toBe(true) - expect(v1Resources).toHaveLength(7) }) test('v2Resources contains only v2 namespace resources', () => { @@ -108,101 +190,394 @@ describe('resource registry', () => { 'account_verifications', 'account_numbers', 'movements', + 'checkout_sessions', + 'customers', + 'entities', + 'onboardings', + 'payment_intents', + 'payment_methods', + 'products', + 'subscriptions', + 'invoices', + 'account_statements', + 'recipients', + 'transfer_batches', + 'events', + 'setup_intents', + 'subscription_items', + 'invoice_lines', + 'simulate', ]) }) }) describe('resource-specific verbs', () => { test('api_keys only supports list', () => { - const apiKeys = resources.find((r) => r.name === 'api_keys')! + const apiKeys = find('api_keys', 'v1') expect(apiKeys.verbs).toEqual(['list']) }) test('webhook_endpoints and links support delete', () => { - const webhooks = resources.find((r) => r.name === 'webhook_endpoints')! - const links = resources.find((r) => r.name === 'links')! + const webhooks = find('webhook_endpoints', 'v1') + const links = find('links', 'v1') expect(webhooks.verbs).toContain('delete') expect(links.verbs).toContain('delete') }) - test('subscriptions and accounts are read-only (get + list)', () => { - const subscriptions = resources.find((r) => r.name === 'subscriptions')! - const accounts = resources.find((r) => r.name === 'accounts')! + test('v1 subscriptions and v1 accounts are read-only (get + list)', () => { + expect(find('subscriptions', 'v1').verbs).toEqual(['get', 'list']) + expect(find('accounts', 'v1').verbs).toEqual(['get', 'list']) + }) - expect(subscriptions.verbs).toEqual(['get', 'list']) - expect(accounts.verbs).toEqual(['get', 'list']) + test('v2 accounts supports create and update', () => { + expect(find('accounts', 'v2').verbs).toEqual(['create', 'get', 'list', 'update']) + }) + + test.each([ + ['payment_intents', 'v1'], + ['checkout_sessions', 'v1'], + ['checkout_sessions', 'v2'], + ] as const)('%s (%s) declares expire as a confirmed action', (name, namespace) => { + const resource = find(name, namespace) + expect(resource.verbs).not.toContain('expire') + const expire = resource.actions!.find((a) => a.name === 'expire')! + expect(expire.sdkMethod).toBe('expire') + expect(expire.confirm).toBe(true) + }) + + test('read-only resources expose only their SDK verbs', () => { + expect(find('payouts', 'v1').verbs).toEqual(['get', 'list']) + expect(find('institutions', 'v1').verbs).toEqual(['list']) + expect(find('link_intents', 'v1').verbs).toEqual(['create']) + expect(find('events', 'v2').verbs).toEqual(['get', 'list']) + expect(find('setup_intents', 'v2').verbs).toEqual(['get']) + }) + + test('recipients support delete', () => { + expect(find('recipients', 'v2').verbs).toContain('delete') }) }) describe('resource-specific flags', () => { test('checkout_sessions requires amount and currency', () => { - const checkoutSessions = resources.find((r) => r.name === 'checkout_sessions')! - const requiredFlags = checkoutSessions.createFlags!.filter((f) => f.required) + const checkoutSessions = find('checkout_sessions', 'v1') + const requiredFlags = checkoutSessions.createFlags!.filter((f) => f.required || f.pathParam) expect(requiredFlags.map((f) => f.name)).toEqual(['amount', 'currency']) }) - test('transfers requires amount, currency, account-id, counterparty-account-number, and counterparty-institution-id', () => { - const transfers = resources.find((r) => r.name === 'transfers')! - const requiredFlags = transfers.createFlags!.filter((f) => f.required) + test('transfers requires amount, currency, account-id and counterparty-account-number', () => { + const transfers = find('transfers', 'v2') + const requiredFlags = transfers.createFlags!.filter((f) => f.required || f.pathParam) expect(requiredFlags.map((f) => f.name)).toEqual([ 'amount', 'currency', 'account-id', 'counterparty-account-number', - 'counterparty-institution-id', ]) }) test('webhook_endpoints requires url and enabled-events', () => { - const webhooks = resources.find((r) => r.name === 'webhook_endpoints')! - const requiredFlags = webhooks.createFlags!.filter((f) => f.required) + const webhooks = find('webhook_endpoints', 'v1') + const requiredFlags = webhooks.createFlags!.filter((f) => f.required || f.pathParam) expect(requiredFlags.map((f) => f.name)).toEqual(['url', 'enabled-events']) }) test('webhook_endpoints enabled-events is string[] type', () => { - const webhooks = resources.find((r) => r.name === 'webhook_endpoints')! + const webhooks = find('webhook_endpoints', 'v1') const eventsFlag = webhooks.createFlags!.find((f) => f.name === 'enabled-events')! expect(eventsFlag.type).toBe('string[]') }) test('webhook_endpoints supports test action with required type flag', () => { - const webhooks = resources.find((r) => r.name === 'webhook_endpoints')! - expect(webhooks.actions?.map((a) => a.name)).toContain('test') - const requiredTestFlags = webhooks - .actions!.find((a) => a.name === 'test')! - .flags!.filter((f) => f.required) + const webhooks = find('webhook_endpoints', 'v1') + const testAction = webhooks.actions!.find((a) => a.name === 'test')! + expect(testAction.sdkMethod).toBe('test') + const requiredTestFlags = testAction + .flags!.filter((f) => f.required || f.pathParam) .map((f) => f.name) expect(requiredTestFlags).toEqual(['type']) }) + test('refresh_intents create requires link-token', () => { + const refreshIntents = find('refresh_intents', 'v1') + expect(refreshIntents.verbs).toContain('create') + const requiredFlags = refreshIntents.createFlags!.filter((f) => f.required || f.pathParam) + expect(requiredFlags.map((f) => f.name)).toEqual(['link-token']) + }) + + test('disputes create_document uploads the file inside the body', () => { + const disputes = find('disputes', 'v1') + const createDocument = disputes.actions!.find((a) => a.name === 'create_document')! + expect(createDocument.sdkMethod).toBe('createDocument') + expect(createDocument.fileAsArg).toBeUndefined() + expect(createDocument.files).toEqual([ + expect.objectContaining({ field: 'file', required: true }), + ]) + }) + test('account_verifications requires account-number and needs JWS', () => { - const av = resources.find((r) => r.name === 'account_verifications')! + const av = find('account_verifications', 'v2') expect(av.needsJws).toBe(true) - const requiredFlags = av.createFlags!.filter((f) => f.required) + const requiredFlags = av.createFlags!.filter((f) => f.required || f.pathParam) expect(requiredFlags.map((f) => f.name)).toEqual(['account-number']) }) test('account_numbers requires account-id for create', () => { - const an = resources.find((r) => r.name === 'account_numbers')! - const requiredFlags = an.createFlags!.filter((f) => f.required) + const an = find('account_numbers', 'v2') + const requiredFlags = an.createFlags!.filter((f) => f.required || f.pathParam) expect(requiredFlags.map((f) => f.name)).toEqual(['account-id']) }) test('movements requires account-id for get and list', () => { - const movements = resources.find((r) => r.name === 'movements')! + const movements = find('movements', 'v2') expect(movements.getFlags).toBeDefined() - expect(movements.getFlags!.filter((f) => f.required).map((f) => f.name)).toEqual([ - 'account-id', - ]) - expect(movements.listFlags!.filter((f) => f.required).map((f) => f.name)).toEqual([ - 'account-id', - ]) + expect( + movements.getFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['account-id']) + expect( + movements.listFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['account-id']) }) test('movements uses nested sdkMethod path', () => { - const movements = resources.find((r) => r.name === 'movements')! + const movements = find('movements', 'v2') expect(movements.sdkMethod).toBe('accounts.movements') }) + + test.each([ + ['payment_intents', 'v1', ['amount', 'currency']], + ['accounts', 'v2', ['entity-id']], + ['refunds', 'v1', ['resource-id', 'resource-type']], + ['payment_links', 'v1', ['amount', 'currency']], + ['checkout_sessions', 'v2', ['currency']], + ['entities', 'v2', ['country-code', 'holder-name']], + ['payment_intents', 'v2', ['amount', 'currency', 'payment-method']], + ['products', 'v2', ['name']], + ['recipients', 'v2', ['entity-id', 'account-number', 'holder-name']], + ['link_intents', 'v1', ['country', 'holder-type', 'product']], + ] as const)('%s (%s) required create flags', (name, namespace, expected) => { + const resource = find(name, namespace) + expect( + resource.createFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(expected) + }) + + test.each(['accounts', 'invoices', 'tax_returns', 'tax_statements', 'refresh_intents'])( + 'v1 %s requires link-token for get and list', + (name) => { + const resource = find(name, 'v1') + expect( + resource.getFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['link-token']) + expect( + resource.listFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['link-token']) + }, + ) + + test('v1 movements requires account-id and link-token for get and list', () => { + const movements = find('movements', 'v1') + expect(movements.sdkMethod).toBe('accounts.movements') + expect( + movements.getFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['account-id', 'link-token']) + expect( + movements.listFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['account-id', 'link-token']) + }) + + test('onboardings uses nested sdkMethod and requires entity-id', () => { + const onboardings = find('onboardings', 'v2') + expect(onboardings.sdkMethod).toBe('entities.onboardings') + expect( + onboardings.getFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['entity-id']) + expect( + onboardings.listFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['entity-id']) + }) + + test('account_statements uses nested sdkMethod, list only, requires account-id', () => { + const statements = find('account_statements', 'v2') + expect(statements.sdkMethod).toBe('accounts.accountStatements') + expect(statements.verbs).toEqual(['list']) + expect( + statements.listFlags!.filter((f) => f.required || f.pathParam).map((f) => f.name), + ).toEqual(['account-id']) + }) + }) + + describe('actions', () => { + const allActions = resources.flatMap((r) => + (r.actions ?? []).map((a) => ({ resource: r, action: a })), + ) + + test('action names are unique per resource and do not clash with verbs', () => { + for (const resource of resources) { + const names = (resource.actions ?? []).map((a) => a.name) + expect(new Set(names).size).toBe(names.length) + for (const name of names) { + expect(resource.verbs).not.toContain(name) + } + } + }) + + test('destructive actions require confirmation', () => { + const destructive = new Set([ + 'cancel', + 'expire', + 'void', + 'pay', + 'finalize', + 'mark_uncollectible', + 'remove_lines', + 'detach_payment_method', + 'delete_document', + 'return', + 'submit_for_review', + 'submit', + 'upload_document', + 'upload_shareholder_document', + 'upload_legal_representative_document', + ]) + for (const { action } of allActions.filter(({ action }) => destructive.has(action.name))) { + expect(action.confirm).toBe(true) + } + }) + + test('upload actions take the file as a positional SDK argument', () => { + const uploads = find('onboardings', 'v2').actions!.filter((a) => a.files?.length) + expect(uploads.map((a) => a.name)).toEqual([ + 'upload_document', + 'add_document', + 'upload_shareholder_document', + 'upload_legal_representative_document', + ]) + for (const action of uploads) { + expect(action.fileAsArg).toBe(true) + expect(action.files).toEqual([expect.objectContaining({ field: 'file', required: true })]) + } + }) + + test('transfers return needs JWS and sends its positional id in the body', () => { + const returnAction = find('transfers', 'v2').actions!.find((a) => a.name === 'return')! + expect(returnAction.needsJws).toBe(true) + expect(returnAction.idArg).toMatchObject({ name: 'transfer_id', bodyField: 'transfer_id' }) + }) + + test('nested write resources mark their parent id as a path param', () => { + for (const [name, flag] of [ + ['subscription_items', 'subscription-id'], + ['invoice_lines', 'invoice-id'], + ] as const) { + const resource = find(name, 'v2') + for (const flags of [resource.createFlags, resource.updateFlags, resource.deleteFlags]) { + if (flags) { + expect(flags.find((f) => f.name === flag)?.pathParam).toBe(true) + } + } + } + }) + + test('payment_methods create requires the four consent documents as files', () => { + const files = find('payment_methods', 'v2').createFiles! + expect(files.map((f) => f.field)).toEqual([ + 'mx_direct_debit.consent_documents.contract', + 'mx_direct_debit.consent_documents.selfie', + 'mx_direct_debit.consent_documents.id_front', + 'mx_direct_debit.consent_documents.id_back', + ]) + expect(files.every((f) => f.required)).toBe(true) + }) + }) +}) + +const allFlags = resources.flatMap((r) => { + const where = `${r.sdkNamespace} ${r.cliCommand}` + const groups = { + create: r.createFlags, + get: r.getFlags, + list: r.listFlags, + update: r.updateFlags, + delete: r.deleteFlags, + } + return [ + ...Object.entries(groups).flatMap(([verb, flags]) => + (flags ?? []).map((flag) => ({ flag, where: `${where} ${verb}` })), + ), + ...(r.actions ?? []).flatMap((a) => + (a.flags ?? []).map((flag) => ({ flag, where: `${where} ${a.name}` })), + ), + ] +}) + +const SHARED_FLAGS = [ + 'recipient-account-holder-id', + 'recipient-account-institution-id', + 'recipient-account-number', + 'recipient-account-type', + 'sender-account-holder-id', + 'sender-account-institution-id', + 'business-profile-tax-id', + 'business-profile-name', + 'business-profile-category', + 'customer-email', + 'collection-method', + 'mx-direct-debit-interval', + 'mx-direct-debit-max-amount', + 'options-min-amount', + 'options-max-amount', + 'metadata', +] + +describe('style', () => { + test('flag descriptions follow the style guide', () => { + const violations = allFlags + .filter(({ flag }) => !flag.deprecated) + .flatMap(({ flag, where }) => { + const d = flag.description ?? '' + const problems = [ + !/^[A-Z]/.test(d) && 'starts lowercase', + /\.$/.test(d) && 'ends with a period', + /true or false/.test(d) && 'repeats "true or false"', + d.startsWith('New ') && 'starts with "New"', + /(? `${where} --${flag.name}: ${p}`) + }) + expect(violations).toEqual([]) + }) + + test.each(SHARED_FLAGS)('--%s has the same description everywhere', (name) => { + const descriptions = new Set( + allFlags.filter(({ flag }) => flag.name === name).map(({ flag }) => flag.description), + ) + expect([...descriptions]).toHaveLength(1) + }) + + test('write actions have a success message and positionals a description', () => { + const problems = resources.flatMap((r) => + (r.actions ?? []).flatMap((a) => [ + ...((a.kind ?? 'write') === 'write' && !a.successMessage + ? [`${r.cliCommand} ${a.name}: missing successMessage`] + : []), + ...[...(a.idArg ? [a.idArg] : []), ...(a.extraArgs ?? [])] + .filter((arg) => !arg.description) + .map((arg) => `${r.cliCommand} ${a.name} <${arg.name}>: missing description`), + ]), + ) + expect(problems).toEqual([]) + }) + + test('command descriptions are sentence case without a trailing period', () => { + const descriptions = resources.flatMap((r) => [ + ...(r.description ? [r.description] : []), + ...(r.actions ?? []).map((a) => a.description), + ]) + expect(descriptions.filter((d) => !/^[A-Z]/.test(d) || /\.$/.test(d))).toEqual([]) }) }) diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 75d69b6..ce98b12 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -1,24 +1,150 @@ -import type { ResourceDef } from '../types.js' +import type { FlagDef, ResourceDef } from '../types.js' import { DOCS_ACCOUNT_NUMBERS_URL, DOCS_ACCOUNT_VERIFICATIONS_URL, + DOCS_ACCOUNTS_URL, DOCS_CHARGES_URL, + DOCS_CHECKOUT_SESSIONS_URL, + DOCS_DISPUTES_URL, + DOCS_ENTITIES_URL, + DOCS_EVENTS_URL, + DOCS_FISCAL_INVOICES_URL, DOCS_LINKS_URL, DOCS_MOVEMENTS_URL, DOCS_PAYMENT_INTENTS_URL, + DOCS_PAYOUTS_URL, + DOCS_RECIPIENTS_URL, + DOCS_REFUNDS_URL, + DOCS_TRANSFER_BATCHES_URL, DOCS_TRANSFERS_OBJECT_URL, } from '../lib/constants.js' +const PAGE_FLAGS: FlagDef[] = [ + { name: 'page', type: 'integer', description: 'Page number to start from' }, + { name: 'per-page', type: 'integer', description: 'Maximum results per API page' }, +] + +const CURSOR_FLAGS: FlagDef[] = [ + { name: 'starting-after', type: 'string', description: 'Return results after this resource ID' }, + { name: 'ending-before', type: 'string', description: 'Return results before this resource ID' }, + { + name: 'page-size', + type: 'integer', + nestedPath: 'limit', + description: 'Maximum results per API page', + }, +] + export const resources: ResourceDef[] = [ { name: 'payment_intents', displayName: 'payment intent', + description: 'Manage payment intents (API v1)', cliCommand: 'payment_intents', sdkMethod: 'paymentIntents', sdkNamespace: 'v1', - verbs: ['get', 'list'], + verbs: ['create', 'get', 'list'], priorityColumns: ['id', 'amount', 'currency', 'status', 'created_at'], + createFlags: [ + { + name: 'cash-channels', + type: 'string[]', + nestedPath: 'payment_type_options.cash.notification_preferences.channels', + description: 'Channels for cash payment notifications (email, phone)', + }, + { + name: 'amount', + type: 'integer', + required: true, + description: 'Amount to pay in the smallest currency unit', + }, + { + name: 'currency', + type: 'string', + required: true, + description: 'Currency code (CLP, MXN)', + }, + { + name: 'payment-type', + type: 'string', + description: 'Payment type (bank_transfer, cash)', + }, + { + name: 'customer-email', + type: 'string', + description: 'Customer email to send notifications and receipts to', + }, + { + name: 'customer-phone', + type: 'string', + description: 'Customer phone number in E.164 format', + }, + { + name: 'expires-at', + type: 'string', + description: 'When the payment expires (ISO 8601 datetime, UTC)', + }, + { + name: 'reference-number', + type: 'string', + description: 'Numeric reference attached to the SPEI transfer (Mexico only)', + }, + { + name: 'recipient-account-holder-id', + type: 'string', + description: 'Recipient account holder tax ID (e.g., RUT in Chile)', + nestedPath: 'recipient_account.holder_id', + }, + { + name: 'recipient-account-number', + type: 'string', + description: 'Recipient account number', + nestedPath: 'recipient_account.number', + }, + { + name: 'recipient-account-type', + type: 'string', + description: 'Recipient account type (e.g., checking_account, sight_account)', + nestedPath: 'recipient_account.type', + }, + { + name: 'recipient-account-institution-id', + type: 'string', + description: 'Recipient bank institution ID (e.g., cl_banco_estado)', + nestedPath: 'recipient_account.institution_id', + }, + { + name: 'sender-account-holder-id', + type: 'string', + description: 'Sender account holder tax ID (e.g., RUT in Chile)', + nestedPath: 'sender_account.holder_id', + }, + { + name: 'sender-account-institution-id', + type: 'string', + description: 'Sender bank institution ID (e.g., cl_banco_estado)', + nestedPath: 'sender_account.institution_id', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + { + name: 'cash-send-voucher', + type: 'boolean', + nestedPath: 'payment_type_options.cash.notification_preferences.send_voucher', + description: 'Send the cash payment voucher to the customer', + }, + { + name: 'cash-send-reminders', + type: 'boolean', + nestedPath: 'payment_type_options.cash.notification_preferences.send_reminders', + description: 'Store the payment reminders preference', + }, + ], listFlags: [ + ...PAGE_FLAGS, { name: 'status', type: 'string', @@ -27,12 +153,22 @@ export const resources: ResourceDef[] = [ { name: 'since', type: 'string', - description: 'Show results created after this date (ISO 8601)', + description: 'Show results created on or after this date (ISO 8601)', }, { name: 'until', type: 'string', - description: 'Show results created before this date (ISO 8601)', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + actions: [ + { + name: 'expire', + idempotencyKey: true, + sdkMethod: 'expire', + description: 'Expire a payment intent', + confirm: true, + successMessage: "{resource} '{id}' expired", }, ], }, @@ -50,7 +186,7 @@ export const resources: ResourceDef[] = [ name: 'amount', type: 'integer', required: true, - description: 'Amount in smallest currency unit', + description: 'Amount in the smallest currency unit', }, { name: 'currency', @@ -74,29 +210,53 @@ export const resources: ResourceDef[] = [ { name: 'counterparty-institution-id', type: 'string', - required: true, - description: 'Destination bank institution ID (e.g., cl_banco_estado)', + description: + 'Destination bank institution ID (e.g., cl_banco_estado; required in Chile and for debit cards in Mexico)', nestedPath: 'counterparty.institution_id', }, { name: 'counterparty-holder-id', type: 'string', - description: 'Counterparty holder ID (e.g., RUT in Chile, required for CL)', + description: 'Destination account holder tax ID (e.g., RUT in Chile; required in Chile)', nestedPath: 'counterparty.holder_id', }, { name: 'counterparty-account-type', type: 'string', - description: 'Counterparty account type (checking_account, sight_account, required for CL)', + description: + 'Destination account type (checking_account, sight_account; required in Chile)', nestedPath: 'counterparty.account_type', }, { name: 'comment', type: 'string', - description: 'Reference visible to the recipient', + description: 'Comment shown to the recipient (up to 40 characters)', + }, + { + name: 'counterparty-holder-name', + type: 'string', + nestedPath: 'counterparty.holder_name', + description: 'Destination account holder name', + }, + { + name: 'counterparty-email', + type: 'string', + nestedPath: 'counterparty.email', + description: 'Destination account holder email', + }, + { + name: 'reference-id', + type: 'string', + description: 'Numeric reference shown to the recipient (up to 7 digits; Mexico only)', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', }, ], listFlags: [ + ...CURSOR_FLAGS, { name: 'status', type: 'string[]', @@ -105,22 +265,71 @@ export const resources: ResourceDef[] = [ { name: 'since', type: 'string', - description: 'Show results created after this date (ISO 8601)', + description: 'Show results created on or after this date (ISO 8601)', }, { name: 'until', type: 'string', - description: 'Show results created before this date (ISO 8601)', + description: 'Show results created on or before this date (ISO 8601)', + }, + { + name: 'account-id', + type: 'string', + description: 'Filter by account ID', + }, + { + name: 'account-number-id', + type: 'string', + description: 'Filter by account number ID', + }, + { + name: 'direction', + type: 'string', + description: 'Filter by direction: inbound or outbound', + }, + { + name: 'tracking-key', + type: 'string', + description: 'Filter by interbank tracking key (clave de rastreo)', + }, + { + name: 'transfer-batch-id', + type: 'string', + description: 'Filter by transfer batch ID', + }, + ], + actions: [ + { + name: 'receipt', + sdkMethod: 'receipt', + description: 'Get the receipt download URL of a transfer', + kind: 'read', + }, + { + name: 'return', + idempotencyKey: true, + sdkMethod: 'return', + description: 'Return an inbound transfer', + idArg: { + name: 'transfer_id', + description: 'ID of the inbound transfer to return', + bodyField: 'transfer_id', + }, + fromJson: true, + confirm: true, + needsJws: true, + successMessage: "{resource} '{id}' returned", }, ], }, { name: 'accounts', displayName: 'account', + description: 'Manage accounts (Transfers API)', cliCommand: 'accounts', sdkMethod: 'accounts', sdkNamespace: 'v2', - verbs: ['get', 'list'], + verbs: ['create', 'get', 'list', 'update'], priorityColumns: [ 'id', 'entity.holder_name', @@ -129,7 +338,49 @@ export const resources: ResourceDef[] = [ 'status', 'refreshed_at', ], - listFlags: [{ name: 'type', type: 'string', description: 'Filter by account type' }], + createFlags: [ + { + name: 'entity-id', + type: 'string', + required: true, + description: 'Entity ID that owns the account', + }, + { + name: 'description', + type: 'string', + description: 'Description of the account (up to 40 characters)', + }, + { + name: 'number', + type: 'string', + description: 'Root account number to assign to the new account (Chile only)', + }, + ], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'type', + type: 'string', + deprecated: 'the API ignores it, so it has no effect and will be removed', + }, + { + name: 'entity-id', + type: 'string', + description: 'Filter by entity ID', + }, + { + name: 'status', + type: 'string', + description: `Filter by status (see ${DOCS_ACCOUNTS_URL})`, + }, + ], + updateFlags: [ + { + name: 'description', + type: 'string', + description: 'Description of the account (up to 40 characters)', + }, + ], }, { name: 'webhook_endpoints', @@ -137,7 +388,7 @@ export const resources: ResourceDef[] = [ cliCommand: 'webhook_endpoints', sdkMethod: 'webhookEndpoints', sdkNamespace: 'v1', - verbs: ['create', 'get', 'list', 'delete'], + verbs: ['create', 'get', 'list', 'update', 'delete'], priorityColumns: ['id', 'name', 'url', 'status', 'created_at'], createFlags: [ { @@ -150,17 +401,49 @@ export const resources: ResourceDef[] = [ name: 'enabled-events', type: 'string[]', required: true, - description: 'Events to subscribe to (comma-separated)', + description: 'Events to subscribe to', }, { name: 'description', type: 'string', description: 'Description of the webhook endpoint' }, + { + name: 'name', + type: 'string', + description: 'Name to identify the endpoint', + }, ], listFlags: [], + updateFlags: [ + { + name: 'url', + type: 'string', + description: 'Endpoint URL to receive webhooks', + }, + { + name: 'enabled-events', + type: 'string[]', + description: 'Events to subscribe to, replacing the current list', + }, + { + name: 'name', + type: 'string', + description: 'Name to identify the endpoint', + }, + { + name: 'description', + type: 'string', + description: 'Description of the webhook endpoint', + }, + { + name: 'disabled', + type: 'boolean', + description: 'Disable (true) or enable (false) the endpoint', + }, + ], actions: [ { name: 'test', + idempotencyKey: true, sdkMethod: 'test', description: 'Send a test event to a webhook endpoint', - successMessage: "Test event sent to {resource} '{id}'", flags: [ { name: 'type', @@ -169,6 +452,7 @@ export const resources: ResourceDef[] = [ description: 'Event type to test (e.g., payment_intent.succeeded)', }, ], + successMessage: "Test event sent to {resource} '{id}'", }, ], }, @@ -178,29 +462,77 @@ export const resources: ResourceDef[] = [ cliCommand: 'charges', sdkMethod: 'charges', sdkNamespace: 'v1', - verbs: ['create', 'get', 'list'], + verbs: ['create', 'get', 'list', 'update'], priorityColumns: ['id', 'amount', 'currency', 'status', 'created_at'], createFlags: [ { name: 'amount', type: 'integer', required: true, - description: 'Amount in smallest currency unit', + description: 'Amount in the smallest currency unit', }, { name: 'currency', type: 'string', required: true, - description: 'Currency code (e.g., CLP)', + description: 'Currency code (CLP)', }, { name: 'subscription-id', type: 'string', required: true, - description: 'Subscription ID to charge', + description: 'ID of the subscription to charge', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + { + name: 'business-profile-category', + type: 'string', + nestedPath: 'business_profile.category', + description: 'Business activity code (e.g., MCC; 6 characters in Chile)', + }, + { + name: 'business-profile-name', + type: 'string', + nestedPath: 'business_profile.name', + description: 'Business name shown to the payer', + }, + { + name: 'business-profile-tax-id', + type: 'string', + nestedPath: 'business_profile.tax_id', + description: 'Business tax ID (e.g., RUT in Chile)', + }, + { + name: 'recipient-account-holder-id', + type: 'string', + nestedPath: 'recipient_account.holder_id', + description: 'Recipient account holder tax ID (e.g., RUT in Chile)', + }, + { + name: 'recipient-account-institution-id', + type: 'string', + nestedPath: 'recipient_account.institution_id', + description: 'Recipient bank institution ID (e.g., cl_banco_estado)', + }, + { + name: 'recipient-account-number', + type: 'string', + nestedPath: 'recipient_account.number', + description: 'Recipient account number', + }, + { + name: 'recipient-account-type', + type: 'string', + nestedPath: 'recipient_account.type', + description: 'Recipient account type (e.g., checking_account, sight_account)', }, ], listFlags: [ + ...PAGE_FLAGS, { name: 'status', type: 'string', @@ -209,12 +541,12 @@ export const resources: ResourceDef[] = [ { name: 'since', type: 'string', - description: 'Show results created after this date (ISO 8601)', + description: 'Show results created on or after this date (ISO 8601)', }, { name: 'until', type: 'string', - description: 'Show results created before this date (ISO 8601)', + description: 'Show results created on or before this date (ISO 8601)', }, { name: 'subscription-id', @@ -222,10 +554,33 @@ export const resources: ResourceDef[] = [ description: 'Filter by subscription ID', }, ], + updateFlags: [ + { + name: 'amount', + type: 'integer', + description: 'Amount in the smallest currency unit', + }, + { + name: 'currency', + type: 'string', + description: 'Currency code (CLP)', + }, + ], + actions: [ + { + name: 'cancel', + idempotencyKey: true, + sdkMethod: 'cancel', + description: 'Cancel a pending charge', + confirm: true, + successMessage: "{resource} '{id}' canceled", + }, + ], }, { name: 'subscriptions', displayName: 'subscription', + description: 'Manage direct debit subscriptions (legacy)', cliCommand: 'subscriptions', sdkMethod: 'subscriptions', sdkNamespace: 'v1', @@ -239,15 +594,16 @@ export const resources: ResourceDef[] = [ 'status', ], listFlags: [ + ...PAGE_FLAGS, { name: 'since', type: 'string', - description: 'Show results created after this date (ISO 8601)', + description: 'Show results created on or after this date (ISO 8601)', }, { name: 'until', type: 'string', - description: 'Show results created before this date (ISO 8601)', + description: 'Show results created on or before this date (ISO 8601)', }, ], }, @@ -257,43 +613,60 @@ export const resources: ResourceDef[] = [ cliCommand: 'links', sdkMethod: 'links', sdkNamespace: 'v1', - verbs: ['get', 'list', 'delete'], + verbs: ['get', 'list', 'update', 'delete'], priorityColumns: ['id', 'holder_id', 'institution', 'status', 'created_at'], getArg: { name: 'link_token', description: `Link token (format: LINK_ID_token_LINK_ACCESS_TOKEN, see ${DOCS_LINKS_URL})`, }, listFlags: [ + ...PAGE_FLAGS, { name: 'status', type: 'string', description: `Filter by status (see ${DOCS_LINKS_URL})`, }, ], + updateFlags: [ + { + name: 'active', + type: 'boolean', + description: 'Activate (true) or deactivate (false) the link', + }, + ], + actions: [ + { + name: 'exchange', + sdkMethod: 'exchange', + description: 'Exchange an exchange token for its link', + kind: 'read', + idArg: false, + flags: [ + { + name: 'exchange-token', + type: 'string', + required: true, + description: 'Exchange token of a succeeded link intent, obtained through the widget', + }, + ], + }, + ], }, { name: 'checkout_sessions', displayName: 'checkout session', + description: 'Manage checkout sessions (API v1)', cliCommand: 'checkout_sessions', sdkMethod: 'checkoutSessions', sdkNamespace: 'v1', verbs: ['create', 'get'], - actions: [ - { - name: 'expire', - sdkMethod: 'expire', - description: 'Expire a checkout session', - confirm: true, - successMessage: "{resource} '{id}' expired", - }, - ], priorityColumns: ['id', 'amount', 'currency', 'status', 'created_at'], createFlags: [ { name: 'amount', type: 'integer', required: true, - description: 'Amount to pay in smallest currency unit (must be > 0 and < 7000000)', + description: 'Amount to pay in the smallest currency unit (up to 6999999)', }, { name: 'currency', @@ -309,17 +682,17 @@ export const resources: ResourceDef[] = [ { name: 'cancel-url', type: 'string', - description: 'Redirect URL if customer cancels', + description: 'Redirect URL when the payment is not completed', }, { name: 'customer-email', type: 'string', - description: 'Customer email for refund notifications', + description: 'Customer email to send notifications and receipts to', }, { name: 'recipient-account-type', type: 'string', - description: 'Recipient account type (checking_account, sight_account)', + description: 'Recipient account type (e.g., checking_account, sight_account)', nestedPath: 'payment_method_options.payment_intent.recipient_account.type', }, { @@ -331,7 +704,7 @@ export const resources: ResourceDef[] = [ { name: 'recipient-account-holder-id', type: 'string', - description: 'Recipient account holder ID (e.g., RUT in Chile)', + description: 'Recipient account holder tax ID (e.g., RUT in Chile)', nestedPath: 'payment_method_options.payment_intent.recipient_account.holder_id', }, { @@ -343,23 +716,33 @@ export const resources: ResourceDef[] = [ { name: 'business-profile-tax-id', type: 'string', - description: 'Merchant tax ID (e.g., RUT in Chile)', + description: 'Business tax ID (e.g., RUT in Chile)', nestedPath: 'business_profile.tax_id', }, { name: 'business-profile-name', type: 'string', - description: 'Merchant display name', + description: 'Business name shown to the payer', nestedPath: 'business_profile.name', }, { name: 'business-profile-category', type: 'string', - description: 'Merchant activity code (6 characters in Chile)', + description: 'Business activity code (e.g., MCC; 6 characters in Chile)', nestedPath: 'business_profile.category', }, ], listFlags: [], + actions: [ + { + name: 'expire', + idempotencyKey: true, + sdkMethod: 'expire', + description: 'Expire a checkout session', + confirm: true, + successMessage: "{resource} '{id}' expired", + }, + ], }, { name: 'account_verifications', @@ -383,16 +766,55 @@ export const resources: ResourceDef[] = [ required: true, description: `Account to verify — CLABE, debit card, or phone number (see ${DOCS_ACCOUNT_VERIFICATIONS_URL})`, }, - ], - listFlags: [], - }, - { - name: 'account_numbers', - displayName: 'account number', - cliCommand: 'account_numbers', + { + name: 'institution-id', + type: 'string', + description: + 'Bank institution ID (e.g., cl_banco_estado; required in Chile and for debit cards or phone numbers in Mexico)', + }, + { + name: 'account-type', + type: 'string', + description: + 'Account type (checking_account, sight_account, savings_account; required in Chile)', + }, + { + name: 'holder-id', + type: 'string', + description: 'Expected account holder tax ID (e.g., RUT in Chile; required in Chile)', + }, + ], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'account-number', + type: 'string', + description: 'Filter by verified account number', + }, + { + name: 'transfer-id', + type: 'string', + description: 'Filter by verification transfer ID', + }, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + }, + { + name: 'account_numbers', + displayName: 'account number', + cliCommand: 'account_numbers', sdkMethod: 'accountNumbers', sdkNamespace: 'v2', - verbs: ['create', 'get', 'list', 'delete'], + verbs: ['create', 'get', 'list', 'update', 'delete'], priorityColumns: ['id', 'number', 'description', 'status', 'is_root'], createFlags: [ { @@ -406,12 +828,72 @@ export const resources: ResourceDef[] = [ type: 'string', description: `Description to identify the account number (see ${DOCS_ACCOUNT_NUMBERS_URL})`, }, + { + name: 'options-min-amount', + type: 'integer', + nestedPath: 'options.min_amount', + description: 'Minimum inbound amount in the smallest currency unit (Mexico only)', + }, + { + name: 'options-max-amount', + type: 'integer', + nestedPath: 'options.max_amount', + description: 'Maximum inbound amount in the smallest currency unit (Mexico only)', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'account-id', + type: 'string', + description: 'Filter by account ID', + }, + { + name: 'without-transfers-since', + type: 'string', + nestedPath: 'no_transfers_since', + description: 'Only account numbers with no inbound transfers since this date (ISO 8601)', + }, + ], + updateFlags: [ + { + name: 'description', + type: 'string', + description: 'Description of the account number', + }, + { + name: 'status', + type: 'string', + description: 'Status of the account number (enabled, disabled)', + }, + { + name: 'options-min-amount', + type: 'integer', + description: 'Minimum inbound amount in the smallest currency unit (Mexico only)', + nestedPath: 'options.min_amount', + }, + { + name: 'options-max-amount', + type: 'integer', + description: 'Maximum inbound amount in the smallest currency unit (Mexico only)', + nestedPath: 'options.max_amount', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, ], - listFlags: [], }, { name: 'movements', displayName: 'movement', + description: 'Manage account movements (Transfers API)', cliCommand: 'movements', sdkMethod: 'accounts.movements', sdkNamespace: 'v2', @@ -421,32 +903,38 @@ export const resources: ResourceDef[] = [ { name: 'account-id', type: 'string', - required: true, + pathParam: true, description: 'Account ID to retrieve the movement from', }, ], listFlags: [ + ...CURSOR_FLAGS, { name: 'account-id', type: 'string', - required: true, + pathParam: true, description: 'Account ID to list movements for', }, { name: 'since', type: 'string', - description: 'Show results with post_date after this date (ISO 8601)', + description: 'Show movements with a post date on or after this date (ISO 8601)', }, { name: 'until', type: 'string', - description: 'Show results with post_date before this date (ISO 8601)', + description: 'Show movements with a post date on or before this date (ISO 8601)', }, { name: 'resource-id', type: 'string', description: `Filter by associated resource ID (see ${DOCS_MOVEMENTS_URL})`, }, + { + name: 'direction', + type: 'string', + description: 'Filter by direction: inbound or outbound', + }, ], }, { @@ -459,6 +947,2328 @@ export const resources: ResourceDef[] = [ priorityColumns: ['token', 'mode', 'is_public', 'created_at'], listFlags: [], }, + { + name: 'refunds', + displayName: 'refund', + cliCommand: 'refunds', + sdkMethod: 'refunds', + sdkNamespace: 'v1', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'amount', 'currency', 'status', 'resource_id', 'created_at'], + createFlags: [ + { + name: 'resource-id', + type: 'string', + required: true, + description: 'ID of the resource to refund', + }, + { + name: 'resource-type', + type: 'string', + required: true, + description: 'Type of the resource to refund (payment_intent)', + }, + { + name: 'amount', + type: 'integer', + description: 'Amount to refund in the smallest currency unit (defaults to the full amount)', + }, + { + name: 'recipient-account-holder-id', + type: 'string', + description: 'Recipient account holder tax ID (e.g., RUT in Chile)', + nestedPath: 'recipient_account.holder_id', + }, + { + name: 'recipient-account-holder-name', + type: 'string', + description: 'Recipient account holder name', + nestedPath: 'recipient_account.holder_name', + }, + { + name: 'recipient-account-number', + type: 'string', + description: 'Recipient account number', + nestedPath: 'recipient_account.number', + }, + { + name: 'recipient-account-type', + type: 'string', + description: 'Recipient account type (e.g., checking_account, sight_account)', + nestedPath: 'recipient_account.type', + }, + { + name: 'recipient-account-institution-id', + type: 'string', + description: 'Recipient bank institution ID (e.g., cl_banco_estado)', + nestedPath: 'recipient_account.institution_id', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'status', + type: 'string', + description: `Filter by status (see ${DOCS_REFUNDS_URL})`, + }, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + actions: [ + { + name: 'cancel', + idempotencyKey: true, + sdkMethod: 'cancel', + description: 'Cancel a refund', + confirm: true, + successMessage: "{resource} '{id}' canceled", + }, + { + name: 'voucher_url', + sdkMethod: 'voucherUrl', + description: 'Get the voucher URL of a refund', + kind: 'read', + }, + ], + }, + { + name: 'subscription_intents', + displayName: 'subscription intent', + cliCommand: 'subscription_intents', + sdkMethod: 'subscriptionIntents', + sdkNamespace: 'v1', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'status', 'customer_email', 'subscription', 'created_at'], + createFlags: [ + { + name: 'business-profile-name', + type: 'string', + description: 'Business name shown to the payer', + nestedPath: 'business_profile.name', + }, + { + name: 'customer-email', + type: 'string', + description: 'Customer email to send notifications and receipts to', + }, + { + name: 'reference-id', + type: 'string', + description: "Identifier shown for the subscription on the payer's bank portal", + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + }, + { + name: 'payment_links', + displayName: 'payment link', + cliCommand: 'payment_links', + sdkMethod: 'paymentLinks', + sdkNamespace: 'v1', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'amount', 'currency', 'status', 'url', 'created_at'], + createFlags: [ + { + name: 'amount', + type: 'integer', + required: true, + description: 'Amount to collect in the smallest currency unit', + }, + { + name: 'currency', + type: 'string', + required: true, + description: 'Currency code (CLP, MXN)', + }, + { + name: 'customer-email', + type: 'string', + description: 'Customer email to send notifications and receipts to', + }, + { + name: 'expires-after-seconds', + type: 'integer', + description: 'Seconds after which the payment link expires', + }, + { + name: 'checkout-description', + type: 'string', + description: 'Text shown next to the buy button on the payment page', + nestedPath: 'checkout.description', + }, + { + name: 'recipient-account-holder-id', + type: 'string', + description: 'Recipient account holder tax ID (e.g., RUT in Chile)', + nestedPath: 'recipient_account.holder_id', + }, + { + name: 'recipient-account-number', + type: 'string', + description: 'Recipient account number', + nestedPath: 'recipient_account.number', + }, + { + name: 'recipient-account-type', + type: 'string', + description: 'Recipient account type (e.g., checking_account, sight_account)', + nestedPath: 'recipient_account.type', + }, + { + name: 'recipient-account-institution-id', + type: 'string', + description: 'Recipient bank institution ID (e.g., cl_banco_estado)', + nestedPath: 'recipient_account.institution_id', + }, + { + name: 'business-profile-tax-id', + type: 'string', + description: 'Business tax ID (e.g., RUT in Chile)', + nestedPath: 'business_profile.tax_id', + }, + { + name: 'business-profile-name', + type: 'string', + description: 'Business name shown to the payer', + nestedPath: 'business_profile.name', + }, + { + name: 'business-profile-category', + type: 'string', + description: 'Business activity code (e.g., MCC; 6 characters in Chile)', + nestedPath: 'business_profile.category', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + actions: [ + { + name: 'cancel', + sdkMethod: 'cancel', + description: 'Cancel a payment link', + confirm: true, + successMessage: "{resource} '{id}' canceled", + }, + ], + }, + { + name: 'disputes', + displayName: 'dispute', + cliCommand: 'disputes', + sdkMethod: 'disputes', + sdkNamespace: 'v1', + verbs: ['get', 'list'], + priorityColumns: ['id', 'amount', 'currency', 'status', 'resource_id', 'created_at'], + listFlags: [ + { + name: 'status', + type: 'string', + description: `Filter by status (see ${DOCS_DISPUTES_URL})`, + }, + { + name: 'resource-id', + type: 'string', + description: 'Filter by disputed resource ID (payment intent)', + }, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + actions: [ + { + name: 'submit_for_review', + confirmPhrase: "submit {resource} '{id}' for review", + idempotencyKey: true, + sdkMethod: 'submitForReview', + description: 'Submit a dispute for review', + confirm: true, + successMessage: "{resource} '{id}' submitted for review", + }, + { + name: 'create_document', + idempotencyKey: true, + sdkMethod: 'createDocument', + description: 'Upload an evidence document (PDF, JPEG or PNG, up to 10 MB) to a dispute', + files: [{ field: 'file', required: true, description: 'Document to upload' }], + successMessage: "Document uploaded to {resource} '{id}'", + }, + ], + }, + { + name: 'accounts', + displayName: 'account', + description: 'Manage accounts of links (Movements API)', + cliCommand: 'accounts', + sdkMethod: 'accounts', + sdkNamespace: 'v1', + verbs: ['get', 'list'], + priorityColumns: ['id', 'name', 'holder_name', 'currency', 'type', 'refreshed_at'], + getFlags: [ + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the link that holds the account (see ${DOCS_LINKS_URL})`, + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the link that holds the accounts (see ${DOCS_LINKS_URL})`, + }, + ], + }, + { + name: 'movements', + displayName: 'movement', + description: 'Manage movements of link accounts (Movements API)', + cliCommand: 'movements', + sdkMethod: 'accounts.movements', + sdkNamespace: 'v1', + verbs: ['get', 'list'], + priorityColumns: ['id', 'amount', 'currency', 'description', 'status', 'post_date'], + getFlags: [ + { + name: 'account-id', + type: 'string', + pathParam: true, + description: 'Account ID to retrieve the movement from', + }, + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the link that holds the account (see ${DOCS_LINKS_URL})`, + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'account-id', + type: 'string', + pathParam: true, + description: 'Account ID to list movements for', + }, + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the link that holds the account (see ${DOCS_LINKS_URL})`, + }, + { + name: 'since', + type: 'string', + description: 'Show movements with a post date on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show movements with a post date on or before this date (ISO 8601)', + }, + { + name: 'updated-since', + type: 'string', + description: 'Show results created or updated on or after this date (ISO 8601)', + }, + { + name: 'confirmed-only', + type: 'boolean', + description: 'Return only confirmed movements (defaults to true)', + }, + ], + }, + { + name: 'invoices', + displayName: 'invoice', + description: 'Manage tax invoices (Fiscal API)', + cliCommand: 'invoices', + sdkMethod: 'invoices', + sdkNamespace: 'v1', + verbs: ['get', 'list'], + priorityColumns: ['id', 'number', 'issue_type', 'total_amount', 'currency', 'date'], + getFlags: [ + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the fiscal link that holds the invoice (see ${DOCS_LINKS_URL})`, + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the fiscal link that holds the invoices (see ${DOCS_LINKS_URL})`, + }, + { + name: 'since', + type: 'string', + description: 'Show invoices dated on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show invoices dated on or before this date (ISO 8601)', + }, + { + name: 'updated-since', + type: 'string', + description: 'Show invoices updated on or after this date (ISO 8601)', + }, + { + name: 'issue-type', + type: 'string', + description: `Filter by issue type (see ${DOCS_FISCAL_INVOICES_URL})`, + }, + { + name: 'invoice-status', + type: 'string', + description: `Filter by invoice status (see ${DOCS_FISCAL_INVOICES_URL})`, + }, + ], + }, + { + name: 'tax_returns', + displayName: 'tax return', + cliCommand: 'tax_returns', + sdkMethod: 'taxReturns', + sdkNamespace: 'v1', + verbs: ['get', 'list'], + priorityColumns: ['id', 'fiscal_year', 'document_number', 'institution_id', 'currency'], + getFlags: [ + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the fiscal link that holds the tax return (see ${DOCS_LINKS_URL})`, + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the fiscal link that holds the tax returns (see ${DOCS_LINKS_URL})`, + }, + ], + }, + { + name: 'refresh_intents', + displayName: 'refresh intent', + cliCommand: 'refresh_intents', + sdkMethod: 'refreshIntents', + sdkNamespace: 'v1', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'type', 'status', 'refreshed_object_id', 'created_at'], + createFlags: [ + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the link to refresh (see ${DOCS_LINKS_URL})`, + }, + { + name: 'refresh-type', + type: 'string', + description: 'Statement to refresh: only_last (default) or historical', + }, + { + name: 'result-status', + type: 'string', + description: 'Result to force in test mode: succeeded, failed or rejected', + }, + ], + getFlags: [ + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the link that holds the refresh intent (see ${DOCS_LINKS_URL})`, + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the link that holds the refresh intents (see ${DOCS_LINKS_URL})`, + }, + ], + }, + { + name: 'checkout_sessions', + displayName: 'checkout session', + description: 'Manage checkout sessions (API v2)', + cliCommand: 'checkout_sessions', + sdkMethod: 'checkoutSessions', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'flow', 'amount', 'currency', 'status', 'created_at'], + createFlags: [ + { + name: 'cash-channels', + type: 'string[]', + nestedPath: 'payment_method_options.cash.notification_preferences.channels', + description: 'Channels for cash payment notifications (email, phone)', + }, + { + name: 'bank-transfer-sender-account-holder-id-value', + type: 'string', + nestedPath: 'payment_method_options.bank_transfer.sender_account.holder_id.value', + description: 'Pre-filled account holder identifier', + }, + { + name: 'bank-transfer-sender-account-holder-id-editable', + type: 'boolean', + nestedPath: 'payment_method_options.bank_transfer.sender_account.holder_id.editable', + description: 'Allow the customer to change the pre-filled account holder identifier', + }, + { + name: 'bank-transfer-sender-account-institution-id-value', + type: 'string', + nestedPath: 'payment_method_options.bank_transfer.sender_account.institution_id.value', + description: 'Institution ID the customer must use', + }, + { + name: 'pac-sender-account-holder-id-value', + type: 'string', + nestedPath: 'payment_method_options.pac.sender_account.holder_id.value', + description: 'Pre-filled account holder identifier', + }, + { + name: 'pac-sender-account-holder-id-editable', + type: 'boolean', + nestedPath: 'payment_method_options.pac.sender_account.holder_id.editable', + description: 'Allow the customer to change the pre-filled account holder identifier', + }, + { + name: 'pac-sender-account-institution-id-value', + type: 'string', + nestedPath: 'payment_method_options.pac.sender_account.institution_id.value', + description: 'Institution ID the customer must use', + }, + { + name: 'pac-sender-account-types', + type: 'string[]', + nestedPath: 'payment_method_options.pac.sender_account.types', + description: 'Account types the customer can subscribe with (checking_account)', + }, + { + name: 'currency', + type: 'string', + required: true, + description: 'Currency code (CLP, MXN)', + }, + { + name: 'amount', + type: 'integer', + description: 'Amount to charge in the smallest currency unit', + }, + { + name: 'flow', + type: 'string', + description: 'Session flow (payment, subscription, setup)', + }, + { + name: 'customer', + type: 'string', + description: 'ID of an existing customer to attach to the session', + }, + { + name: 'customer-email', + type: 'string', + description: 'Customer email to send notifications and receipts to', + }, + { + name: 'customer-phone', + type: 'string', + description: 'Customer phone number in E.164 format', + }, + { + name: 'payment-method-types', + type: 'string[]', + description: `Payment method types the customer can use (see ${DOCS_CHECKOUT_SESSIONS_URL})`, + }, + { + name: 'payment-method', + type: 'string', + description: 'ID of a saved payment method to pay the session with', + }, + { + name: 'save-payment-method', + type: 'string', + description: 'Offer the customer to save their payment method (disabled, enabled)', + }, + { + name: 'success-url', + type: 'string', + description: 'Redirect URL after successful payment', + }, + { + name: 'cancel-url', + type: 'string', + description: 'Redirect URL when the payment is not completed', + }, + { + name: 'expires-at', + type: 'string', + description: 'When the session expires (ISO 8601 datetime, UTC)', + }, + { + name: 'ui-mode', + type: 'string', + description: 'UI mode for payment flow sessions (embedded)', + }, + { + name: 'business-profile-tax-id', + type: 'string', + description: 'Business tax ID (e.g., RUT in Chile)', + nestedPath: 'business_profile.tax_id', + }, + { + name: 'business-profile-name', + type: 'string', + description: 'Business name shown to the payer', + nestedPath: 'business_profile.name', + }, + { + name: 'business-profile-category', + type: 'string', + description: 'Business activity code (e.g., MCC; 6 characters in Chile)', + nestedPath: 'business_profile.category', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + { + name: 'subscription', + type: 'string', + description: 'ID of a subscription whose payment method this session updates', + }, + { + name: 'trial-end', + type: 'string', + description: + 'End of the trial of the subscription the session starts (ISO 8601 datetime, UTC)', + }, + { + name: 'billing-cycle-anchor', + type: 'string', + description: 'First charge of the subscription the session starts (ISO 8601 datetime, UTC)', + }, + { + name: 'subscription-cancel-at', + type: 'string', + description: + 'Scheduled cancellation of the subscription the session starts (ISO 8601 datetime, UTC)', + }, + { + name: 'customer-data-name', + type: 'string', + nestedPath: 'customer_data.name', + description: 'Name of the customer to create', + }, + { + name: 'customer-data-email', + type: 'string', + nestedPath: 'customer_data.email', + description: 'Email of the customer to create', + }, + { + name: 'customer-data-phone', + type: 'string', + nestedPath: 'customer_data.phone', + description: 'Phone of the customer to create, in E.164 format', + }, + { + name: 'customer-data-tax-id-type', + type: 'string', + nestedPath: 'customer_data.tax_id.type', + description: 'Tax ID type of the customer to create: cl_rut or mx_rfc', + }, + { + name: 'customer-data-tax-id-value', + type: 'string', + nestedPath: 'customer_data.tax_id.value', + description: 'Tax ID of the customer to create', + }, + { + name: 'customer-data-address-line1', + type: 'string', + nestedPath: 'customer_data.address.line1', + description: 'Address line 1 of the customer to create', + }, + { + name: 'customer-data-address-line2', + type: 'string', + nestedPath: 'customer_data.address.line2', + description: 'Address line 2 of the customer to create', + }, + { + name: 'customer-data-address-city', + type: 'string', + nestedPath: 'customer_data.address.city', + description: 'City of the customer to create', + }, + { + name: 'customer-data-address-state', + type: 'string', + nestedPath: 'customer_data.address.state', + description: 'State of the customer to create', + }, + { + name: 'customer-data-address-postal-code', + type: 'string', + nestedPath: 'customer_data.address.postal_code', + description: 'Postal code of the customer to create', + }, + { + name: 'customer-data-address-country', + type: 'string', + nestedPath: 'customer_data.address.country', + description: 'Country of the customer to create (ISO 3166-1 alpha-2)', + }, + { + name: 'customer-data-metadata', + type: 'map', + nestedPath: 'customer_data.metadata', + description: 'Metadata of the customer to create', + }, + { + name: 'card-types', + type: 'string[]', + nestedPath: 'payment_method_options.card.types', + description: 'Card providers to accept: bank, hites', + }, + { + name: 'card-kinds', + type: 'string[]', + nestedPath: 'payment_method_options.card.kinds', + description: 'Card kinds to accept: credit, debit, prepaid', + }, + { + name: 'mx-direct-debit-max-amount', + type: 'integer', + nestedPath: 'payment_method_options.mx_direct_debit.max_amount', + description: 'Maximum debit per interval in the smallest currency unit', + }, + { + name: 'mx-direct-debit-interval', + type: 'string', + nestedPath: 'payment_method_options.mx_direct_debit.interval', + description: 'Period of the maximum debit (week, month, year)', + }, + { + name: 'bank-transfer-reference-number', + type: 'string', + nestedPath: 'payment_method_options.bank_transfer.reference_number', + description: 'Reference number to attach to the transfer', + }, + { + name: 'cash-send-voucher', + type: 'boolean', + nestedPath: 'payment_method_options.cash.notification_preferences.send_voucher', + description: 'Send the cash payment voucher to the customer', + }, + { + name: 'cash-send-reminders', + type: 'boolean', + nestedPath: 'payment_method_options.cash.notification_preferences.send_reminders', + description: 'Store the payment reminders preference', + }, + ], + listFlags: [...CURSOR_FLAGS], + actions: [ + { + name: 'expire', + idempotencyKey: true, + sdkMethod: 'expire', + description: 'Expire a checkout session', + confirm: true, + successMessage: "{resource} '{id}' expired", + }, + ], + }, + { + name: 'customers', + displayName: 'customer', + cliCommand: 'customers', + sdkMethod: 'customers', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'name', 'email', 'tax_id.value', 'created_at'], + createFlags: [ + { name: 'name', type: 'string', description: 'Customer full name or business name' }, + { + name: 'email', + type: 'string', + description: 'Customer email (email or tax ID is required)', + }, + { + name: 'phone', + type: 'string', + description: 'Customer phone number in E.164 format', + }, + { + name: 'tax-id-type', + type: 'string', + description: 'Tax ID type (cl_rut, mx_rfc)', + nestedPath: 'tax_id.type', + }, + { + name: 'tax-id-value', + type: 'string', + description: 'Tax ID number (email or tax ID is required)', + nestedPath: 'tax_id.value', + }, + { + name: 'address-line1', + type: 'string', + nestedPath: 'address.line1', + description: 'Address line 1', + }, + { + name: 'address-line2', + type: 'string', + nestedPath: 'address.line2', + description: 'Address line 2', + }, + { + name: 'address-city', + type: 'string', + nestedPath: 'address.city', + description: 'City', + }, + { + name: 'address-state', + type: 'string', + nestedPath: 'address.state', + description: 'State or region', + }, + { + name: 'address-postal-code', + type: 'string', + nestedPath: 'address.postal_code', + description: 'Postal or ZIP code', + }, + { + name: 'address-country', + type: 'string', + nestedPath: 'address.country', + description: 'Country (ISO 3166-1 alpha-2)', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + listFlags: [ + ...CURSOR_FLAGS, + { name: 'tax-id', type: 'string', description: 'Filter by tax ID number' }, + { + name: 'tax-id-type', + type: 'string', + description: 'Filter by tax ID type (cl_rut, mx_rfc)', + }, + ], + }, + { + name: 'entities', + displayName: 'entity', + pluralDisplayName: 'entities', + cliCommand: 'entities', + sdkMethod: 'entities', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'holder_name', 'holder_id', 'country_code', 'status', 'is_root'], + createFlags: [ + { + name: 'country-code', + type: 'string', + required: true, + description: 'ISO 3166-1 alpha-2 country code of the entity', + }, + { + name: 'holder-name', + type: 'string', + required: true, + description: 'Legal name of the entity owner', + }, + { + name: 'holder-id', + type: 'string', + description: 'Tax ID of the entity owner (e.g., RUT in Chile)', + }, + ], + listFlags: [ + ...CURSOR_FLAGS, + { name: 'holder-id', type: 'string', description: 'Filter by tax ID of the entity owner' }, + { + name: 'status', + type: 'string[]', + description: `Filter by status (see ${DOCS_ENTITIES_URL})`, + }, + { + name: 'is-root', + type: 'boolean', + description: 'Return only the root entity (true) or only non-root entities (false)', + }, + ], + actions: [ + { + name: 'settlement_account', + sdkMethod: 'settlementAccount', + description: 'Get the settlement account of an entity', + kind: 'read', + }, + ], + }, + { + name: 'onboardings', + displayName: 'onboarding', + cliCommand: 'onboardings', + sdkMethod: 'entities.onboardings', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'entity_id', 'type', 'status', 'submitted_at'], + getFlags: [ + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID to retrieve the onboarding from', + }, + ], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID to list onboardings for', + }, + ], + createFlags: [ + { + name: 'company-basics-fantasy-name', + type: 'string', + nestedPath: 'data.company_basics.fantasy_name', + description: 'Company trading name', + }, + { + name: 'company-basics-incorporation-type', + type: 'string', + nestedPath: 'data.company_basics.incorporation_type', + description: 'Company incorporation method (one_day_company, traditional)', + }, + { + name: 'company-information-activities-start-date', + type: 'string', + nestedPath: 'data.company_information.activities_start_date', + description: 'Date the company started activities (ISO 8601)', + }, + { + name: 'company-information-business-activity-code', + type: 'string', + nestedPath: 'data.company_information.business_activity_code', + description: 'Company economic activity code', + }, + { + name: 'company-information-business-address', + type: 'string', + nestedPath: 'data.company_information.business_address', + description: 'Company business address', + }, + { + name: 'company-information-company-type', + type: 'string', + nestedPath: 'data.company_information.company_type', + description: 'Company legal structure (spa, sa, ltda, eirl, colectiva, en_comandita)', + }, + { + name: 'company-information-fiscal-address', + type: 'string', + nestedPath: 'data.company_information.fiscal_address', + description: 'Company fiscal address', + }, + { + name: 'plaft-uaf-obligated', + type: 'boolean', + nestedPath: 'data.plaft.uaf_obligated', + description: 'Company must report to the Financial Analysis Unit (UAF)', + }, + { + name: 'transactional-profile-business-relationship-purposes', + type: 'string[]', + nestedPath: 'data.transactional_profile.business_relationship_purposes', + description: 'Ways the company uses Fintoc', + }, + { + name: 'transactional-profile-equity-origins', + type: 'string[]', + nestedPath: 'data.transactional_profile.equity_origins', + description: 'Sources of the company equity', + }, + { + name: 'transactional-profile-resource-origins', + type: 'string[]', + nestedPath: 'data.transactional_profile.resource_origins', + description: 'Sources of funds the company moves through Fintoc', + }, + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID that owns the onboarding', + }, + { + name: 'type', + type: 'string', + required: true, + description: + 'Onboarding type (account_holder, settlement_recipient); pass complex answers in --from-json', + }, + ], + actions: [ + { + name: 'submit', + confirm: true, + idempotencyKey: true, + sdkMethod: 'submit', + description: 'Submit an onboarding for review', + flags: [ + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID that owns the onboarding', + }, + ], + successMessage: "{resource} '{id}' submitted", + }, + { + name: 'upload_document', + confirm: true, + sdkMethod: 'uploadDocument', + description: 'Upload a document to a slot, replacing its current file', + extraArgs: [{ name: 'slot_key', description: 'Document slot key' }], + flags: [ + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID that owns the onboarding', + }, + ], + files: [{ field: 'file', required: true, description: 'Document to upload' }], + fileAsArg: true, + successMessage: "Document uploaded to {resource} '{id}'", + }, + { + name: 'add_document', + idempotencyKey: true, + sdkMethod: 'addDocument', + description: 'Add a document to a slot that accepts more than one file', + extraArgs: [{ name: 'slot_key', description: 'Document slot key' }], + flags: [ + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID that owns the onboarding', + }, + ], + files: [{ field: 'file', required: true, description: 'Document to add' }], + fileAsArg: true, + successMessage: "Document added to {resource} '{id}'", + }, + { + name: 'delete_document', + confirmPhrase: "delete a document from {resource} '{id}'", + sdkMethod: 'deleteDocument', + description: 'Delete a document from a slot', + extraArgs: [ + { name: 'slot_key', description: 'Document slot key' }, + { name: 'document_id', description: 'ID of the document to delete' }, + ], + flags: [ + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID that owns the onboarding', + }, + ], + confirm: true, + successMessage: "Document deleted from {resource} '{id}'", + }, + { + name: 'upload_shareholder_document', + confirm: true, + sdkMethod: 'uploadShareholderDocument', + description: 'Upload the document of a shareholder', + extraArgs: [{ name: 'shareholder_id', description: 'Shareholder ID' }], + flags: [ + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID that owns the onboarding', + }, + ], + files: [{ field: 'file', required: true, description: 'Document to upload' }], + fileAsArg: true, + successMessage: "Shareholder document uploaded to {resource} '{id}'", + }, + { + name: 'upload_legal_representative_document', + confirm: true, + sdkMethod: 'uploadLegalRepresentativeDocument', + description: 'Upload a document of a legal representative', + extraArgs: [ + { name: 'legal_representative_id', description: 'Legal representative ID' }, + { + name: 'slot_key', + description: + 'Document slot key (identification, identification_front, identification_back, power_of_attorney)', + }, + ], + flags: [ + { + name: 'entity-id', + type: 'string', + pathParam: true, + description: 'Entity ID that owns the onboarding', + }, + ], + files: [{ field: 'file', required: true, description: 'Document to upload' }], + fileAsArg: true, + successMessage: "Legal representative document uploaded to {resource} '{id}'", + }, + ], + }, + { + name: 'payment_intents', + displayName: 'payment intent', + description: 'Manage payment intents (API v2)', + cliCommand: 'payment_intents', + sdkMethod: 'paymentIntents', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'amount', 'currency', 'status', 'payment_type', 'created_at'], + createFlags: [ + { + name: 'amount', + type: 'integer', + required: true, + description: 'Amount to charge in the smallest currency unit', + }, + { + name: 'currency', + type: 'string', + required: true, + description: 'Currency code (CLP, MXN)', + }, + { + name: 'payment-method', + type: 'string', + required: true, + description: 'ID of the payment method to charge', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + actions: [ + { + name: 'cancel', + idempotencyKey: true, + sdkMethod: 'cancel', + description: 'Cancel a payment intent', + confirm: true, + successMessage: "{resource} '{id}' canceled", + }, + { + name: 'voucher_url', + sdkMethod: 'voucherUrl', + description: 'Get the voucher URL of a payment intent', + kind: 'read', + }, + { + name: 'check_eligibility', + idempotencyKey: true, + sdkMethod: 'checkEligibility', + description: 'Check whether a payment can be made', + idArg: false, + successMessage: 'Payment eligibility checked', + flags: [ + { + name: 'amount', + type: 'integer', + required: true, + description: 'Amount to pay in the smallest currency unit', + }, + { + name: 'currency', + type: 'string', + required: true, + description: 'Currency code (CLP)', + }, + { + name: 'sender-account-institution-id', + type: 'string', + required: true, + description: 'Sender bank institution ID (e.g., cl_banco_estado)', + nestedPath: 'sender_account.institution_id', + }, + { + name: 'sender-account-holder-id', + type: 'string', + description: 'Sender account holder tax ID (e.g., RUT in Chile)', + nestedPath: 'sender_account.holder_id', + }, + { + name: 'recipient-account-holder-id', + type: 'string', + description: 'Recipient account holder tax ID (e.g., RUT in Chile)', + nestedPath: 'recipient_account.holder_id', + }, + { + name: 'recipient-account-institution-id', + type: 'string', + description: 'Recipient bank institution ID (e.g., cl_banco_estado)', + nestedPath: 'recipient_account.institution_id', + }, + { + name: 'recipient-account-number', + type: 'string', + description: 'Recipient account number', + nestedPath: 'recipient_account.number', + }, + { + name: 'recipient-account-type', + type: 'string', + description: 'Recipient account type (e.g., checking_account, sight_account)', + nestedPath: 'recipient_account.type', + }, + ], + fromJson: true, + }, + ], + }, + { + name: 'payment_methods', + displayName: 'payment method', + cliCommand: 'payment_methods', + sdkMethod: 'paymentMethods', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'type', 'customer', 'created_at'], + listFlags: [ + ...CURSOR_FLAGS, + { name: 'customer', type: 'string', description: 'Filter by customer ID' }, + { + name: 'payment-options-amount', + type: 'integer', + nestedPath: 'payment_options.amount', + description: 'Only payment methods that can pay this amount', + }, + ], + createFlags: [ + { + name: 'type', + type: 'string', + required: true, + description: 'Payment method type (mx_direct_debit)', + }, + { + name: 'customer', + type: 'string', + required: true, + description: 'ID of the customer the payment method belongs to', + }, + { + name: 'mx-direct-debit-account-number', + type: 'string', + required: true, + description: 'Account to debit (CLABE or debit card number)', + nestedPath: 'mx_direct_debit.account_number', + }, + { + name: 'mx-direct-debit-account-type', + type: 'string', + required: true, + description: 'Type of account (clabe, debit_card)', + nestedPath: 'mx_direct_debit.account_type', + }, + { + name: 'mx-direct-debit-institution-id', + type: 'string', + description: 'Institution that issued the account (required for debit_card)', + nestedPath: 'mx_direct_debit.institution_id', + }, + { + name: 'mx-direct-debit-interval', + type: 'string', + required: true, + description: 'Period of the maximum debit (week, month, year)', + nestedPath: 'mx_direct_debit.interval', + }, + { + name: 'mx-direct-debit-max-amount', + type: 'integer', + required: true, + description: 'Maximum debit per interval in the smallest currency unit', + nestedPath: 'mx_direct_debit.max_amount', + }, + { + name: 'mx-direct-debit-account-holder-name', + type: 'string', + description: 'Account holder name (defaults to the customer)', + nestedPath: 'mx_direct_debit.account_holder.name', + }, + { + name: 'mx-direct-debit-account-holder-email', + type: 'string', + description: 'Account holder email (defaults to the customer)', + nestedPath: 'mx_direct_debit.account_holder.email', + }, + { + name: 'mx-direct-debit-account-holder-rfc', + type: 'string', + description: 'Account holder RFC (defaults to the customer)', + nestedPath: 'mx_direct_debit.account_holder.rfc', + }, + ], + createFiles: [ + { + field: 'mx_direct_debit.consent_documents.contract', + required: true, + description: 'Mandate contract signed by the account holder', + }, + { + field: 'mx_direct_debit.consent_documents.selfie', + required: true, + description: 'Photo of the holder with their ID', + }, + { + field: 'mx_direct_debit.consent_documents.id_front', + required: true, + description: 'Front of the holder ID', + }, + { + field: 'mx_direct_debit.consent_documents.id_back', + required: true, + description: 'Back of the holder ID', + }, + ], + actions: [ + { + name: 'cancel', + idempotencyKey: true, + sdkMethod: 'cancel', + description: 'Cancel a payment method', + confirm: true, + successMessage: "{resource} '{id}' canceled", + }, + { + name: 'mandate_document', + sdkMethod: 'mandateDocument', + description: 'Get the download URL of the mandate contract', + kind: 'read', + }, + ], + }, + { + name: 'products', + displayName: 'product', + cliCommand: 'products', + sdkMethod: 'products', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list', 'update'], + priorityColumns: ['id', 'name', 'description', 'created_at'], + createFlags: [ + { + name: 'name', + type: 'string', + required: true, + description: 'Name of the product (up to 100 characters)', + }, + { + name: 'description', + type: 'string', + description: 'Description of the product (up to 200 characters)', + }, + { + name: 'image-url', + type: 'string', + description: 'Publicly reachable image URL for the product', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'inline', + type: 'boolean', + description: 'Return only products created inline through other resources', + }, + ], + updateFlags: [ + { + name: 'name', + type: 'string', + description: 'Name of the product (up to 100 characters)', + }, + { + name: 'description', + type: 'string', + description: 'Description of the product (up to 200 characters)', + }, + { + name: 'image-url', + type: 'string', + description: 'Publicly reachable image URL for the product', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + }, + { + name: 'subscriptions', + displayName: 'subscription', + description: 'Manage subscriptions (Billing API)', + cliCommand: 'subscriptions', + sdkMethod: 'subscriptions', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list', 'update'], + priorityColumns: ['id', 'customer', 'status', 'collection_method', 'created_at'], + createFlags: [ + { + name: 'customer', + type: 'string', + required: true, + description: 'ID of the customer to bill', + }, + { + name: 'collection-method', + type: 'string', + description: 'How invoices are collected (charge_automatically, send_invoice)', + }, + { + name: 'payment-method', + type: 'string', + description: 'ID of the payment method to charge (required with charge_automatically)', + }, + { + name: 'trial-end', + type: 'string', + description: 'When the trial ends (ISO 8601 datetime, UTC)', + }, + { + name: 'billing-cycle-anchor', + type: 'string', + description: 'First invoice and start of each billing period (ISO 8601 datetime, UTC)', + }, + { + name: 'cancel-at', + type: 'string', + description: 'Scheduled cancellation (ISO 8601 datetime, UTC)', + }, + { + name: 'business-profile-tax-id', + type: 'string', + description: 'Business tax ID (e.g., RUT in Chile)', + nestedPath: 'business_profile.tax_id', + }, + { + name: 'business-profile-name', + type: 'string', + description: 'Business name shown to the payer', + nestedPath: 'business_profile.name', + }, + { + name: 'business-profile-category', + type: 'string', + description: 'Business activity code (e.g., MCC; 6 characters in Chile)', + nestedPath: 'business_profile.category', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + listFlags: [...CURSOR_FLAGS], + updateFlags: [ + { + name: 'trial-end', + type: 'string', + description: 'When the trial ends (ISO 8601 datetime, UTC)', + }, + { + name: 'payment-method', + type: 'string', + description: 'ID of the payment method to charge from now on', + }, + { + name: 'collection-method', + type: 'string', + description: 'How invoices are collected (charge_automatically, send_invoice)', + }, + { + name: 'cancel-at', + type: 'string', + description: 'Scheduled cancellation (ISO 8601 datetime, UTC)', + }, + ], + actions: [ + { + name: 'cancel', + idempotencyKey: true, + sdkMethod: 'cancel', + description: 'Cancel a subscription', + confirm: true, + successMessage: "{resource} '{id}' canceled", + }, + { + name: 'detach_payment_method', + confirmPhrase: "detach the payment method from {resource} '{id}'", + sdkMethod: 'detachPaymentMethod', + description: 'Detach the payment method from a subscription', + confirm: true, + successMessage: "Payment method detached from {resource} '{id}'", + }, + ], + }, + { + name: 'invoices', + displayName: 'invoice', + description: 'Manage invoices (Billing API)', + cliCommand: 'invoices', + sdkMethod: 'invoices', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list', 'update'], + priorityColumns: ['id', 'customer', 'total', 'currency', 'status', 'created_at'], + listFlags: [ + ...CURSOR_FLAGS, + { name: 'subscription', type: 'string', description: 'Filter by subscription ID' }, + ], + createFlags: [ + { + name: 'customer', + type: 'string', + required: true, + description: 'ID of the customer to bill', + }, + { + name: 'collection-method', + type: 'string', + description: 'How invoices are collected (charge_automatically, send_invoice)', + }, + { + name: 'default-payment-method', + type: 'string', + description: 'Payment method charged when the invoice is finalized', + }, + { + name: 'business-profile-tax-id', + type: 'string', + description: 'Business tax ID (e.g., RUT in Chile)', + nestedPath: 'business_profile.tax_id', + }, + { + name: 'business-profile-name', + type: 'string', + description: 'Business name shown to the payer', + nestedPath: 'business_profile.name', + }, + { + name: 'business-profile-category', + type: 'string', + description: 'Business activity code (e.g., MCC; 6 characters in Chile)', + nestedPath: 'business_profile.category', + }, + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + updateFlags: [ + { + name: 'metadata', + type: 'map', + description: 'Metadata to attach', + }, + ], + actions: [ + { + name: 'finalize', + idempotencyKey: true, + sdkMethod: 'finalize', + description: 'Finalize a draft invoice', + confirm: true, + successMessage: "{resource} '{id}' finalized", + }, + { + name: 'pay', + idempotencyKey: true, + sdkMethod: 'pay', + description: 'Pay an open invoice', + flags: [ + { + name: 'payment-method', + type: 'string', + description: "ID of the payment method to charge (defaults to the invoice's)", + }, + { + name: 'external-payment', + type: 'boolean', + description: 'Mark the invoice as paid outside Fintoc', + }, + ], + confirm: true, + successMessage: "{resource} '{id}' paid", + }, + { + name: 'void', + idempotencyKey: true, + sdkMethod: 'void', + description: 'Void an invoice', + confirm: true, + successMessage: "{resource} '{id}' voided", + }, + { + name: 'mark_uncollectible', + confirmPhrase: "mark {resource} '{id}' as uncollectible", + idempotencyKey: true, + sdkMethod: 'markUncollectible', + description: 'Mark an invoice as uncollectible', + confirm: true, + successMessage: "{resource} '{id}' marked uncollectible", + }, + { + name: 'add_lines', + idempotencyKey: true, + sdkMethod: 'addLines', + description: 'Add line items to a draft invoice (pass `lines` in --from-json)', + fromJson: true, + successMessage: "Lines added to {resource} '{id}'", + }, + { + name: 'remove_lines', + confirmPhrase: "remove lines from {resource} '{id}'", + idempotencyKey: true, + sdkMethod: 'removeLines', + description: 'Remove line items from a draft invoice', + flags: [ + { + name: 'lines', + type: 'string[]', + required: true, + description: 'IDs of the line items to remove', + }, + ], + confirm: true, + successMessage: "Lines removed from {resource} '{id}'", + }, + ], + }, + { + name: 'account_statements', + displayName: 'account statement', + cliCommand: 'account_statements', + sdkMethod: 'accounts.accountStatements', + sdkNamespace: 'v2', + verbs: ['list'], + priorityColumns: ['id', 'start_date', 'end_date', 'final_balance_cents', 'download_url'], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'account-id', + type: 'string', + pathParam: true, + description: 'Account ID to list statements for', + }, + { + name: 'since', + type: 'string', + description: 'Show statements whose period ends on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show statements whose period starts on or before this date (ISO 8601)', + }, + ], + }, + { + name: 'recipients', + displayName: 'recipient', + cliCommand: 'recipients', + sdkMethod: 'recipients', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list', 'update', 'delete'], + priorityColumns: ['id', 'holder_name', 'holder_id', 'account_number', 'alias'], + createFlags: [ + { + name: 'entity-id', + type: 'string', + required: true, + description: 'Entity ID the recipient belongs to', + }, + { + name: 'account-number', + type: 'string', + required: true, + description: 'Destination account number (CLABE in Mexico)', + }, + { + name: 'holder-name', + type: 'string', + required: true, + description: 'Account holder name (up to 40 characters)', + }, + { + name: 'holder-id', + type: 'string', + description: 'Account holder tax ID (e.g., RUT in Chile; required in Chile)', + }, + { + name: 'holder-type', + type: 'string', + description: 'Account holder type (person, company; required in Chile)', + }, + { + name: 'institution-id', + type: 'string', + description: 'Destination bank institution ID (e.g., cl_banco_estado; required in Chile)', + }, + { + name: 'account-type', + type: 'string', + description: + 'Destination account type (checking_account, sight_account, savings_account; required in Chile)', + }, + { name: 'alias', type: 'string', description: 'Label for the recipient' }, + { name: 'email', type: 'string', description: 'Contact email' }, + ], + listFlags: [ + { name: 'entity-id', type: 'string', description: 'Filter by entity ID' }, + { + name: 'holder-type', + type: 'string', + description: `Filter by holder type (see ${DOCS_RECIPIENTS_URL})`, + }, + { name: 'holder-id', type: 'string', description: 'Filter by account holder tax ID' }, + { name: 'institution-id', type: 'string', description: 'Filter by institution ID' }, + { name: 'account-number', type: 'string', description: 'Filter by account number' }, + ], + updateFlags: [ + { + name: 'alias', + type: 'string', + description: 'Label for the recipient', + }, + { + name: 'holder-name', + type: 'string', + description: 'Account holder name (up to 40 characters)', + }, + { + name: 'holder-type', + type: 'string', + description: 'Account holder type (person, company; required in Chile)', + }, + { + name: 'account-type', + type: 'string', + description: + 'Destination account type (checking_account, sight_account, savings_account; required in Chile)', + }, + { + name: 'email', + type: 'string', + description: 'Contact email', + }, + ], + }, + { + name: 'transfer_batches', + displayName: 'transfer batch', + pluralDisplayName: 'transfer batches', + cliCommand: 'transfer_batches', + sdkMethod: 'transferBatches', + sdkNamespace: 'v2', + verbs: ['create', 'get', 'list'], + priorityColumns: ['id', 'description', 'amount', 'currency', 'status', 'transfer_count'], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'status', + type: 'string[]', + description: `Filter by status (see ${DOCS_TRANSFER_BATCHES_URL})`, + }, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + needsJws: true, + createFlags: [ + { + name: 'description', + type: 'string', + required: true, + description: 'Label shown in the Dashboard when approving the batch (up to 40 characters)', + }, + { + name: 'currency', + type: 'string', + required: true, + description: 'Currency code of every transfer in the batch (CLP, MXN)', + }, + ], + }, + { + name: 'payouts', + displayName: 'payout', + cliCommand: 'payouts', + sdkMethod: 'payouts', + sdkNamespace: 'v1', + verbs: ['get', 'list'], + priorityColumns: ['id', 'amount', 'currency', 'status', 'succeeded_at', 'created_at'], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'status', + type: 'string', + description: `Filter by status (see ${DOCS_PAYOUTS_URL})`, + }, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + actions: [ + { + name: 'resources', + sdkMethod: 'listResources', + description: 'List the resources a payout disburses or discounts', + kind: 'list', + flags: [ + ...PAGE_FLAGS, + { + name: 'resource-type', + type: 'string', + required: true, + description: + 'Type of resources to list (charge, payment_intent, refund, refund_adjustment)', + }, + ], + priorityColumns: ['id', 'object', 'amount', 'currency', 'status', 'created_at'], + }, + ], + }, + { + name: 'institutions', + displayName: 'institution', + cliCommand: 'institutions', + sdkMethod: 'institutions', + sdkNamespace: 'v1', + verbs: ['list'], + priorityColumns: ['id', 'name', 'country', 'type', 'products'], + listFlags: [ + { + name: 'country', + type: 'string', + description: 'Filter by country (lowercase ISO 3166-1 alpha-2 code, e.g., cl, mx)', + }, + { + name: 'type', + type: 'string', + description: 'Filter by institution type (bank, fiscal_authority)', + }, + { + name: 'product', + type: 'string', + description: 'Filter by available product (e.g., payments, movements)', + }, + ], + }, + { + name: 'link_intents', + displayName: 'link intent', + cliCommand: 'link_intents', + sdkMethod: 'linkIntents', + sdkNamespace: 'v1', + verbs: ['create'], + priorityColumns: ['id', 'country', 'holder_type', 'product', 'status'], + createFlags: [ + { + name: 'country', + type: 'string', + required: true, + description: 'Country of the institution to connect (cl)', + }, + { + name: 'holder-type', + type: 'string', + required: true, + description: 'Owner of the credentials to connect (individual, business)', + }, + { + name: 'product', + type: 'string', + required: true, + description: 'Product the resulting link will be used for (movements)', + }, + ], + }, + { + name: 'tax_statements', + displayName: 'tax statement', + cliCommand: 'tax_statements', + sdkMethod: 'taxStatements', + sdkNamespace: 'v1', + verbs: ['get', 'list'], + priorityColumns: ['id', 'fiscal_year', 'period', 'document_number', 'status'], + getFlags: [ + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the fiscal link that holds the tax statement (see ${DOCS_LINKS_URL})`, + }, + ], + listFlags: [ + ...PAGE_FLAGS, + { + name: 'link-token', + type: 'string', + required: true, + description: `Token of the fiscal link that holds the tax statements (see ${DOCS_LINKS_URL})`, + }, + ], + }, + { + name: 'events', + displayName: 'event', + cliCommand: 'events', + sdkMethod: 'events', + sdkNamespace: 'v2', + verbs: ['get', 'list'], + priorityColumns: ['id', 'type', 'mode', 'created_at'], + listFlags: [ + ...CURSOR_FLAGS, + { + name: 'type', + type: 'string', + description: `Filter by event type (see ${DOCS_EVENTS_URL})`, + }, + { + name: 'resource-id', + type: 'string', + description: 'Filter by the ID of the resource the event is about', + }, + { + name: 'since', + type: 'string', + description: 'Show results created on or after this date (ISO 8601)', + }, + { + name: 'until', + type: 'string', + description: 'Show results created on or before this date (ISO 8601)', + }, + ], + }, + { + name: 'setup_intents', + displayName: 'setup intent', + cliCommand: 'setup_intents', + sdkMethod: 'setupIntents', + sdkNamespace: 'v2', + verbs: ['get'], + priorityColumns: ['id', 'status', 'error_reason'], + }, + { + name: 'subscription_items', + displayName: 'subscription item', + cliCommand: 'subscription_items', + sdkMethod: 'subscriptions.items', + sdkNamespace: 'v2', + verbs: ['create', 'update', 'delete'], + priorityColumns: ['id', 'quantity', 'price.unit_amount', 'price.currency'], + createFlags: [ + { + name: 'subscription-id', + type: 'string', + pathParam: true, + description: 'ID of the subscription the item belongs to', + }, + { + name: 'quantity', + type: 'integer', + required: true, + description: 'Number of units of the price', + }, + { + name: 'price-data-currency', + type: 'string', + required: true, + description: 'Price currency (CLP, MXN, CLF)', + nestedPath: 'price_data.currency', + }, + { + name: 'price-data-unit-amount', + type: 'integer', + required: true, + description: 'Amount per unit in the smallest currency unit', + nestedPath: 'price_data.unit_amount', + }, + { + name: 'price-data-recurring-interval', + type: 'string', + required: true, + description: 'Billing frequency (week, month, year)', + nestedPath: 'price_data.recurring.interval', + }, + { + name: 'price-data-recurring-interval-count', + type: 'integer', + required: true, + description: 'Number of intervals between billing cycles', + nestedPath: 'price_data.recurring.interval_count', + }, + { + name: 'price-data-product', + type: 'string', + description: 'ID of an existing product for the price', + nestedPath: 'price_data.product', + }, + { + name: 'price-data-product-data-name', + type: 'string', + description: 'Name of a new inline product', + nestedPath: 'price_data.product_data.name', + }, + { + name: 'price-data-product-data-description', + type: 'string', + description: 'Description of a new inline product', + nestedPath: 'price_data.product_data.description', + }, + { + name: 'price-data-product-data-image-url', + type: 'string', + description: 'Image URL of a new inline product', + nestedPath: 'price_data.product_data.image_url', + }, + { + name: 'price-data-product-data-metadata', + type: 'map', + nestedPath: 'price_data.product_data.metadata', + description: 'Metadata of the product to create', + }, + ], + updateFlags: [ + { + name: 'subscription-id', + type: 'string', + pathParam: true, + description: 'ID of the subscription the item belongs to', + }, + { + name: 'quantity', + type: 'integer', + description: 'Number of units of the price', + }, + { + name: 'price-data-currency', + type: 'string', + description: 'Price currency (CLP, MXN, CLF)', + nestedPath: 'price_data.currency', + }, + { + name: 'price-data-unit-amount', + type: 'integer', + description: 'Amount per unit in the smallest currency unit', + nestedPath: 'price_data.unit_amount', + }, + { + name: 'price-data-recurring-interval', + type: 'string', + description: 'Billing frequency (week, month, year)', + nestedPath: 'price_data.recurring.interval', + }, + { + name: 'price-data-recurring-interval-count', + type: 'integer', + description: 'Number of intervals between billing cycles', + nestedPath: 'price_data.recurring.interval_count', + }, + { + name: 'price-data-product', + type: 'string', + description: 'ID of an existing product for the price', + nestedPath: 'price_data.product', + }, + { + name: 'price-data-product-data-name', + type: 'string', + description: 'Name of a new inline product', + nestedPath: 'price_data.product_data.name', + }, + { + name: 'price-data-product-data-description', + type: 'string', + description: 'Description of a new inline product', + nestedPath: 'price_data.product_data.description', + }, + { + name: 'price-data-product-data-image-url', + type: 'string', + description: 'Image URL of a new inline product', + nestedPath: 'price_data.product_data.image_url', + }, + { + name: 'price-data-product-data-metadata', + type: 'map', + nestedPath: 'price_data.product_data.metadata', + description: 'Metadata of the product to create', + }, + ], + deleteFlags: [ + { + name: 'subscription-id', + type: 'string', + pathParam: true, + description: 'ID of the subscription the item belongs to', + }, + ], + }, + { + name: 'invoice_lines', + displayName: 'invoice line', + cliCommand: 'invoice_lines', + sdkMethod: 'invoices.lines', + sdkNamespace: 'v2', + verbs: ['update'], + priorityColumns: ['id', 'name', 'amount', 'currency', 'quantity'], + updateFlags: [ + { + name: 'invoice-id', + type: 'string', + pathParam: true, + description: 'Invoice ID the line item belongs to', + }, + { + name: 'amount', + type: 'integer', + description: 'Amount in the smallest currency unit', + }, + { + name: 'currency', + type: 'string', + description: 'Currency code (CLP, MXN)', + }, + { + name: 'quantity', + type: 'integer', + description: 'Number of units', + }, + { + name: 'name', + type: 'string', + description: 'Display name of the line', + }, + { + name: 'description', + type: 'string', + description: 'Description of the line (up to 200 characters)', + }, + { + name: 'period-start', + type: 'string', + description: 'Start of the billing period (ISO 8601)', + }, + { + name: 'period-end', + type: 'string', + description: 'End of the billing period (ISO 8601)', + }, + ], + }, + { + name: 'simulate', + displayName: 'simulation', + description: 'Simulate events in test mode', + cliCommand: 'simulate', + sdkMethod: 'simulate', + sdkNamespace: 'v2', + verbs: [], + priorityColumns: ['id', 'amount', 'currency', 'status'], + actions: [ + { + name: 'receive_transfer', + idempotencyKey: true, + sdkMethod: 'receiveTransfer', + description: 'Simulate an inbound transfer to an account number (test mode)', + idArg: false, + successMessage: "Inbound transfer '{id}' simulated", + flags: [ + { + name: 'account-number-id', + type: 'string', + required: true, + description: 'Account number that receives the transfer', + }, + { + name: 'amount', + type: 'integer', + required: true, + description: 'Amount in the smallest currency unit', + }, + { + name: 'currency', + type: 'string', + required: true, + description: 'Currency code (CLP, MXN)', + }, + { + name: 'comment', + type: 'string', + description: 'Comment shown to the recipient (up to 40 characters)', + }, + { + name: 'reference-id', + type: 'string', + description: 'Numeric reference shown to the recipient (up to 7 digits; Mexico only)', + }, + { + name: 'counterparty-account-number', + type: 'string', + description: 'Sender account number', + }, + { + name: 'counterparty-account-type', + type: 'string', + description: 'Sender account type (checking_account, sight_account; Chile only)', + }, + { + name: 'counterparty-institution-id', + type: 'string', + description: 'Sender bank institution ID (e.g., cl_banco_estado; Chile only)', + }, + { + name: 'counterparty-holder-id', + type: 'string', + description: 'Sender account holder tax ID (e.g., RUT in Chile)', + }, + { + name: 'counterparty-holder-name', + type: 'string', + description: 'Sender account holder name', + }, + ], + fromJson: true, + }, + ], + }, ] export const v1Resources = resources.filter((r) => r.sdkNamespace === 'v1') From aae298fb5d4f0fb1dfc7322d893f4340014cec75 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 12:13:24 -0300 Subject: [PATCH 05/15] test: proteger el contrato de los comandos publicados --- .../__tests__/backward-compat.test.ts | 55 ++ src/resources/__tests__/cli-surface.ts | 42 ++ .../__tests__/fixtures/published-surface.json | 490 ++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 src/resources/__tests__/backward-compat.test.ts create mode 100644 src/resources/__tests__/cli-surface.ts create mode 100644 src/resources/__tests__/fixtures/published-surface.json diff --git a/src/resources/__tests__/backward-compat.test.ts b/src/resources/__tests__/backward-compat.test.ts new file mode 100644 index 0000000..d432652 --- /dev/null +++ b/src/resources/__tests__/backward-compat.test.ts @@ -0,0 +1,55 @@ +import type { Surface } from './cli-surface.js' +import { readFileSync, writeFileSync } from 'node:fs' +import { describe, expect, test } from 'vitest' +import { resources } from '../registry.js' +import { cliSurface, mergeSurface } from './cli-surface.js' + +const FIXTURE = new URL('./fixtures/published-surface.json', import.meta.url) + +const current = cliSurface(resources) + +if (process.env.UPDATE_CLI_SURFACE) { + const baseline = JSON.parse(readFileSync(FIXTURE, 'utf-8')) as { + source: string + commands: Surface + } + writeFileSync( + FIXTURE, + `${JSON.stringify({ ...baseline, commands: mergeSurface(baseline.commands, current) }, null, 2)}\n`, + ) +} + +const baseline = JSON.parse(readFileSync(FIXTURE, 'utf-8')) as { source: string; commands: Surface } + +const published = baseline.commands + +describe('backward compatibility with the published CLI', () => { + test.each(Object.keys(published))('%s keeps its contract', (command) => { + const before = published[command] + const after = current[command] + expect(after, 'command removed').toBeDefined() + expect(after.args.length, 'number of positional arguments changed').toBe(before.args.length) + + for (const [name, flag] of Object.entries(before.flags)) { + const now = after.flags[name] + expect(now, `--${name} removed (mark it deprecated instead)`).toBeDefined() + expect(now.type, `--${name} changed type`).toBe(flag.type) + if (!flag.required) { + expect(now.required, `--${name} became required`).toBe(false) + } + } + for (const [name, flag] of Object.entries(after.flags)) { + if (!before.flags[name]) { + expect(flag.required, `new flag --${name} is required`).toBe(false) + } + } + for (const option of before.options) { + expect(after.options, `${option} removed`).toContain(option) + } + if (!before.confirm) { + expect(after.confirm, 'now asks for confirmation, which breaks scripts without --yes').toBe( + false, + ) + } + }) +}) diff --git a/src/resources/__tests__/cli-surface.ts b/src/resources/__tests__/cli-surface.ts new file mode 100644 index 0000000..daa63de --- /dev/null +++ b/src/resources/__tests__/cli-surface.ts @@ -0,0 +1,42 @@ +import type { ResourceDef } from '../../types.js' +import { describeCommands } from '../factory.js' + +export type CommandSurface = { + args: string[] + flags: Record + options: string[] + confirm: boolean +} + +export type Surface = Record + +export const cliSurface = (resources: ResourceDef[]): Surface => + Object.fromEntries( + describeCommands(resources).map((c) => [ + c.command.replace(/^fintoc /, ''), + { + args: c.args.map((a) => a.name), + flags: Object.fromEntries( + c.flags.map((f) => [f.name, { type: f.type, required: f.required }]), + ), + options: c.options, + confirm: c.confirm, + }, + ]), + ) + +// Adds new commands, flags and options without dropping or changing anything already published +export const mergeSurface = (published: Surface, current: Surface): Surface => { + const merged: Surface = { ...published } + for (const [command, now] of Object.entries(current)) { + const before = published[command] + merged[command] = before + ? { + ...before, + flags: { ...now.flags, ...before.flags }, + options: [...new Set([...before.options, ...now.options])], + } + : now + } + return merged +} diff --git a/src/resources/__tests__/fixtures/published-surface.json b/src/resources/__tests__/fixtures/published-surface.json new file mode 100644 index 0000000..ca0d485 --- /dev/null +++ b/src/resources/__tests__/fixtures/published-surface.json @@ -0,0 +1,490 @@ +{ + "source": "4eb0f859bdf046576fb94e2ec2e6c7919cb38ce1", + "commands": { + "payment_intents get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "payment_intents list": { + "args": [], + "flags": { + "status": { + "type": "string", + "required": false + }, + "since": { + "type": "string", + "required": false + }, + "until": { + "type": "string", + "required": false + } + }, + "options": [ + "--limit" + ], + "confirm": false + }, + "v2 transfers create": { + "args": [], + "flags": { + "amount": { + "type": "integer", + "required": true + }, + "currency": { + "type": "string", + "required": true + }, + "account-id": { + "type": "string", + "required": true + }, + "counterparty-account-number": { + "type": "string", + "required": true + }, + "counterparty-institution-id": { + "type": "string", + "required": true + }, + "counterparty-holder-id": { + "type": "string", + "required": false + }, + "counterparty-account-type": { + "type": "string", + "required": false + }, + "comment": { + "type": "string", + "required": false + } + }, + "options": [ + "--from-json", + "--jws-private-key" + ], + "confirm": false + }, + "v2 transfers get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "v2 transfers list": { + "args": [], + "flags": { + "status": { + "type": "string[]", + "required": false + }, + "since": { + "type": "string", + "required": false + }, + "until": { + "type": "string", + "required": false + } + }, + "options": [ + "--limit" + ], + "confirm": false + }, + "v2 accounts get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "v2 accounts list": { + "args": [], + "flags": { + "type": { + "type": "string", + "required": false + } + }, + "options": [ + "--limit" + ], + "confirm": false + }, + "webhook_endpoints create": { + "args": [], + "flags": { + "url": { + "type": "string", + "required": true + }, + "enabled-events": { + "type": "string[]", + "required": true + }, + "description": { + "type": "string", + "required": false + } + }, + "options": [ + "--from-json" + ], + "confirm": false + }, + "webhook_endpoints get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "webhook_endpoints list": { + "args": [], + "flags": {}, + "options": [ + "--limit" + ], + "confirm": false + }, + "webhook_endpoints delete": { + "args": [ + "id" + ], + "flags": {}, + "options": [ + "--yes" + ], + "confirm": true + }, + "webhook_endpoints test": { + "args": [ + "id" + ], + "flags": { + "type": { + "type": "string", + "required": true + } + }, + "options": [], + "confirm": false + }, + "charges create": { + "args": [], + "flags": { + "amount": { + "type": "integer", + "required": true + }, + "currency": { + "type": "string", + "required": true + }, + "subscription-id": { + "type": "string", + "required": true + } + }, + "options": [ + "--from-json" + ], + "confirm": false + }, + "charges get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "charges list": { + "args": [], + "flags": { + "status": { + "type": "string", + "required": false + }, + "since": { + "type": "string", + "required": false + }, + "until": { + "type": "string", + "required": false + }, + "subscription-id": { + "type": "string", + "required": false + } + }, + "options": [ + "--limit" + ], + "confirm": false + }, + "subscriptions get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "subscriptions list": { + "args": [], + "flags": { + "since": { + "type": "string", + "required": false + }, + "until": { + "type": "string", + "required": false + } + }, + "options": [ + "--limit" + ], + "confirm": false + }, + "links get": { + "args": [ + "link_token" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "links list": { + "args": [], + "flags": { + "status": { + "type": "string", + "required": false + } + }, + "options": [ + "--limit" + ], + "confirm": false + }, + "links delete": { + "args": [ + "id" + ], + "flags": {}, + "options": [ + "--yes" + ], + "confirm": true + }, + "checkout_sessions create": { + "args": [], + "flags": { + "amount": { + "type": "integer", + "required": true + }, + "currency": { + "type": "string", + "required": true + }, + "success-url": { + "type": "string", + "required": false + }, + "cancel-url": { + "type": "string", + "required": false + }, + "customer-email": { + "type": "string", + "required": false + }, + "recipient-account-type": { + "type": "string", + "required": false + }, + "recipient-account-number": { + "type": "string", + "required": false + }, + "recipient-account-holder-id": { + "type": "string", + "required": false + }, + "recipient-account-institution-id": { + "type": "string", + "required": false + }, + "business-profile-tax-id": { + "type": "string", + "required": false + }, + "business-profile-name": { + "type": "string", + "required": false + }, + "business-profile-category": { + "type": "string", + "required": false + } + }, + "options": [ + "--from-json" + ], + "confirm": false + }, + "checkout_sessions get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "checkout_sessions expire": { + "args": [ + "id" + ], + "flags": {}, + "options": [ + "--yes" + ], + "confirm": true + }, + "v2 account_verifications create": { + "args": [], + "flags": { + "account-number": { + "type": "string", + "required": true + } + }, + "options": [ + "--from-json", + "--jws-private-key" + ], + "confirm": false + }, + "v2 account_verifications get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "v2 account_verifications list": { + "args": [], + "flags": {}, + "options": [ + "--limit" + ], + "confirm": false + }, + "v2 account_numbers create": { + "args": [], + "flags": { + "account-id": { + "type": "string", + "required": true + }, + "description": { + "type": "string", + "required": false + } + }, + "options": [ + "--from-json" + ], + "confirm": false + }, + "v2 account_numbers get": { + "args": [ + "id" + ], + "flags": {}, + "options": [], + "confirm": false + }, + "v2 account_numbers list": { + "args": [], + "flags": {}, + "options": [ + "--limit" + ], + "confirm": false + }, + "v2 account_numbers delete": { + "args": [ + "id" + ], + "flags": {}, + "options": [ + "--yes" + ], + "confirm": true + }, + "v2 movements get": { + "args": [ + "id" + ], + "flags": { + "account-id": { + "type": "string", + "required": true + } + }, + "options": [], + "confirm": false + }, + "v2 movements list": { + "args": [], + "flags": { + "account-id": { + "type": "string", + "required": true + }, + "since": { + "type": "string", + "required": false + }, + "until": { + "type": "string", + "required": false + }, + "resource-id": { + "type": "string", + "required": false + } + }, + "options": [ + "--limit" + ], + "confirm": false + }, + "api_keys list": { + "args": [], + "flags": {}, + "options": [ + "--limit" + ], + "confirm": false + } + } +} From 2b5939d9301151601ba2f1e9a187740fe57c1e65 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 12:13:58 -0300 Subject: [PATCH 06/15] feat: ofrecer JSON y descubrimiento de comandos para scripts --- src/__tests__/index.test.ts | 51 ++++++++ src/commands/__tests__/config.test.ts | 2 +- src/commands/__tests__/login.test.ts | 78 ++++++++++-- src/commands/commands.ts | 55 ++++++++ src/commands/config.ts | 57 +++++---- src/commands/doctor.ts | 175 +++++++++++++++++--------- src/commands/login.ts | 98 +++++++++------ src/commands/logout.ts | 8 +- src/commands/open.ts | 14 ++- src/commands/trigger.ts | 17 +-- src/commands/webhooks.ts | 2 +- src/index.ts | 52 +++++++- 12 files changed, 461 insertions(+), 148 deletions(-) create mode 100644 src/commands/commands.ts diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 8f08b20..c941e7e 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -178,3 +178,54 @@ describe('error formatting', () => { }) }) }) + +describe('json errors', () => { + test.each([ + [['charges', 'create', '--amount', '1.5'], 'invalid_value', 'amount'], + [['charges', 'list', '--bogus'], 'unknown_option', 'bogus'], + [ + ['v2', 'subscription_items', 'update', 'si_1', '--quantity', '2'], + 'missing_flag', + 'subscription-id', + ], + ])('%j emits a JSON usage error on stderr', (args, code, param) => { + const { stdout, stderr, exitCode } = run(['--json', ...args], true) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(JSON.parse(stderr).error).toMatchObject({ type: 'usage_error', code, param }) + }) +}) + +describe('published help commands', () => { + test.each([ + ['--json', 'v2', 'transfers'], + ['config', 'help'], + ['config', 'help', 'set'], + ])('%j keeps help output and exit 0', (...args) => { + const { stdout, stderr, exitCode } = run(args) + expect(exitCode).toBe(0) + expect(stderr).toBe('') + expect(stdout).toContain('Usage:') + }) +}) + +describe('commands', () => { + test('lists every resource command as JSON', () => { + const commands = JSON.parse(run(['--json', 'commands']).stdout) as { + command: string + kind: string + options: string[] + }[] + const names = commands.map((c) => c.command) + expect(names).toContain('fintoc v2 transfers create') + expect(names).toEqual( + expect.arrayContaining(['fintoc login', 'fintoc config set', 'fintoc doctor']), + ) + expect(names).not.toContain('fintoc config') + expect(commands.find((c) => c.command === 'fintoc trigger')).toMatchObject({ + kind: 'utility', + options: ['--override', '--from-json'], + }) + }) +}) diff --git a/src/commands/__tests__/config.test.ts b/src/commands/__tests__/config.test.ts index 64ef65d..172a2df 100644 --- a/src/commands/__tests__/config.test.ts +++ b/src/commands/__tests__/config.test.ts @@ -289,7 +289,7 @@ describe('config set command', () => { ).rejects.toThrow('exit 1') expect(error).toHaveBeenCalledWith( - expect.stringContaining("Unknown config key: 'unknown_key'"), + expect.stringContaining("Unknown config key 'unknown_key'"), ) expect(writeConfig).not.toHaveBeenCalled() diff --git a/src/commands/__tests__/login.test.ts b/src/commands/__tests__/login.test.ts index 3fe1c8e..709ddd0 100644 --- a/src/commands/__tests__/login.test.ts +++ b/src/commands/__tests__/login.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { whoami } from '../../lib/auth.js' import { BrowserLoginError, startBrowserLogin } from '../../lib/browser-login.js' import { readConfig, writeConfig } from '../../lib/config.js' -import { error, hint, success, warn } from '../../lib/output.js' +import { error, hint, printJson, success, warn } from '../../lib/output.js' import { loginCommand } from '../login.js' vi.mock('../../lib/auth.js', () => ({ @@ -35,6 +35,7 @@ vi.mock('../../lib/output.js', () => ({ success: vi.fn(), error: vi.fn(), warn: vi.fn(), + printJson: vi.fn(), })) vi.mock('@inquirer/prompts', () => ({ @@ -63,7 +64,9 @@ const mockBrowserRejects = (err: unknown) => const createProgram = () => { const program = new Command() program.exitOverride() - program.option('--api-key ', 'Override API key for this command') + program + .option('--api-key ', 'Override API key for this command') + .option('--json', 'Output as JSON') loginCommand(program) return program } @@ -74,7 +77,6 @@ describe('login command', () => { beforeEach(() => { vi.clearAllMocks() process.stdin.isTTY = true - // Default: the inline paste prompt hangs so the callback wins the race. vi.mocked(password).mockImplementation(() => never()) }) @@ -82,6 +84,61 @@ describe('login command', () => { process.stdin.isTTY = originalIsTTY }) + describe('JSON mode', () => { + test('rejects browser login without starting an interactive flow', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit') + }) + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect( + createProgram().parseAsync(['login', '--json'], { from: 'user' }), + ).rejects.toThrow('exit') + expect(JSON.parse(stderr.mock.calls[0][0]).error).toMatchObject({ + code: 'missing_input', + param: 'api-key', + }) + expect(startBrowserLogin).not.toHaveBeenCalled() + expect(password).not.toHaveBeenCalled() + exit.mockRestore() + stderr.mockRestore() + }) + + test('requires --yes to replace credentials even in a TTY', async () => { + vi.mocked(readConfig).mockReturnValueOnce({ secret_key: 'sk_test_old' }) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit') + }) + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect( + createProgram().parseAsync(['login', '--json', '--api-key', 'sk_test_new'], { + from: 'user', + }), + ).rejects.toThrow('exit') + expect(JSON.parse(stderr.mock.calls[0][0]).error.code).toBe('confirmation_required') + expect(confirm).not.toHaveBeenCalled() + expect(writeConfig).not.toHaveBeenCalled() + exit.mockRestore() + stderr.mockRestore() + }) + + test('returns JSON when the key is supplied explicitly', async () => { + vi.mocked(whoami).mockResolvedValue({ + organizationName: 'Acme Corp', + mode: 'test', + apiVersion: '2026-01-01', + }) + await createProgram().parseAsync(['login', '--json', '--api-key', 'sk_test_new', '--yes'], { + from: 'user', + }) + expect(printJson).toHaveBeenCalledWith( + expect.objectContaining({ authenticated: true, organization: 'Acme Corp' }), + ) + expect(success).not.toHaveBeenCalled() + expect(hint).not.toHaveBeenCalled() + expect(confirm).not.toHaveBeenCalled() + }) + }) + describe('when called without flags', () => { test('runs browser flow in test mode and saves the result', async () => { mockBrowserCallback({ @@ -173,7 +230,7 @@ describe('login command', () => { expect(confirm).toHaveBeenCalled() expect(startBrowserLogin).not.toHaveBeenCalled() expect(writeConfig).not.toHaveBeenCalled() - expect(hint).toHaveBeenCalledWith('Login aborted.') + expect(hint).toHaveBeenCalledWith('Aborted.') }) }) @@ -190,7 +247,9 @@ describe('login command', () => { const program = createProgram() await program.parseAsync(['login'], { from: 'user' }) - expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ default: true })) + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ default: false }), { + output: process.stderr, + }) expect(startBrowserLogin).toHaveBeenCalled() expect(success).toHaveBeenCalledWith('Authenticated as Globex (test mode)') }) @@ -280,7 +339,7 @@ describe('login command', () => { await program.parseAsync(['login'], { from: 'user' }) expect(password).toHaveBeenCalled() - expect(hint).toHaveBeenCalledWith('Login aborted.') + expect(hint).toHaveBeenCalledWith('Aborted.') expect(writeConfig).not.toHaveBeenCalled() }) }) @@ -442,7 +501,7 @@ describe('login command', () => { await expect(program.parseAsync(['login'], { from: 'user' })).rejects.toThrow('process.exit') expect(startBrowserLogin).not.toHaveBeenCalled() - expect(error).toHaveBeenCalledWith(expect.stringContaining('Non-interactive terminal')) + expect(error).toHaveBeenCalledWith('Browser login needs an interactive terminal') exitSpy.mockRestore() }) @@ -486,8 +545,9 @@ describe('login command', () => { ).rejects.toThrow('process.exit') expect(whoami).not.toHaveBeenCalled() - expect(error).toHaveBeenCalledWith(expect.stringContaining('Non-interactive terminal')) - expect(hint).toHaveBeenCalledWith(expect.stringContaining('Re-run with --yes to override.')) + expect(error).toHaveBeenCalledWith( + expect.stringContaining('Confirmation required to replace the existing session'), + ) exitSpy.mockRestore() }) diff --git a/src/commands/commands.ts b/src/commands/commands.ts new file mode 100644 index 0000000..8dc7979 --- /dev/null +++ b/src/commands/commands.ts @@ -0,0 +1,55 @@ +import type { Command } from 'commander' +import type { CommandSchema } from '../resources/factory.js' +import { printJson, printTable } from '../lib/output.js' +import { describeCommands } from '../resources/factory.js' +import { resources, v1Resources } from '../resources/registry.js' + +const RESOURCE_COMMANDS = new Set(['v2', ...v1Resources.map((r) => r.cliCommand)]) + +const describeUtility = (cmd: Command, path: string[]): CommandSchema[] => { + const fullPath = [...path, cmd.name()] + if (cmd.commands.length > 0) { + return cmd.commands.flatMap((sub) => describeUtility(sub, fullPath)) + } + const options = cmd.options.flatMap((o) => (o.hidden || !o.long ? [] : [o.long])) + return [ + { + command: `fintoc ${fullPath.join(' ')}`, + aliases: [], + description: cmd.description(), + kind: 'utility', + args: cmd.registeredArguments.map((a) => ({ + name: a.name(), + ...(a.description ? { description: a.description } : {}), + })), + flags: [], + files: [], + options, + confirm: options.includes('--yes'), + }, + ] +} + +export const commandsCommand = (program: Command) => { + program + .command('commands') + .description('List all commands (with --json: arguments, flags and API parameters)') + .configureHelp({ showGlobalOptions: true }) + .action((_opts: unknown, actionCmd: Command) => { + const root = actionCmd.parent! + const commands = [ + ...root.commands + .filter((c) => !RESOURCE_COMMANDS.has(c.name())) + .flatMap((c) => describeUtility(c, [])), + ...describeCommands(resources), + ] + if (root.opts<{ json?: boolean }>().json) { + printJson(commands) + return + } + printTable({ + columns: ['command', 'description'], + rows: commands.map(({ command, description }) => ({ command, description })), + }) + }) +} diff --git a/src/commands/config.ts b/src/commands/config.ts index 2087db3..017d9a5 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -3,7 +3,8 @@ import type { AuthSource } from '../lib/auth.js' import type { FintocConfig } from '../types.js' import { maskKey, resolveAuth, whoami } from '../lib/auth.js' import { CONFIG_PATH, readConfig, writeConfig } from '../lib/config.js' -import { error, hint, log, printJson, success, warn } from '../lib/output.js' +import { handleError, UsageError } from '../lib/errors.js' +import { hint, log, printJson, success, warn } from '../lib/output.js' const ALLOWED_KEYS: readonly string[] = [ 'secret_key', @@ -100,30 +101,44 @@ export const configCommand = (program: Command) => { configCmd .command('set ') - .description('Set a config value (allowed keys: secret_key, jws_private_key, color)') - .action((key: string, value: string) => { - if (!isConfigKey(key)) { - error(`Unknown config key: '${key}'`) - hint('') - hint(` Allowed keys: ${ALLOWED_KEYS.join(', ')}`) - hint(` Example: fintoc config set jws_private_key /path/to/key.pem`) - process.exit(1) - } - - const config = readConfig() + .description('Set a config value (secret_key, jws_private_key, color)') + .action((key: string, value: string, _opts: unknown, cmd: Command) => { + const { json } = cmd.optsWithGlobals<{ json?: boolean }>() + try { + if (!isConfigKey(key)) { + throw new UsageError(`Unknown config key '${key}'`, { + code: 'invalid_value', + param: 'key', + hints: [ + `Allowed keys: ${ALLOWED_KEYS.join(', ')}`, + 'Example: fintoc config set jws_private_key /path/to/key.pem', + ], + }) + } - if (key === 'color') { - if (value !== 'true' && value !== 'false') { - error(`Invalid value for 'color': must be 'true' or 'false'`) - process.exit(1) + const config = readConfig() + + if (key === 'color') { + if (value !== 'true' && value !== 'false') { + throw new UsageError(`Invalid value '${value}' for 'color': expected true or false`, { + code: 'invalid_value', + param: 'value', + }) + } + config[key] = value === 'true' + } else { + config[key] = value } - config[key] = value === 'true' - } else { - config[key] = value - } - writeConfig(config) + writeConfig(config) + } catch (err) { + handleError(err, { json }) + } + if (json) { + printJson({ updated: key, config_path: CONFIG_PATH }) + return + } success(`Config updated: ${key}`) hint(` Saved to ${CONFIG_PATH}`) }) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 806ba0a..3e5090d 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -8,21 +8,32 @@ import { CONFIG_FILE_PERMISSIONS, DASHBOARD_API_KEYS_URL, IP_ALLOWLIST_ERROR_CODES, + JWS_COMMANDS, NPM_CHECK_TIMEOUT_MS, NPM_PACKAGE_NAME, } from '../lib/constants.js' import { parseFintocError } from '../lib/errors.js' -import { error, hint, info, success, warn } from '../lib/output.js' +import { error, hint, info, printJson, success, warn } from '../lib/output.js' import { getCliVersion } from '../lib/version.js' const LABEL_WIDTH = 20 const DETAIL_INDENT = 2 + LABEL_WIDTH -const row = (label: string, value: string) => `${label.padEnd(LABEL_WIDTH)}${value}` +type CheckStatus = 'ok' | 'warn' | 'error' | 'info' -const detail = (text: string) => hint(`${' '.repeat(DETAIL_INDENT)}${text}`) +type CheckResult = { check: string; status: CheckStatus; message: string; details?: string[] } -const checkCliVersion = () => { +const SYMBOL_OUTPUT = { ok: success, warn, error, info } satisfies Record< + CheckStatus, + (m: string) => void +> + +const printCheck = ({ check, status, message, details = [] }: CheckResult) => { + SYMBOL_OUTPUT[status](`${check.padEnd(LABEL_WIDTH)}${message}`) + details.forEach((text) => hint(`${' '.repeat(DETAIL_INDENT)}${text}`)) +} + +const checkCliVersion = (): CheckResult => { const currentVersion = getCliVersion() try { const latest = execSync(`npm view ${NPM_PACKAGE_NAME} version`, { @@ -31,112 +42,158 @@ const checkCliVersion = () => { stdio: ['pipe', 'pipe', 'pipe'], }).trim() - if (latest === currentVersion) { - success(row('CLI version', `${currentVersion} (latest)`)) - } else { - warn(row('CLI version', `${currentVersion} (latest: ${latest})`)) - } + return latest === currentVersion + ? { check: 'CLI version', status: 'ok', message: `${currentVersion} (latest)` } + : { check: 'CLI version', status: 'warn', message: `${currentVersion} (latest: ${latest})` } } catch { - success(row('CLI version', currentVersion)) - detail('(could not check for updates)') + return { + check: 'CLI version', + status: 'ok', + message: currentVersion, + details: ['(could not check for updates)'], + } } } -const checkConfigFile = (authSource?: string) => { +const checkConfigFile = (authSource?: string): CheckResult => { if (!existsSync(CONFIG_PATH)) { - if (authSource) { - warn(row('Config file', `${CONFIG_PATH} not found (using ${authSource})`)) - } else { - error(row('Config file', `${CONFIG_PATH} not found`)) - } - return + return authSource + ? { + check: 'Config file', + status: 'warn', + message: `${CONFIG_PATH} not found (using ${authSource})`, + } + : { check: 'Config file', status: 'error', message: `${CONFIG_PATH} not found` } } - const stats = statSync(CONFIG_PATH) - const mode = stats.mode & 0o777 + const mode = statSync(CONFIG_PATH).mode & 0o777 if (mode !== CONFIG_FILE_PERMISSIONS) { - warn( - row('Config file', `${CONFIG_PATH} found (permissions: ${mode.toString(8)}, expected: 600)`), - ) - return + return { + check: 'Config file', + status: 'warn', + message: `${CONFIG_PATH} found (permissions: ${mode.toString(8)}, expected: 600)`, + } } - success(row('Config file', `${CONFIG_PATH} found`)) + return { check: 'Config file', status: 'ok', message: `${CONFIG_PATH} found` } } const checkApiKey = (options?: { apiKey?: string }) => { try { const auth = resolveAuth(options) - success(row('API key', `${maskKey(auth.secretKey)} (source: ${auth.source})`)) - return auth + const result: CheckResult = { + check: 'API key', + status: 'ok', + message: `${maskKey(auth.secretKey)} (source: ${auth.source})`, + } + return { auth, result } } catch { - error(row('API key', 'not configured')) - detail('Run `fintoc login` or set FINTOC_API_KEY') - return null + const result: CheckResult = { + check: 'API key', + status: 'error', + message: 'not configured', + details: ['Run `fintoc login` or set FINTOC_API_KEY'], + } + return { auth: null, result } } } -const checkConnectivity = async (secretKey: string) => { +const checkConnectivity = async (secretKey: string): Promise => { try { const info = await whoami(secretKey) - success(row('Connectivity', `${API_HOST} reachable`)) - success(row('Organization', `${info.organizationName} (${info.mode} mode)`)) + return [ + { check: 'Connectivity', status: 'ok', message: `${API_HOST} reachable` }, + { + check: 'Organization', + status: 'ok', + message: `${info.organizationName} (${info.mode} mode)`, + }, + ] } catch (err) { const fields = parseFintocError(err) if (fields?.code && IP_ALLOWLIST_ERROR_CODES.has(fields.code)) { - error(row('Connectivity', `${API_HOST} reachable, but your IP is not allow-listed`)) - detail(fields.message ?? 'Your IP is not in the allowed CIDR blocks.') - detail('Add your IP to the allow list in the dashboard:') - detail(DASHBOARD_API_KEYS_URL) - return + return [ + { + check: 'Connectivity', + status: 'error', + message: `${API_HOST} reachable, but your IP is not allow-listed`, + details: [ + fields.message ?? 'Your IP is not in the allowed CIDR blocks.', + 'Add your IP to the allow list in the dashboard:', + DASHBOARD_API_KEYS_URL, + ], + }, + ] } if (fields?.type === 'authentication_error') { - error(row('Connectivity', `${API_HOST} reachable, but the API key was rejected`)) - detail('Check your API key is valid for this environment.') - return + return [ + { + check: 'Connectivity', + status: 'error', + message: `${API_HOST} reachable, but the API key was rejected`, + details: ['Check your API key is valid for this environment.'], + }, + ] } - error(row('Connectivity', `could not reach ${API_HOST}`)) - detail('Check your internet connection and API key') + return [ + { + check: 'Connectivity', + status: 'error', + message: `could not reach ${API_HOST}`, + details: ['Check your internet connection and API key'], + }, + ] } } -const checkJwsKey = () => { +const checkJwsKey = (): CheckResult => { const config = readConfig() if (!config.jws_private_key) { - info(row('JWS private key', 'not configured (only needed for transfers create)')) - return + return { + check: 'JWS private key', + status: 'info', + message: `not configured (only needed for ${JWS_COMMANDS})`, + } } if (!existsSync(config.jws_private_key)) { - error(row('JWS private key', `file not found: ${config.jws_private_key}`)) - return + return { + check: 'JWS private key', + status: 'error', + message: `file not found: ${config.jws_private_key}`, + } } - success(row('JWS private key', config.jws_private_key)) + return { check: 'JWS private key', status: 'ok', message: config.jws_private_key } } export const doctorCommand = (program: Command) => { const cmd = program.command('doctor').description('Check CLI setup and connectivity') cmd.configureHelp({ showGlobalOptions: true }) cmd.action(async (_opts: unknown, actionCmd: Command) => { - const rootOpts = actionCmd.parent!.opts<{ apiKey?: string }>() - hint('') - checkCliVersion() - - const auth = checkApiKey(rootOpts) - checkConfigFile(auth?.source) + const rootOpts = actionCmd.parent!.opts<{ apiKey?: string; json?: boolean }>() + const { auth, result: apiKey } = checkApiKey(rootOpts) + const checks: CheckResult[] = [checkCliVersion(), apiKey, checkConfigFile(auth?.source)] if (auth) { - await checkConnectivity(auth.secretKey) + checks.push(...(await checkConnectivity(auth.secretKey))) } else { - info(row('Connectivity', 'skipped (no API key)')) - info(row('Organization', 'skipped (no API key)')) + checks.push( + { check: 'Connectivity', status: 'info', message: 'skipped (no API key)' }, + { check: 'Organization', status: 'info', message: 'skipped (no API key)' }, + ) } + checks.push(checkJwsKey()) - checkJwsKey() + if (rootOpts.json) { + printJson({ ok: checks.every((c) => c.status !== 'error'), checks }) + return + } + hint('') + checks.forEach(printCheck) hint('') }) } diff --git a/src/commands/login.ts b/src/commands/login.ts index a213359..4498777 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -10,11 +10,11 @@ import { whoami } from '../lib/auth.js' import { BrowserLoginError, startBrowserLogin } from '../lib/browser-login.js' import { CONFIG_PATH, readConfig, writeConfig } from '../lib/config.js' import { BROWSER_LOGIN_TIMEOUT_MS } from '../lib/constants.js' -import { handleError } from '../lib/errors.js' -import { error, hint, info, success, warn } from '../lib/output.js' +import { handleError, UsageError } from '../lib/errors.js' +import { error, hint, info, printJson, success, warn } from '../lib/output.js' type LoginOpts = { mode: FintocMode; yes?: boolean } -type RootOpts = { apiKey?: string } +type RootOpts = { apiKey?: string; json?: boolean } const modeFromSecret = (secret: string): FintocMode | null => { if (secret.startsWith('sk_live_')) { @@ -29,7 +29,11 @@ const modeFromSecret = (secret: string): FintocMode | null => { const isPromptCancelled = (err: unknown): boolean => err instanceof Error && err.name === 'ExitPromptError' -const persistAndAnnounce = (existingConfig: FintocConfig, outcome: BrowserLoginResult) => { +const persistAndAnnounce = ( + existingConfig: FintocConfig, + outcome: BrowserLoginResult, + json?: boolean, +) => { const next: FintocConfig = { ...existingConfig, secret_key: outcome.secret, @@ -40,8 +44,18 @@ const persistAndAnnounce = (existingConfig: FintocConfig, outcome: BrowserLoginR try { writeConfig(next) } catch { - error(`Authentication succeeded but could not write to ${CONFIG_PATH}`) - process.exit(1) + throw new Error(`Authentication succeeded but could not write to ${CONFIG_PATH}`) + } + + if (json) { + printJson({ + authenticated: true, + organization: outcome.organizationName, + mode: outcome.mode, + key_name: outcome.keyName ?? null, + config_path: CONFIG_PATH, + }) + return } success(`Authenticated as ${outcome.organizationName} (${outcome.mode} mode)`) @@ -58,22 +72,24 @@ const persistAndAnnounce = (existingConfig: FintocConfig, outcome: BrowserLoginR } } -const saveSecret = async (existingConfig: FintocConfig, secretKey: string) => { +const saveSecret = async (existingConfig: FintocConfig, secretKey: string, json?: boolean) => { if (!modeFromSecret(secretKey)) { - error(`Invalid key format. Expected 'sk_test_...' or 'sk_live_...'.`) - process.exit(1) - } - try { - const { organizationName, mode } = await whoami(secretKey) - persistAndAnnounce(existingConfig, { secret: secretKey, organizationName, mode }) - } catch (err) { - handleError(err) + throw new UsageError(`Invalid key format: expected 'sk_test_...' or 'sk_live_...'`, { + code: 'invalid_value', + param: 'api-key', + }) } + const { organizationName, mode } = await whoami(secretKey) + persistAndAnnounce(existingConfig, { secret: secretKey, organizationName, mode }, json) } -type ConfirmReloginOpts = { config: FintocConfig; skipPrompt: boolean } +type ConfirmReloginOpts = { config: FintocConfig; skipPrompt: boolean; json?: boolean } -const confirmRelogin = async ({ config, skipPrompt }: ConfirmReloginOpts): Promise => { +const confirmRelogin = async ({ + config, + skipPrompt, + json, +}: ConfirmReloginOpts): Promise => { if (skipPrompt || !config.secret_key) { return true } @@ -81,19 +97,20 @@ const confirmRelogin = async ({ config, skipPrompt }: ConfirmReloginOpts): Promi const mode = modeFromSecret(config.secret_key) ?? 'test' const keySuffix = config.key_name ? ` (key '${config.key_name}')` : '' - if (!process.stdin.isTTY) { - error(`Non-interactive terminal detected. Cannot prompt to override existing session.`) - hint(`\n Existing session: ${mode} mode${keySuffix}.`) - hint(` Re-run with --yes to override.`) - process.exit(1) + const phrase = `replace the existing session (${mode} mode${keySuffix})` + if (json || !process.stdin.isTTY) { + throw new UsageError(`Confirmation required to ${phrase}. Use --yes to skip.`, { + code: 'confirmation_required', + param: 'yes', + }) } - const proceed = await confirm({ - message: `Already authenticated in ${mode} mode${keySuffix}. Continue?`, - default: true, - }) + const proceed = await confirm( + { message: `Are you sure you want to ${phrase}?`, default: false }, + { output: process.stderr }, + ) if (!proceed) { - hint('Login aborted.') + hint('Aborted.') } return proceed } @@ -106,7 +123,7 @@ const promptPaste = (signal: AbortSignal) => validate: (v) => modeFromSecret(v.trim()) !== null || `Expected 'sk_test_...' or 'sk_live_...'`, }, - { signal }, + { signal, output: process.stderr }, ).then((s) => s.trim()) const handleBrowserError = (err: BrowserLoginError, mode: FintocMode): never => { @@ -179,32 +196,35 @@ export const loginCommand = (program: Command) => { .option('-y, --yes', 'Skip confirmation when overriding an existing session') .configureHelp({ showGlobalOptions: true }) .action(async (opts: LoginOpts, actionCmd: Command) => { + const { apiKey, json } = actionCmd.optsWithGlobals() try { - const { apiKey } = actionCmd.optsWithGlobals() - - if (!process.stdin.isTTY && !apiKey) { - error('Non-interactive terminal detected.') - hint('\n Pass the key via flag: fintoc login --api-key sk_...') - hint(' Or set env: export FINTOC_API_KEY=sk_...') - process.exit(1) + if ((json || !process.stdin.isTTY) && !apiKey) { + throw new UsageError( + json ? 'JSON login requires --api-key' : 'Browser login needs an interactive terminal', + { + code: 'missing_input', + param: 'api-key', + hints: ['Pass the key via flag: fintoc login --api-key sk_...'], + }, + ) } const existingConfig = readConfig() - if (!(await confirmRelogin({ config: existingConfig, skipPrompt: !!opts.yes }))) { + if (!(await confirmRelogin({ config: existingConfig, skipPrompt: !!opts.yes, json }))) { return } if (apiKey) { - await saveSecret(existingConfig, apiKey) + await saveSecret(existingConfig, apiKey, json) return } await runBrowserFlow(existingConfig, opts.mode) } catch (err) { if (isPromptCancelled(err)) { - hint('Login aborted.') + hint('Aborted.') return } - handleError(err) + handleError(err, { json }) } }) } diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 85ecee9..cadcd81 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,12 +1,16 @@ import type { Command } from 'commander' import { clearConfig, CONFIG_PATH } from '../lib/config.js' -import { success } from '../lib/output.js' +import { printJson, success } from '../lib/output.js' export const logoutCommand = (program: Command) => { const cmd = program.command('logout').description('Remove stored credentials') cmd.configureHelp({ showGlobalOptions: true }) - cmd.action(() => { + cmd.action((_opts: unknown, actionCmd: Command) => { clearConfig() + if (actionCmd.parent!.opts<{ json?: boolean }>().json) { + printJson({ logged_out: true, config_path: CONFIG_PATH }) + return + } success(`Credentials removed from ${CONFIG_PATH}`) }) } diff --git a/src/commands/open.ts b/src/commands/open.ts index 062b563..56bcf09 100644 --- a/src/commands/open.ts +++ b/src/commands/open.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander' import { openInBrowser } from '../lib/browser.js' import { addDefaultAction } from '../lib/commands.js' import { DASHBOARD_URL } from '../lib/constants.js' -import { hint, success } from '../lib/output.js' +import { hint, printJson, success } from '../lib/output.js' export const openCommand = (program: Command) => { const open = program.command('open').description('Open Fintoc resources in the browser') @@ -11,11 +11,19 @@ export const openCommand = (program: Command) => { open .command('dashboard') .description('Open the Fintoc dashboard') - .action(async () => { + .action(async (_opts: unknown, actionCmd: Command) => { + const { json } = actionCmd.optsWithGlobals<{ json?: boolean }>() + let opened = true try { await openInBrowser(DASHBOARD_URL) - success(`Opening ${DASHBOARD_URL} in your browser...`) } catch { + opened = false + } + if (json) { + printJson({ url: DASHBOARD_URL, opened }) + } else if (opened) { + success(`Opening ${DASHBOARD_URL} in your browser...`) + } else { hint(`Could not open browser automatically. Visit: ${DASHBOARD_URL}`) } }) diff --git a/src/commands/trigger.ts b/src/commands/trigger.ts index 70de23f..8fb9e39 100644 --- a/src/commands/trigger.ts +++ b/src/commands/trigger.ts @@ -14,7 +14,7 @@ type RootOpts = { } type TriggerOpts = { - override: Record + override?: Record fromJson?: string } @@ -26,7 +26,10 @@ const tryJsonParse = (value: string): unknown => { } } -const collectOverride = (value: string, acc: Record): Record => { +const collectOverride = ( + value: string, + acc: Record = {}, +): Record => { const eqIndex = value.indexOf('=') if (eqIndex === -1) { throw new InvalidArgumentError( @@ -36,7 +39,7 @@ const collectOverride = (value: string, acc: Record): Record segment.length === 0)) { throw new InvalidArgumentError( - `Invalid override key in '${value}'. Keys must be non-empty (use dot-notation, e.g. metadata.order_id).`, + `Invalid override key in '${value}'. Keys must be non-empty (use dot-notation, e.g., metadata.order_id).`, ) } const rawValue = value.slice(eqIndex + 1) @@ -64,12 +67,11 @@ export const triggerCommand = (program: Command) => { const cmd = program .command('trigger') .description('Trigger a test event') - .argument('', 'Event type (e.g. payment_intent.succeeded)') + .argument('', 'Event type (e.g., payment_intent.succeeded)') .option( '--override ', 'Override a field in the event payload (dot-notation, repeatable)', collectOverride, - {} as Record, ) .option( '--from-json ', @@ -79,10 +81,9 @@ export const triggerCommand = (program: Command) => { cmd.action(async (eventType: string, opts: TriggerOpts, actionCmd: Command) => { const rootOpts = actionCmd.parent!.opts() - const base = opts.fromJson ? readJsonBody(opts.fromJson) : {} - const overrides = deepMerge(base, opts.override) - try { + const base = opts.fromJson ? readJsonBody(opts.fromJson) : {} + const overrides = deepMerge(base, opts.override ?? {}) const auth = resolveAuth(rootOpts) const client = createClient(auth.secretKey) const args: Parameters[0] = { type: eventType } diff --git a/src/commands/webhooks.ts b/src/commands/webhooks.ts index ea135c9..a44e8d8 100644 --- a/src/commands/webhooks.ts +++ b/src/commands/webhooks.ts @@ -55,7 +55,6 @@ export const webhooksCommand = (program: Command) => { ) .action(async (opts: ListenOpts, actionCmd: Command) => { const rootOpts = actionCmd.parent!.parent!.opts() - const auth = resolveAuth(rootOpts) const shutdown = () => { process.exit(0) @@ -66,6 +65,7 @@ export const webhooksCommand = (program: Command) => { let caughtError: unknown try { + const auth = resolveAuth(rootOpts) const session = await createCliSession({ secretKey: auth.secretKey, streamType: 'webhook_event', diff --git a/src/index.ts b/src/index.ts index 01048ef..7b3fd79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import type { Help, Option } from 'commander' import { Command, CommanderError } from 'commander' +import { commandsCommand } from './commands/commands.js' import { configCommand } from './commands/config.js' import { doctorCommand } from './commands/doctor.js' import { loginCommand } from './commands/login.js' @@ -17,7 +18,7 @@ declare const __CLI_VERSION__: string const versionString = `fintoc/${__CLI_VERSION__} ${process.platform} node-${process.version}` const AUTH_COMMANDS = new Set(['login', 'logout', 'config']) -const UTILITY_COMMANDS = new Set(['doctor', 'open', 'trigger', 'webhooks']) +const UTILITY_COMMANDS = new Set(['commands', 'doctor', 'open', 'trigger', 'webhooks']) type HelpEntry = { name: () => string; description: () => string } @@ -49,12 +50,46 @@ const formatOptions = (lines: string[], title: string, options: Option[], helper } } +const wantsJson = process.argv.includes('--json') + +// Same codes as the UsageErrors thrown by the resource factory, so agents see one vocabulary +const COMMANDER_ERROR_CODES: Record = { + 'commander.invalidArgument': 'invalid_value', + 'commander.missingMandatoryOptionValue': 'missing_flag', + 'commander.optionMissingArgument': 'missing_value', + 'commander.missingArgument': 'missing_argument', + 'commander.unknownOption': 'unknown_option', + 'commander.unknownCommand': 'unknown_command', + 'commander.missingCommand': 'missing_command', + 'commander.excessArguments': 'too_many_arguments', +} + +const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1) + +const commanderErrorJson = (err: CommanderError) => { + const message = capitalize( + err.message + .replace(/^error:\s*/i, '') + .replace(/\s*\n\s*/g, ' ') + .trim(), + ) + return { + error: { + type: 'usage_error', + code: COMMANDER_ERROR_CODES[err.code] ?? 'invalid_usage', + param: message.match(/'--([\w-]+)/)?.[1], + message, + }, + } +} + const program = new Command() program .name('fintoc') .description('Fintoc CLI — manage your Fintoc resources from the terminal') - .version(versionString, '-v, --version') + .version(versionString, '-v, --version', 'Show the CLI version') + .helpOption('-h, --help', 'Show help for a command') .option('--api-key ', 'Override API key for this command') .option('--json', 'Output as JSON') .option('--no-color', 'Disable colored output') @@ -62,10 +97,13 @@ program .showSuggestionAfterError(true) .configureOutput({ writeErr: (str: string) => { + if (wantsJson) { + return + } const match = str.match(/^error:\s*(.*)/is) const message = match ? match[1].trim() : str.trim() if (message) { - error(message) + error(capitalize(message)) } }, writeOut: (str: string) => { @@ -80,6 +118,7 @@ doctorCommand(program) openCommand(program) triggerCommand(program) webhooksCommand(program) +commandsCommand(program) registerResourceCommands(program, v1Resources) const v2Cmd = program.command('v2').description('API v2 resources') @@ -93,7 +132,7 @@ program.configureHelp({ lines.push(cmd.description()) lines.push('') - lines.push(`Usage: ${cmd.name()} [flags]`) + lines.push(`Usage: ${cmd.name()} [options]`) const subcommands = cmd.commands @@ -119,7 +158,7 @@ program.configureHelp({ formatGroup(lines, 'Resources', resourceEntries, padWidth) formatGroup(lines, 'Utilities', utilityEntries, padWidth) - formatOptions(lines, 'Flags', helper.visibleOptions(cmd), helper) + formatOptions(lines, 'Options', helper.visibleOptions(cmd), helper) lines.push('') lines.push('Get started: fintoc login') @@ -144,6 +183,9 @@ try { program.parse() } catch (err) { if (err instanceof CommanderError) { + if (wantsJson && err.exitCode !== 0) { + process.stderr.write(`${JSON.stringify(commanderErrorJson(err))}\n`) + } process.exit(err.exitCode) } throw err From 000a120f67b73f1e23eb6c537f034ac0b6ecd0f5 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 12:14:14 -0300 Subject: [PATCH 07/15] =?UTF-8?q?docs:=20explicar=20recursos,=20paginaci?= =?UTF-8?q?=C3=B3n=20y=20contratos=20de=20la=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/add-api-resource/SKILL.md | 35 ++++-- README.md | 139 ++++++++++++++++++----- 2 files changed, 136 insertions(+), 38 deletions(-) diff --git a/.agents/skills/add-api-resource/SKILL.md b/.agents/skills/add-api-resource/SKILL.md index 7f21c08..38a423d 100644 --- a/.agents/skills/add-api-resource/SKILL.md +++ b/.agents/skills/add-api-resource/SKILL.md @@ -17,11 +17,13 @@ Check the SDK first. Look for the manager in `node_modules/fintoc/build/main/lib | --- | --- | | New resource | Append a registry entry at the end of the array. Copy `account_numbers` (v2, create and delete). | | New verb | Add it to the entry's `verbs`, with its flags when it takes any. | -| New flag or filter | Add it to the verb's flags (`createFlags`, `listFlags`, `getFlags`, `testFlags`). | -| Nested resource | Use a dotted `sdkMethod`. Copy `movements`. | +| Any other SDK method (`cancel`, `expire`, `submit`, uploads) | Add an entry to `actions`. Copy `v2 invoices` (body flags), `v2 onboardings` (extra positional args and files) or `v1 refunds` (`voucher_url`, a read). | +| New flag or filter | Add it to the verb's flags (`createFlags`, `listFlags`, `getFlags`, `updateFlags`, `deleteFlags`) or to the action's `flags`. | +| Nested resource | Use a dotted `sdkMethod` and a `pathParam` flag for the parent id. Copy `movements` or `subscription_items`. | +| File upload | Add `createFiles` or the action's `files`. Set `fileAsArg` only when the SDK takes the file as a positional argument instead of inside the body. | | New field | Usually no change, since `get` and `--json` show every field. Add a flag only for a new create param or list filter, and touch `priorityColumns` only if the field matters in `list`. | -Do not rename or remove existing flags or verbs, since users' scripts depend on them. +Do not rename or remove existing flags or verbs, since users' scripts depend on them. To retire a flag, set `deprecated` with the reason: it is hidden from help, not sent, and warns when used. `backward-compat.test.ts` enforces this against `fixtures/published-surface.json`: removing a command, flag or option, changing a flag's type or the positional arguments, making a flag required, or adding a confirmation fails. After publishing a release, run `UPDATE_CLI_SURFACE=1 npm run test` and update the fixture source commit to protect the newly published commands. Do not add unpublished commands to this baseline. That only adds to the fixture, so it cannot hide a breaking change. Set `idempotencyKey: true` on actions that send a POST. ## Registry entry @@ -31,20 +33,35 @@ Do not rename or remove existing flags or verbs, since users' scripts depend on | `displayName` | Singular, human readable (`account number`) | | `sdkNamespace` | `'v1'` for `Fintoc`, `'v2'` for `FintocV2` | | `sdkMethod` | camelCase manager property (`accountNumbers`). Nested managers use a dotted path (`accounts.movements`) | -| `verbs` | Only the ones in the manager's `methods`, out of `create`, `get`, `list`, `delete`, `expire`, `test` | -| `needsJws` | `true` when the API requires a JWS signature to create | +| `verbs` | Only the ones in the manager's `methods`, out of `create`, `get`, `list`, `update`, `delete` | +| `actions` | Every other method. `kind` is `read` or `list` for GETs, `write` (default) otherwise. Set `confirm` for destructive ones | +| `needsJws` | `true` when the API requires a JWS signature to create. Actions that sign set their own `needsJws` | | `priorityColumns` | 1 to 6 fields for `list`, usually `id` first | | `createFlags` | Must be non-empty when there is `create` | | `getArg` | Only when `get` takes something other than an id (see `links`) | -Flags: `name` is kebab-case and is sent as snake_case, so it must match the API param. Use `nestedPath` for fields inside an object (`counterparty.account_number`). Set `required: true` only if the API requires it. For enum filters like `status`, link the object docs with a `DOCS_*_URL` constant from `src/lib/constants.ts` instead of listing values. +Flags: `name` is kebab-case and is sent as snake_case, so it must match the API param. Use `nestedPath` for fields inside an object (`counterparty.account_number`). Set `required: true` only if the API requires it. Use type `map` for key-value objects such as `metadata` (`--metadata key=value`, repeatable). For enum filters like `status`, link the object docs with a `DOCS_*_URL` constant from `src/lib/constants.ts` instead of listing values. Watch out for: -- Nested managers only work for `get` and `list`. Limit `verbs` to those two and add the parent id as a required flag to each verb's flags (`getFlags`, `listFlags`), matching the SDK placeholder (`account-id` → `{account_id}`). +- Nested managers fill their parent id from a flag that matches the SDK placeholder (`account-id` → `{account_id}`). Mark it `pathParam: true` so it stays required even with `--from-json`. - A flag segment that starts with a digit (`address-line-1`) breaks the flag. Use a letter-only name and put the API param in `nestedPath` (`address_line_1`). -- The help text pluralizes with `${displayName}s`, so an irregular plural (`entity` → "entitys") reads wrong. Keep it and mention it in the PR. -- Anything the factory does not support (an `update` verb, new flag types) stays out. Changing the factory needs its own design and review, so mention it in the PR instead. +- The help text pluralizes with `${displayName}s`. Set `pluralDisplayName` for irregular plurals (`entity` → `entities`). +- When v1 and v2 share a resource name, give both a `description` that says which API they belong to (`Manage accounts (Transfers API)`). +- Anything the factory does not support (new flag types, arrays of objects as flags) goes through `--from-json`. Changing the factory needs its own design and review, so mention it in the PR instead. + +## Style + +Every command must read like the rest of the CLI. `registry.test.ts` checks most of these rules: + +- Descriptions are sentence case with no trailing period. Write "ID" in uppercase. +- Flags reuse the wording of the same flag elsewhere. The shared ones (`recipient-account-*`, `sender-account-*`, `business-profile-*`, `customer-email`, `metadata`, ...) must match exactly. +- Values: closed lists as `(a, b, c)`, examples as `(e.g., x)`, long or changing lists as `(see ${DOCS_*_URL})`. Country rules go last after a semicolon: `(checking_account, sight_account; required in Chile)` or `(Mexico only)`. +- Amounts say "in the smallest currency unit". Dates say `(ISO 8601)` and timestamps `(ISO 8601 datetime, UTC)`. Date filters are inclusive: "Show results created on or after this date (ISO 8601)". +- Update flags reuse the create description, without "New". Booleans describe the true case only. +- A command that acts on one object takes its ID as the first positional. If the SDK expects it in the body, set `bodyField` on the `ArgDef`. +- Write actions have a `successMessage` like `"{resource} '{id}' canceled"`. Confirmations read "Are you sure you want to {resource} '{id}'?"; set `confirmPhrase` when that reads wrong (`remove lines from {resource} '{id}'`). +- Path placeholders are `pathParam: true`, so they stay required and show as `in: path` in `fintoc commands --json`. ## Tests diff --git a/README.md b/README.md index 863cdc6..4a95188 100644 --- a/README.md +++ b/README.md @@ -68,39 +68,71 @@ fintoc logout # Remove stored credentials ### Auth -| Command | Description | -| -------------------- | ------------------------------ | -| `fintoc login` | Authenticate with your API key | -| `fintoc logout` | Remove stored credentials | -| `fintoc config show` | Show active configuration | +| Command | Description | +| --------------------------------- | ------------------------------------- | +| `fintoc login` | Authenticate with your Fintoc API key | +| `fintoc logout` | Remove stored credentials | +| `fintoc config show` | Show current configuration | +| `fintoc config set ` | Set a config value | ### Resources -Resources follow the pattern `fintoc [flags]`. - -| Resource | Actions | -| -------------------------- | ------------------------------- | -| `payment_intents` | get, list | -| `charges` | create, get, list | -| `webhook_endpoints` | create, get, list, delete, test | -| `checkout_sessions` | create, get, expire | -| `subscriptions` | get, list | -| `links` | get, list, delete | -| `api_keys` | list | -| `v2 transfers` | create, get, list | -| `v2 accounts` | get, list | -| `v2 account_verifications` | create, get, list | -| `v2 account_numbers` | create, get, list, delete | -| `v2 movements` | get, list | +Resources follow the pattern `fintoc [v2] [args] [flags]`. Actions that act on a single object take its ID as the first argument. + +| Resource | Actions | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `payment_intents` | create, get, list, expire | +| `webhook_endpoints` | create, get, list, update, delete, test | +| `charges` | create, get, list, update, cancel | +| `subscriptions` | get, list | +| `links` | get, list, update, delete, exchange | +| `checkout_sessions` | create, get, expire | +| `api_keys` | list | +| `refunds` | create, get, list, cancel, voucher_url | +| `subscription_intents` | create, get, list | +| `payment_links` | create, get, list, cancel | +| `disputes` | get, list, submit_for_review, create_document | +| `accounts` | get, list | +| `movements` | get, list | +| `invoices` | get, list | +| `tax_returns` | get, list | +| `refresh_intents` | create, get, list | +| `payouts` | get, list, resources | +| `institutions` | list | +| `link_intents` | create | +| `tax_statements` | get, list | +| `v2 transfers` | create, get, list, receipt, return | +| `v2 accounts` | create, get, list, update | +| `v2 account_verifications` | create, get, list | +| `v2 account_numbers` | create, get, list, update, delete | +| `v2 movements` | get, list | +| `v2 checkout_sessions` | create, get, list, expire | +| `v2 customers` | create, get, list | +| `v2 entities` | create, get, list, settlement_account | +| `v2 onboardings` | create, get, list, submit, upload_document, add_document, delete_document, upload_shareholder_document, upload_legal_representative_document | +| `v2 payment_intents` | create, get, list, cancel, voucher_url, check_eligibility | +| `v2 payment_methods` | create, get, list, cancel, mandate_document | +| `v2 products` | create, get, list, update | +| `v2 subscriptions` | create, get, list, update, cancel, detach_payment_method | +| `v2 invoices` | create, get, list, update, finalize, pay, void, mark_uncollectible, add_lines, remove_lines | +| `v2 account_statements` | list | +| `v2 recipients` | create, get, list, update, delete | +| `v2 transfer_batches` | create, get, list | +| `v2 events` | get, list | +| `v2 setup_intents` | get | +| `v2 subscription_items` | create, update, delete | +| `v2 invoice_lines` | update | +| `v2 simulate` | receive_transfer | ### Utilities -| Command | Description | -| ------------------------ | ----------------------------------------- | -| `fintoc doctor` | Check CLI setup and connectivity | -| `fintoc open dashboard` | Open the Fintoc dashboard in your browser | -| `fintoc webhooks listen` | Listen for webhook events in real time | -| `fintoc trigger` | Trigger a test event | +| Command | Description | +| ------------------------ | ---------------------------------------- | +| `fintoc doctor` | Check CLI setup and connectivity | +| `fintoc open dashboard` | Open the Fintoc dashboard | +| `fintoc webhooks listen` | Listen for webhook events in real time | +| `fintoc trigger` | Trigger a test event | +| `fintoc commands` | List all commands (`--json` for details) | ## Usage examples @@ -144,6 +176,55 @@ fintoc webhook_endpoints delete we_test_abc123 fintoc webhook_endpoints delete we_test_abc123 --yes # Skip confirmation (CI-friendly) ``` +### Update + +```bash +fintoc v2 products update prod_test_abc123 --name "Plan Pro" +fintoc v2 invoices update in_test_abc123 --metadata order_id=123 --metadata channel=web +``` + +`--metadata` and the other `key=value` flags are repeatable and build an object, one key per flag. + +### Arrays of objects + +Arrays of objects (subscription `items`, invoice `lines`, batch `transfers`), free-form objects, and explicit `null` values go in `--from-json`. Scalar fields and arrays of strings also have flags: + +```bash +# items.json: { "items": [{ "price_data": { ... }, "quantity": 1 }] } +fintoc v2 subscriptions create --customer cus_test_abc123 --from-json items.json +``` + +### Resource actions + +Actions such as `cancel`, `pay`, `void` or `voucher_url` follow the same pattern: the ID of the object goes first as a positional argument. Actions on an existing object that move money or can't be undone (`cancel`, `expire`, `pay`, `void`, `finalize`, `return`, ...) ask for confirmation, like `delete`; use `--yes` to skip it: + +```bash +fintoc refunds cancel re_test_abc123 --yes +fintoc v2 invoices pay in_test_abc123 --payment-method pm_test_abc123 +fintoc v2 payment_intents voucher_url pi_test_abc123 +fintoc payouts resources po_test_abc123 --resource-type payment_intent +``` + +### Upload files + +Multipart operations take one `--file =` per file. Run the command with `--help` to see its fields: + +```bash +fintoc v2 onboardings upload_document onb_test_abc123 proof_of_address \ + --entity-id ent_test_abc123 --file file=./proof.pdf +``` + +### Scripting and AI agents + +- `fintoc --json commands` lists every command with its arguments, flags (type, API parameter and whether it goes in the path, query or body), options and aliases. +- With `--json`, results go to stdout and errors go to stderr as `{"error": {"type", "code", "param", "message"}}`, and nothing else is written to stderr (except for `webhooks listen`, which streams status lines). Usage errors have `type: "usage_error"` and a stable `code` such as `missing_flag`, `invalid_value`, `unknown_option`, `unknown_command`. +- Without a TTY the CLI never prompts: commands that need confirmation fail until you pass `--yes`. When the API key is live, the confirmation says so. +- `list` returns up to `--limit` results without fetching another item to check for more. Without `--json` it warns on stderr when the limit is reached. Paginated v1 endpoints accept `--page` and `--per-page`; paginated v2 endpoints accept `--starting-after`, `--ending-before`, and `--page-size` (the API `limit`). These set the initial API page; the SDK follows subsequent pages until the CLI limit is reached. +- JSON mode never prompts: confirmed operations require `--yes`, and `login --json` requires `--api-key` (plus `--yes` when replacing a saved session). +- Help commands and resource groups without a subcommand keep their published text output and exit code 0, including with `--json`. Use `commands --json` for structured command discovery. +- `create` and actions that send a POST (`pay`, `cancel`, `return`, ...) accept `--idempotency-key`, so a retried request does not run twice. +- Resources and actions also answer to kebab-case (`payment-intents`, `submit-for-review`). Boolean flags work bare (`--active`) or with a value (`--active false`), and list flags take comma-separated or repeated values. + ### Trigger test events Fire a test event against your account — useful for exercising webhook handlers without producing real activity. Combine `--override` (dot-notation, repeatable) and `--from-json` to shape the payload; flags take precedence over JSON, and arrays are replaced wholesale (same semantics as `create --from-json`). @@ -165,9 +246,9 @@ fintoc webhooks listen --events payment.succeeded,payment.failed fintoc webhooks listen --forward-to http://localhost:3000/webhooks ``` -### V2 transfers (JWS required) +### Commands that need a JWS key -Transfers require a JWS private key for `create`: +`v2 transfers create`, `v2 transfers return`, `v2 transfer_batches create` and `v2 account_verifications create` are signed with a JWS private key: ```bash fintoc v2 transfers create --amount 10000 --currency CLP \ From 75f5ff5d9870dd39347b393afe8c5f726f3b1204 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 13:15:59 -0300 Subject: [PATCH 08/15] fixup! feat: centralizar errores de uso y sugerencias de comandos Co-Authored-By: Claude Opus 5.5 --- src/lib/__tests__/auth.test.ts | 3 ++ src/lib/__tests__/errors.test.ts | 70 +++++++++++++++++++++++++++++++- src/lib/auth.ts | 9 +++- src/lib/errors.ts | 17 +++++++- 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts index 0067416..129f273 100644 --- a/src/lib/__tests__/auth.test.ts +++ b/src/lib/__tests__/auth.test.ts @@ -2,6 +2,7 @@ import { Fintoc } from 'fintoc' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { createClient, maskKey, resolveAuth } from '../auth.js' import { readConfig } from '../config.js' +import { AuthError } from '../errors.js' vi.mock('fintoc', () => ({ Fintoc: vi.fn(), @@ -100,6 +101,7 @@ describe('resolveAuth', () => { vi.mocked(readConfig).mockReturnValue({ secret_key: 'sk_test_config' }) expect(() => resolveAuth({ apiKey: '' })).toThrow('API key is empty') + expect(() => resolveAuth({ apiKey: '' })).toThrow(AuthError) }) test('throws when --api-key is only whitespace', () => { @@ -114,6 +116,7 @@ describe('resolveAuth', () => { vi.mocked(readConfig).mockReturnValue({ secret_key: 'sk_test_config' }) expect(() => resolveAuth()).toThrow('FINTOC_API_KEY is set but empty') + expect(() => resolveAuth()).toThrow(AuthError) }) test('throws when FINTOC_API_KEY is only whitespace', () => { diff --git a/src/lib/__tests__/errors.test.ts b/src/lib/__tests__/errors.test.ts index 1144266..f5dff22 100644 --- a/src/lib/__tests__/errors.test.ts +++ b/src/lib/__tests__/errors.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { handleError, UsageError } from '../errors.js' +import { AuthError, handleError, UsageError } from '../errors.js' import { error, hint } from '../output.js' vi.mock('../output.js', () => ({ @@ -64,6 +64,22 @@ describe('handleError', () => { }) }) + describe('empty API key', () => { + test('prints the message without the login steps', () => { + const err = new AuthError( + 'API key is empty. Provide a valid key with --api-key or remove the flag.', + ) + + expect(() => handleError(err)).toThrow('process.exit') + + expect(error).toHaveBeenCalledWith( + 'API key is empty. Provide a valid key with --api-key or remove the flag.', + ) + expect(hint).not.toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + }) + }) + describe('connectivity errors', () => { test.each(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'ERR_NETWORK'])( 'formats %s as connectivity error', @@ -249,6 +265,19 @@ describe('handleError', () => { expect(error).toHaveBeenCalledWith('This value must be a positive integer.') }) + + test('shows only the API message when the param is nested', () => { + const err = createFintocError('InvalidRequestError', { + type: 'invalid_request_error', + code: 'invalid_enum', + param: 'tax_id.type', + message: 'Invalid tax_id.type: bogus', + }) + + expect(() => handleError(err)).toThrow('process.exit') + + expect(error).toHaveBeenCalledWith('Invalid tax_id.type: bogus') + }) }) describe('AxiosError with structured response', () => { @@ -399,6 +428,45 @@ describe('handleError', () => { }) }) + test.each(['tax_id.type', 'counterparty.account_number', 'items[0].price'])( + 'emits JSON for FintocError with nested param %s', + (param) => { + const err = createFintocError('InvalidRequestError', { + type: 'invalid_request_error', + code: 'invalid_enum', + param, + message: 'Invalid value', + }) + + expect(() => handleError(err, { json: true })).toThrow('process.exit') + + expect(JSON.parse(stderrSpy.mock.calls[0][0] as string)).toEqual({ + error: { + type: 'invalid_request_error', + code: 'invalid_enum', + param, + message: 'Invalid value', + }, + }) + }, + ) + + test('emits JSON auth_error for an empty API key', () => { + const err = new AuthError( + 'FINTOC_API_KEY is set but empty. Provide a valid key or unset the variable.', + ) + + expect(() => handleError(err, { json: true })).toThrow('process.exit') + + expect(JSON.parse(stderrSpy.mock.calls[0][0] as string)).toEqual({ + error: { + type: 'auth_error', + message: 'FINTOC_API_KEY is set but empty. Provide a valid key or unset the variable.', + }, + }) + expect(exitSpy).toHaveBeenCalledWith(1) + }) + test('emits JSON for usage errors with code and param', () => { const err = new UsageError('Missing required flag: --amount', { code: 'missing_flag', diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6aa0dfc..dc425dd 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,7 @@ import { Fintoc } from 'fintoc' import { readConfig } from './config.js' import { DASHBOARD_API_KEYS_URL } from './constants.js' +import { AuthError } from './errors.js' import { getCliVersion } from './version.js' export type AuthSource = 'flag' | 'env' | 'config' @@ -15,7 +16,9 @@ export type ResolvedAuth = { export const resolveAuth = (options?: { apiKey?: string }): ResolvedAuth => { if (options?.apiKey !== undefined) { if (!options.apiKey.trim()) { - throw new Error('API key is empty. Provide a valid key with --api-key or remove the flag.') + throw new AuthError( + 'API key is empty. Provide a valid key with --api-key or remove the flag.', + ) } return { secretKey: options.apiKey, source: 'flag' } } @@ -23,7 +26,9 @@ export const resolveAuth = (options?: { apiKey?: string }): ResolvedAuth => { const envKey = process.env.FINTOC_API_KEY if (envKey !== undefined) { if (!envKey.trim()) { - throw new Error('FINTOC_API_KEY is set but empty. Provide a valid key or unset the variable.') + throw new AuthError( + 'FINTOC_API_KEY is set but empty. Provide a valid key or unset the variable.', + ) } return { secretKey: envKey, source: 'env' } } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index b98a4b8..519edd6 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -50,7 +50,7 @@ export const parseFintocError = (err: unknown): FintocErrorFields | undefined => const lines = err.message.split('\n') const firstLine = lines[0] ?? '' - const match = firstLine.match(/^(\w+)(?::\s*(\w+))?(?:\s*\((\w+)\))?$/) + const match = firstLine.match(/^(\w+)(?::\s*(\w+))?(?:\s*\(([\w.[\]]+)\))?$/) if (!match) { return { type: firstLine } } @@ -100,6 +100,13 @@ export class UsageError extends Error { } } +export class AuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'AuthError' + } +} + const isNoAuthError = (err: unknown): boolean => err instanceof Error && err.message.includes('No API key found') @@ -158,6 +165,14 @@ export const handleError = (err: unknown, context?: ErrorContext): never => { return process.exit(1) } + if (err instanceof AuthError) { + if (context?.json) { + return exitJsonError({ type: 'auth_error', message: err.message }) + } + error(err.message) + return process.exit(1) + } + if (isConnectivityError(err)) { if (context?.json) { return exitJsonError({ From a92ebaada5150fed09bd2428ffc755130b5385b1 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 13:15:59 -0300 Subject: [PATCH 09/15] fixup! feat: ampliar el motor de recursos con acciones y archivos Co-Authored-By: Claude Opus 5.5 --- .../__tests__/factory-actions.test.ts | 23 ++++++++++++++++++- src/resources/factory.ts | 9 +++----- src/types.ts | 2 ++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/resources/__tests__/factory-actions.test.ts b/src/resources/__tests__/factory-actions.test.ts index e579236..557cf4f 100644 --- a/src/resources/__tests__/factory-actions.test.ts +++ b/src/resources/__tests__/factory-actions.test.ts @@ -664,6 +664,17 @@ describe('factory: update, actions and files', () => { ], } + test('--file help says repeatable only when there is more than one field', () => { + const cmd = createProgram(uploadResource).commands.find((c) => c.name() === 'widgets')! + const fileHelp = (name: string) => + cmd.commands.find((c) => c.name() === name)!.options.find((o) => o.long === '--file')! + .description + + expect(fileHelp('create')).toContain('multipart, repeatable.') + expect(fileHelp('upload_document')).toContain('multipart (or just ). Fields') + expect(fileHelp('upload_document')).not.toContain('repeatable') + }) + test('create puts each --file at its field path inside the body', async () => { mockManager.create.mockResolvedValue(result({ id: 'w_1' })) @@ -1030,7 +1041,10 @@ describe('factory: update, actions and files', () => { const commands = describeCommands([ { ...resource, - createFlags: [{ name: 'max-amount', type: 'integer', nestedPath: 'options.max_amount' }], + createFlags: [ + { name: 'max-amount', type: 'integer', nestedPath: 'options.max_amount' }, + { name: 'link-token', type: 'string', in: 'query' }, + ], }, ]) @@ -1049,6 +1063,13 @@ describe('factory: update, actions and files', () => { param: 'options.max_amount', in: 'body', }, + { + name: 'link-token', + type: 'string', + required: false, + param: 'link_token', + in: 'query', + }, ]) expect(commands[0].options).toEqual(['--from-json', '--idempotency-key']) expect(commands[1].options).toEqual(['--limit']) diff --git a/src/resources/factory.ts b/src/resources/factory.ts index 4f21385..73c6f1f 100644 --- a/src/resources/factory.ts +++ b/src/resources/factory.ts @@ -217,12 +217,9 @@ const addFileOption = (cmd: Command, files: FileDef[]) => { `${f.field}${f.required ? ' (required)' : ''}${f.description ? `: ${f.description}` : ''}`, ) .join('; ') - const shorthand = files.length === 1 ? ' (or just )' : '' + const usage = files.length === 1 ? ' (or just )' : ', repeatable' cmd.addOption( - new Option( - '--file ', - `File to upload as multipart${shorthand}, repeatable. Fields: ${fields}`, - ) + new Option('--file ', `File to upload as multipart${usage}. Fields: ${fields}`) .argParser(parseFileValue(files)) .helpGroup(PARAMETERS_GROUP), ) @@ -801,7 +798,7 @@ const flagSchemas = (flags: FlagDef[], location: 'query' | 'body'): FlagSchema[] type: f.type, required: !!(f.required || f.pathParam), param: f.nestedPath ?? toSnakeCase(f.name), - in: f.pathParam ? 'path' : location, + in: f.pathParam ? 'path' : (f.in ?? location), ...(f.description ? { description: f.description } : {}), ...(f.deprecated ? { deprecated: f.deprecated } : {}), })) diff --git a/src/types.ts b/src/types.ts index 0fea428..15547ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,8 @@ export type FlagDef = { nestedPath?: string // Fills a path placeholder of a nested manager (e.g. {entity_id}). Always required, even with --from-json pathParam?: boolean + // Where the SDK sends it when that differs from the verb's default (body for writes, query for reads) + in?: 'query' | 'body' // Hidden from help and not sent; using it prints this message as a warning deprecated?: string } From 77c61baa0e12c2570ad293634ad6ff0ed1c59f2e Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 13:15:59 -0300 Subject: [PATCH 10/15] =?UTF-8?q?fixup!=20feat:=20completar=20recursos=20y?= =?UTF-8?q?=20par=C3=A1metros=20de=20la=20API=20p=C3=BAblica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/lib/__tests__/errors.test.ts | 18 ++++++++++++++++++ src/lib/errors.ts | 8 ++++++++ src/resources/__tests__/registry.test.ts | 5 +++++ src/resources/registry.ts | 14 +++++++++----- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/errors.test.ts b/src/lib/__tests__/errors.test.ts index f5dff22..9b76e4f 100644 --- a/src/lib/__tests__/errors.test.ts +++ b/src/lib/__tests__/errors.test.ts @@ -280,6 +280,24 @@ describe('handleError', () => { }) }) + describe('single-file document slot', () => { + test('points to upload_document', () => { + const err = createFintocError('InvalidRequestError', { + type: 'invalid_request_error', + code: 'single_file_document_slot', + param: 'slot_key', + message: 'This slot accepts a single file. Use PUT to replace it.', + }) + + expect(() => handleError(err)).toThrow('process.exit') + + expect(error).toHaveBeenCalledWith('This slot accepts a single file. Use PUT to replace it.') + expect(hint).toHaveBeenCalledWith( + ' Use: fintoc v2 onboardings upload_document --entity-id --file ', + ) + }) + }) + describe('AxiosError with structured response', () => { test('extracts message from response.data.error', () => { const err = new Error('Request failed with status code 400') diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 519edd6..c665985 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -218,6 +218,14 @@ export const handleError = (err: unknown, context?: ErrorContext): never => { return process.exit(1) } + if (fields.code === 'single_file_document_slot') { + error(fields.message ?? 'This document slot accepts a single file') + printNextSteps([ + 'Use: fintoc v2 onboardings upload_document --entity-id --file ', + ]) + return process.exit(1) + } + if (fields.code === 'missing_resource') { const fallback = context?.id ? `'${context.id}' not found` : 'Resource not found' error(`Error (404): ${fields.message?.trim() || fallback}`) diff --git a/src/resources/__tests__/registry.test.ts b/src/resources/__tests__/registry.test.ts index 531f057..fa5f5e7 100644 --- a/src/resources/__tests__/registry.test.ts +++ b/src/resources/__tests__/registry.test.ts @@ -306,6 +306,11 @@ describe('resource registry', () => { expect(requiredFlags.map((f) => f.name)).toEqual(['link-token']) }) + test('refresh_intents create declares its flags as query params', () => { + const refreshIntents = find('refresh_intents', 'v1') + expect(refreshIntents.createFlags!.map((f) => f.in)).toEqual(['query', 'query', 'query']) + }) + test('disputes create_document uploads the file inside the body', () => { const disputes = find('disputes', 'v1') const createDocument = disputes.actions!.find((a) => a.name === 'create_document')! diff --git a/src/resources/registry.ts b/src/resources/registry.ts index ce98b12..99f6c3f 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -224,7 +224,7 @@ export const resources: ResourceDef[] = [ name: 'counterparty-account-type', type: 'string', description: - 'Destination account type (checking_account, sight_account; required in Chile)', + 'Destination account type (checking_account, sight_account, savings_account; required in Chile)', nestedPath: 'counterparty.account_type', }, { @@ -1419,16 +1419,19 @@ export const resources: ResourceDef[] = [ name: 'link-token', type: 'string', required: true, + in: 'query', description: `Token of the link to refresh (see ${DOCS_LINKS_URL})`, }, { name: 'refresh-type', type: 'string', + in: 'query', description: 'Statement to refresh: only_last (default) or historical', }, { name: 'result-status', type: 'string', + in: 'query', description: 'Result to force in test mode: succeeded, failed or rejected', }, ], @@ -1641,7 +1644,7 @@ export const resources: ResourceDef[] = [ name: 'customer-data-tax-id-type', type: 'string', nestedPath: 'customer_data.tax_id.type', - description: 'Tax ID type of the customer to create: cl_rut or mx_rfc', + description: 'Tax ID type of the customer to create (cl_rut, mx_rfc, custom)', }, { name: 'customer-data-tax-id-value', @@ -1769,7 +1772,7 @@ export const resources: ResourceDef[] = [ { name: 'tax-id-type', type: 'string', - description: 'Tax ID type (cl_rut, mx_rfc)', + description: 'Tax ID type (cl_rut, mx_rfc, custom)', nestedPath: 'tax_id.type', }, { @@ -1826,7 +1829,7 @@ export const resources: ResourceDef[] = [ { name: 'tax-id-type', type: 'string', - description: 'Filter by tax ID type (cl_rut, mx_rfc)', + description: 'Filter by tax ID type (cl_rut, mx_rfc, custom)', }, ], }, @@ -3247,7 +3250,8 @@ export const resources: ResourceDef[] = [ { name: 'counterparty-account-type', type: 'string', - description: 'Sender account type (checking_account, sight_account; Chile only)', + description: + 'Sender account type (checking_account, sight_account, savings_account; Chile only)', }, { name: 'counterparty-institution-id', From 72c3266cdf956da339abcc68f8e241b0dfb2cef6 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 13:15:59 -0300 Subject: [PATCH 11/15] =?UTF-8?q?fixup!=20docs:=20explicar=20recursos,=20p?= =?UTF-8?q?aginaci=C3=B3n=20y=20contratos=20de=20la=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a95188..d28153b 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,8 @@ fintoc v2 onboardings upload_document onb_test_abc123 proof_of_address \ - With `--json`, results go to stdout and errors go to stderr as `{"error": {"type", "code", "param", "message"}}`, and nothing else is written to stderr (except for `webhooks listen`, which streams status lines). Usage errors have `type: "usage_error"` and a stable `code` such as `missing_flag`, `invalid_value`, `unknown_option`, `unknown_command`. - Without a TTY the CLI never prompts: commands that need confirmation fail until you pass `--yes`. When the API key is live, the confirmation says so. - `list` returns up to `--limit` results without fetching another item to check for more. Without `--json` it warns on stderr when the limit is reached. Paginated v1 endpoints accept `--page` and `--per-page`; paginated v2 endpoints accept `--starting-after`, `--ending-before`, and `--page-size` (the API `limit`). These set the initial API page; the SDK follows subsequent pages until the CLI limit is reached. -- JSON mode never prompts: confirmed operations require `--yes`, and `login --json` requires `--api-key` (plus `--yes` when replacing a saved session). +- JSON mode never prompts, even in an interactive terminal: confirmed operations fail with `usage_error` code `confirmation_required` until you pass `--yes`, and `login --json` requires `--api-key` (plus `--yes` when replacing a saved session) instead of opening the browser. +- `login --api-key`, `logout`, `doctor`, `config set` and `open dashboard` also print their result as JSON on stdout with `--json`. - Help commands and resource groups without a subcommand keep their published text output and exit code 0, including with `--json`. Use `commands --json` for structured command discovery. - `create` and actions that send a POST (`pay`, `cancel`, `return`, ...) accept `--idempotency-key`, so a retried request does not run twice. - Resources and actions also answer to kebab-case (`payment-intents`, `submit-for-review`). Boolean flags work bare (`--active`) or with a value (`--active false`), and list flags take comma-separated or repeated values. From c4a94e65048531e2d5608ac01905142250952889 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 13:25:41 -0300 Subject: [PATCH 12/15] =?UTF-8?q?fixup!=20feat:=20completar=20recursos=20y?= =?UTF-8?q?=20par=C3=A1metros=20de=20la=20API=20p=C3=BAblica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/resources/registry.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 99f6c3f..f17fa6f 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -1456,7 +1456,7 @@ export const resources: ResourceDef[] = [ { name: 'checkout_sessions', displayName: 'checkout session', - description: 'Manage checkout sessions (API v2)', + description: 'Manage checkout sessions (API v2; requires API version 2026-02-01)', cliCommand: 'checkout_sessions', sdkMethod: 'checkoutSessions', sdkNamespace: 'v2', @@ -2111,7 +2111,7 @@ export const resources: ResourceDef[] = [ { name: 'payment_intents', displayName: 'payment intent', - description: 'Manage payment intents (API v2)', + description: 'Manage payment intents (API v2; requires API version 2026-02-01)', cliCommand: 'payment_intents', sdkMethod: 'paymentIntents', sdkNamespace: 'v2', @@ -2982,6 +2982,7 @@ export const resources: ResourceDef[] = [ { name: 'setup_intents', displayName: 'setup intent', + description: 'Manage setup intents (requires API version 2026-02-01)', cliCommand: 'setup_intents', sdkMethod: 'setupIntents', sdkNamespace: 'v2', From 50c27e5259e2f0bd6db8b04b719f0a56323dc926 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 18:53:58 -0300 Subject: [PATCH 13/15] fixup! feat: ampliar el motor de recursos con acciones y archivos Co-Authored-By: Claude Opus 5.5 --- .../__tests__/factory-actions.test.ts | 39 +++++++++++++++++++ src/resources/__tests__/factory.test.ts | 30 +++++++++++++- src/resources/factory.ts | 31 ++++++++++++++- 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/resources/__tests__/factory-actions.test.ts b/src/resources/__tests__/factory-actions.test.ts index 557cf4f..e0483e0 100644 --- a/src/resources/__tests__/factory-actions.test.ts +++ b/src/resources/__tests__/factory-actions.test.ts @@ -115,6 +115,45 @@ describe('factory: update, actions and files', () => { vi.restoreAllMocks() }) + describe('path segments', () => { + const resource: ResourceDef = { + ...baseResource, + verbs: ['get', 'delete'], + actions: [ + { + name: 'remove_document', + sdkMethod: 'deleteDocument', + description: 'Remove a document', + confirm: true, + extraArgs: [{ name: 'slot_key' }], + flags: [{ name: 'entity-id', type: 'string', pathParam: true }], + }, + ], + } + + test.each([ + ['an id that climbs to another endpoint', ['widgets', 'get', '../invoices/inv_1']], + ['an id with a query string', ['widgets', 'delete', 'w_1?x=', '--yes']], + ['a dot segment', ['widgets', 'delete', '..', '--yes']], + [ + 'an extra positional', + ['widgets', 'remove_document', 'w_1', 'a/b', '--entity-id', 'ent_1', '--yes'], + ], + [ + 'a path param flag', + ['widgets', 'remove_document', 'w_1', 'slot', '--entity-id', 'ent_1#x', '--yes'], + ], + ])('rejects %s before calling the SDK', async (_label, args) => { + mockProcessExit() + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(run(resource, ['--json', ...args])).rejects.toThrow('process.exit') + + expect(JSON.parse(stderr.mock.calls[0][0]).error.code).toBe('invalid_value') + expect(createClient).not.toHaveBeenCalled() + }) + }) + describe('public API parameter mappings', () => { const resource = (name: string, namespace: 'v1' | 'v2'): ResourceDef => ({ ...resources.find((r) => r.name === name && r.sdkNamespace === namespace)!, diff --git a/src/resources/__tests__/factory.test.ts b/src/resources/__tests__/factory.test.ts index 3556cbf..a807c0a 100644 --- a/src/resources/__tests__/factory.test.ts +++ b/src/resources/__tests__/factory.test.ts @@ -1,5 +1,5 @@ import type { ResourceDef } from '../../types.js' -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { confirm } from '@inquirer/prompts' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -9,7 +9,7 @@ import { registerResourceCommands } from '../factory.js' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, readFileSync: vi.fn() } + return { ...actual, existsSync: vi.fn(() => true), readFileSync: vi.fn() } }) vi.mock('../../lib/auth.js', () => ({ @@ -856,6 +856,32 @@ describe('factory', () => { expect(createClient).toHaveBeenCalledWith('sk_test_123', '/path/to/key.pem') }) + + test('exits with error when the key file does not exist', async () => { + vi.mocked(existsSync).mockReturnValueOnce(false) + const exitSpy = mockProcessExit() + + const program = createProgram(jwsResource) + await expect( + program.parseAsync( + [ + 'transfers', + 'create', + '--amount', + '10000', + '--currency', + 'CLP', + '--jws-private-key', + '/missing.pem', + ], + { from: 'user' }, + ), + ).rejects.toThrow('process.exit') + + expect(error).toHaveBeenCalledWith("--jws-private-key: file '/missing.pem' not found") + expect(mockManager.create).not.toHaveBeenCalled() + exitSpy.mockRestore() + }) }) describe('when resource.needsJws is false', () => { diff --git a/src/resources/factory.ts b/src/resources/factory.ts index 73c6f1f..e4832f4 100644 --- a/src/resources/factory.ts +++ b/src/resources/factory.ts @@ -11,7 +11,7 @@ import type { UploadFile, Verb, } from '../types.js' -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { basename } from 'node:path' import { confirm } from '@inquirer/prompts' import { InvalidArgumentError, Option } from 'commander' @@ -188,7 +188,7 @@ const parseFileValue = (files: FileDef[]) => { const separator = value.indexOf('=') let field = separator > 0 ? value.slice(0, separator) : '' let path = separator > 0 ? value.slice(separator + 1) : '' - if (files.length === 1 && !allowed.includes(field)) { + if (files.length === 1 && !/^[\w.]+$/.test(field)) { field = allowed[0] path = value } @@ -251,6 +251,13 @@ const usageHints = (fullCliPath: string, verb: string, usage: string[]) => [ `Help: fintoc ${fullCliPath} ${verb} --help`, ] +// The SDK interpolates these into the URL unencoded, so "../x" or "a?b" would reach another endpoint +const assertPathSegment = (param: string, value: string) => { + if (!value || value === '.' || value === '..' || /[/\\?#%]/.test(value)) { + throw new UsageError(`Invalid ${param}: '${value}'`, { code: 'invalid_value', param }) + } +} + const validateRequired = ( cmd: Command, flags: FlagDef[], @@ -259,6 +266,12 @@ const validateRequired = ( { onlyPathParams = false, args = [] as string[] } = {}, ) => { const opts = cmd.opts() + for (const flag of flags) { + const value = opts[toCamelCase(flag.name)] + if (flag.pathParam && value !== undefined) { + assertPathSegment(flag.name, String(value)) + } + } const isRequired = (f: FlagDef) => f.pathParam || (!onlyPathParams && f.required) const missing = flags.filter((f) => isRequired(f) && opts[toCamelCase(f.name)] === undefined) @@ -324,6 +337,12 @@ const resolveClient = ( { needsJws, jwsKeyPath }: { needsJws?: boolean; jwsKeyPath?: string } = {}, ) => { const auth = resolveAuth(parentOpts) + if (jwsKeyPath !== undefined && !existsSync(jwsKeyPath)) { + throw new UsageError(`--jws-private-key: file '${jwsKeyPath}' not found`, { + code: 'file_not_found', + param: 'jws-private-key', + }) + } const configJwsPath = needsJws ? readConfig().jws_private_key : undefined const resolvedPath = jwsKeyPath ?? configJwsPath return createClient(auth.secretKey, resolvedPath) @@ -502,6 +521,7 @@ const registerGet = (parent: Command, resource: ResourceDef) => { cmd.action(async (identifier: string, _opts: unknown, actionCmd: Command) => { const context = { cliPath, verb: 'get', id: identifier, availableVerbs: resource.verbs } await runCommand(actionCmd, context, async (rootOpts) => { + assertPathSegment(argName, identifier) validateRequired(actionCmd, flags, cliPath, 'get', { args: [argName] }) const params = flags.length ? collectSetOptions(actionCmd, flags) : undefined const result = await getManager(resolveClient(rootOpts), resource).get!(identifier, params) @@ -552,6 +572,7 @@ const registerUpdate = (parent: Command, resource: ResourceDef) => { const context = { cliPath, verb: 'update', id: identifier, availableVerbs: resource.verbs } await runCommand(actionCmd, context, async (rootOpts) => { const localOpts = actionCmd.opts() + assertPathSegment(argName, identifier) validateRequired(actionCmd, flags, cliPath, 'update', { onlyPathParams: !!localOpts.fromJson, args: [argName], @@ -599,6 +620,7 @@ const registerDelete = (parent: Command, resource: ResourceDef) => { cmd.action(async (id: string, opts: { yes?: boolean }, actionCmd: Command) => { const context = { cliPath, verb: 'delete', id, availableVerbs: resource.verbs } await runCommand(actionCmd, context, async (rootOpts) => { + assertPathSegment(argName, id) validateRequired(actionCmd, flags, cliPath, 'delete', { args: [argName] }) const phrase = fillTemplate("delete {resource} '{id}'", resource, id) if (!(await confirmOrAbort(rootOpts, opts.yes, phrase))) { @@ -669,6 +691,11 @@ const registerAction = (parent: Command, resource: ResourceDef, action: ActionDe await runCommand(actionCmd, context, async (rootOpts) => { const localOpts = actionCmd.opts() + positional.forEach((arg, i) => { + if (!arg.bodyField) { + assertPathSegment(arg.name, values[i]) + } + }) validateRequired(actionCmd, flags, cliPath, action.name, { onlyPathParams: !!localOpts.fromJson, args: positional.map((a) => a.name), From ab1af94709e6b11660f16a2621c9a1531539f230 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 18:53:58 -0300 Subject: [PATCH 14/15] =?UTF-8?q?fixup!=20feat:=20completar=20recursos=20y?= =?UTF-8?q?=20par=C3=A1metros=20de=20la=20API=20p=C3=BAblica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/resources/registry.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/resources/registry.ts b/src/resources/registry.ts index f17fa6f..03bafe5 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -1115,6 +1115,11 @@ export const resources: ResourceDef[] = [ type: 'integer', description: 'Seconds after which the payment link expires', }, + { + name: 'max-completed-sessions', + type: 'integer', + description: 'Pass 1 to accept a single successful payment (reusable when omitted)', + }, { name: 'checkout-description', type: 'string', @@ -2747,6 +2752,7 @@ export const resources: ResourceDef[] = [ { name: 'holder-id', type: 'string', description: 'Filter by account holder tax ID' }, { name: 'institution-id', type: 'string', description: 'Filter by institution ID' }, { name: 'account-number', type: 'string', description: 'Filter by account number' }, + ...CURSOR_FLAGS, ], updateFlags: [ { From 23161d22318da60569843892430255b5d0807789 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Mon, 2 Nov 2026 18:53:58 -0300 Subject: [PATCH 15/15] fixup! feat: ofrecer JSON y descubrimiento de comandos para scripts Co-Authored-By: Claude Opus 5.5 --- src/commands/__tests__/login.test.ts | 2 +- src/commands/login.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/__tests__/login.test.ts b/src/commands/__tests__/login.test.ts index 709ddd0..91a7041 100644 --- a/src/commands/__tests__/login.test.ts +++ b/src/commands/__tests__/login.test.ts @@ -247,7 +247,7 @@ describe('login command', () => { const program = createProgram() await program.parseAsync(['login'], { from: 'user' }) - expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ default: false }), { + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ default: true }), { output: process.stderr, }) expect(startBrowserLogin).toHaveBeenCalled() diff --git a/src/commands/login.ts b/src/commands/login.ts index 4498777..e7c4af6 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -106,7 +106,7 @@ const confirmRelogin = async ({ } const proceed = await confirm( - { message: `Are you sure you want to ${phrase}?`, default: false }, + { message: `Are you sure you want to ${phrase}?`, default: true }, { output: process.stderr }, ) if (!proceed) {