diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b366ea..17e753d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,77 @@ All notable changes to `pdcli` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). +## [0.18.0] - 2026-06-14 + +Second contract-hardening pass from a full quality audit, closing the +exit-code, machine-output, and data-safety gaps that a 1.0 freeze would lock +in. Most items are bug fixes; the BREAKING notes are exit-code/output changes a +script could observe. + +### Fixed — data safety (the headline) + +- **CSV `--upsert` no longer deletes a matched record's other emails/phones.** + The match (identity) field is now excluded from the update diff, so matching + a person by one email never PATCHes their email list down to that single + value. Same for phones. +- **Upsert matching on a monetary/address custom field is refused (exit 64)** + instead of silently never matching and creating a duplicate on every run + (v2 returns those fields as objects the scalar compare can't match). +- **Excel "CSV UTF-8" imports work**: a leading UTF-8 BOM is stripped instead + of corrupting the first column name into a "missing name column" error. +- A non-integer `org_id`/`owner_id` CSV cell is rejected (exit 65) instead of + serializing as `null` and silently unlinking the relation; a non-numeric + value for a numeric custom field is likewise rejected. +- **`changes --limit` no longer silently skips rows** sharing the cut row's + update-time second; they replay next run (it warns, never silently loses, + when a single second exceeds the limit). +- **`--output csv` no longer emits a silently-blank header+rows** when a command + has no preset columns — it derives columns from the data (so `changes +--output csv` can't advance its watermark over never-written rows). + +### Security + +- The HTTP transport no longer follows redirects (`redirect: 'manual'`) and + refuses any 3xx (exit 78), so the API token is never re-sent to a redirect + target off your host-locked company domain. + +### Changed + +- **BREAKING — exit codes corrected to the ladder.** Network unreachable / DNS + failure / `--timeout` now exit **69** (was 70); exhausted 429 retries exit + **75** (was 69); a failed/rejected `deal`/`lead convert` exits **65** (was + 70); a failed OAuth refresh (`invalid_grant`) exits **77** (was 65); + malformed `pdcli api --body` JSON exits **65** (was 70). Exit **70** is now + truly internal-bug-only. +- **BREAKING — aliased commands keep their real exit code.** An alias for a + command that fails now propagates the command's exit code (e.g. `watch`'s + exit 8, auth 77, usage 64) instead of collapsing every failure to 1, and + prints the error message it previously swallowed. +- **Mutations honor `--output`.** Every delete/remove, both converts, + `deal bulk-update`, `person`/`org import`, and `backup` now emit a structured + JSON/yaml/csv object (honoring `--jq`/`--fields`) instead of prose on stdout; + the converts expose the new record id (`deal_id`/`lead_id`) in JSON. Human + one-liners remain in interactive table mode. +- `--fields` now projects keys for `json`/`yaml` output, not only table/csv. + +### Fixed — other + +- `pdcli api` reads a body piped on stdin (the documented behavior was + unreachable); a usage/parse error in a TTY now honors an explicit + `--output json`. +- `search --item-types --limit` is clamped to 100 (the scoped endpoint + rejects more) instead of surfacing a confusing API 400. +- `deal history --limit` caps how much it fetches instead of walking the whole + changelog; bulk-target parsing rejects blank/zero/`undefined` ids and + malformed piped JSON; `digest` rejects `--format` combined with `--output`. + +### Chore + +- `npm run docs:commands` builds the manifest first (worked from a dirty tree + only); removed the unused `undici` dependency; added the 10 missing + `--help` topic descriptions; config tests isolate the store (`PDCLI_CONFIG_DIR`) + so they never touch your real profiles. + ## [0.17.0] - 2026-06-11 Contract-hardening release — the last changes to the machine-facing surface diff --git a/docs/commands.md b/docs/commands.md index a9d59c3..a8b1829 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the pdcli command-line interface. -Reference for `pdcli` v0.17.0 (145 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`. +Reference for `pdcli` v0.18.0 (145 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`. ## Top-level diff --git a/package-lock.json b/package-lock.json index bb4271e..60ca105 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wavyx/pdcli", - "version": "0.17.0", + "version": "0.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wavyx/pdcli", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "8.5.0", @@ -22,8 +22,7 @@ "js-yaml": "^4.1.1", "node-jq": "^6.3.1", "open": "^11.0.0", - "ora": "9.4.0", - "undici": "8.3.0" + "ora": "9.4.0" }, "bin": { "pdcli": "bin/run.js" @@ -8315,15 +8314,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 4d33bd7..db2bc5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wavyx/pdcli", - "version": "0.17.0", + "version": "0.18.0", "publishConfig": { "access": "public" }, @@ -74,6 +74,36 @@ "field": { "description": "Custom-field management: discovery, name/key resolution, create/update/delete" }, + "file": { + "description": "Files (list, get, upload, download, remote-link, update, delete)" + }, + "filter": { + "description": "Filters (list, get, delete)" + }, + "goal": { + "description": "Goals (list)" + }, + "lead": { + "description": "Leads (list, get, create, update, convert, delete) and labels" + }, + "note": { + "description": "Notes (list, get, create, update, delete) and comments" + }, + "pipeline": { + "description": "Pipelines (list, get, health)" + }, + "product": { + "description": "Products (list, get, create, update, delete)" + }, + "project": { + "description": "Projects (list, get, create, update, delete)" + }, + "stage": { + "description": "Pipeline stages (list, get)" + }, + "webhook": { + "description": "Webhooks (list, create, delete)" + }, "metrics": { "description": "Sales metrics and KPIs" }, @@ -151,8 +181,7 @@ "js-yaml": "^4.1.1", "node-jq": "^6.3.1", "open": "^11.0.0", - "ora": "9.4.0", - "undici": "8.3.0" + "ora": "9.4.0" }, "devDependencies": { "@eslint/js": "10.0.1", @@ -167,7 +196,7 @@ }, "scripts": { "build": "oclif manifest", - "docs:commands": "node scripts/gen-commands.mjs", + "docs:commands": "npm run build && node scripts/gen-commands.mjs", "docs:demo": "node scripts/gen-demo.mjs", "lint": "eslint . && prettier --check .", "lint:fix": "eslint --fix . && prettier --write .", diff --git a/src/base-command.js b/src/base-command.js index 2f521f9..7199312 100644 --- a/src/base-command.js +++ b/src/base-command.js @@ -192,14 +192,44 @@ export default class BaseCommand extends Command { } let filteredColumns = columns - if (this.flags.fields && columns) { + let outData = data + if (this.flags.fields) { const requested = this.flags.fields.split(',').map((f) => f.trim()) filteredColumns = Object.fromEntries( Object.entries(columns).filter(([key]) => requested.includes(key)), ) + // table/csv project through `columns`; json/yaml serialize the data + // as-is, so project the records themselves (by key) — otherwise --fields + // is silently ignored for exactly the machine consumers it serves. + // `Object(row)` keeps the pick null/primitive-safe without a branch. + const format = this.resolveFormat() + if (format === 'json' || format === 'yaml') { + const pick = (row) => + Object.fromEntries( + requested.filter((k) => k in Object(row)).map((k) => [k, row[k]]), + ) + outData = Array.isArray(data) ? data.map(pick) : pick(data) + } } - formatOutput(data, filteredColumns, this.resolveFormat(), this) + formatOutput(outData, filteredColumns, this.resolveFormat(), this) + } + + /** + * Report the result of a mutating action. In interactive table mode it + * prints the human one-liner; in any machine format (explicit --output, a + * json/yaml/csv profile default, or piped) it emits `machineObject` through + * outputResults so `--output json | jq`, --fields and --jq all work — a + * delete/convert/import is no longer prose-only on stdout. + * @param {object} machineObject the structured result (e.g. { deleted: id }) + * @param {string} humanMessage the interactive one-liner + */ + async outputAction(machineObject, humanMessage) { + if (this.resolveFormat() === 'table') { + this.log(humanMessage) + return + } + await this.outputResults(machineObject, {}) } async catch(err) { diff --git a/src/commands/activity/delete.js b/src/commands/activity/delete.js index 4379e2d..a327e7b 100644 --- a/src/commands/activity/delete.js +++ b/src/commands/activity/delete.js @@ -34,6 +34,9 @@ export default class ActivityDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v2/activities/${args.id}`) - this.log(chalk.green(`Deleted activity ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted activity ${args.id}`), + ) } } diff --git a/src/commands/api.js b/src/commands/api.js index a3d495b..ac7f2f0 100644 --- a/src/commands/api.js +++ b/src/commands/api.js @@ -1,6 +1,7 @@ import { Args, Flags } from '@oclif/core' import BaseCommand from '../base-command.js' import { resolveBody } from '../lib/body.js' +import { CliError } from '../lib/errors.js' export default class ApiCommand extends BaseCommand { static description = @@ -46,9 +47,19 @@ export default class ApiCommand extends BaseCommand { const method = methodMap[args.method] const opts = {} - if (flags.body && !['GET', 'DELETE'].includes(args.method)) { + // Resolve the body for any method that carries one — from --body, @file, + // or piped stdin (resolveBody handles all three; it errors when a body is + // required but none is given). Previously this was gated on `flags.body`, + // so the documented "pipe stdin" path was unreachable. + if (!['GET', 'DELETE'].includes(args.method)) { const bodyText = await resolveBody(flags) - opts.body = JSON.parse(bodyText) + try { + opts.body = JSON.parse(bodyText) + } catch (err) { + throw new CliError(`--body is not valid JSON: ${err.message}`, { + exitCode: 65, + }) + } } const data = await this.apiClient[method](args.path, opts) diff --git a/src/commands/backup.js b/src/commands/backup.js index 8d3c82f..1c97a29 100644 --- a/src/commands/backup.js +++ b/src/commands/backup.js @@ -42,7 +42,13 @@ export default class BackupCommand extends BaseCommand { spinner.stop() } - this.log( + await this.outputAction( + { + exported: summary.exported, + total: summary.total, + skipped: summary.skipped ?? 0, + dir: flags.dir, + }, chalk.green( `Backup complete: ${summary.exported}/${summary.total} resources ` + `exported to ${chalk.cyan(flags.dir)}` + diff --git a/src/commands/changes.js b/src/commands/changes.js index 8f4b8dd..35e4a89 100644 --- a/src/commands/changes.js +++ b/src/commands/changes.js @@ -12,6 +12,14 @@ import { CliError } from '../lib/errors.js' const WATERMARK_KEY = 'changes_watermark' +/** Whole-second bucket of an update_time (v2 timestamps are seconds-precision). + * A missing timestamp returns -Infinity; the truncation logic guards against + * treating that as a real cut second (such rows can't be resumed anyway). */ +function secondOf(updateTime) { + if (updateTime == null) return -Infinity + return Math.floor(new Date(updateTime).getTime() / 1000) +} + /** The five v2 entities that support `updated_since` + update_time ordering. */ const ENTITY_PATHS = { deals: '/api/v2/deals', @@ -82,7 +90,38 @@ export default class ChangesCommand extends BaseCommand { const { rows: allRows } = buildChangeFeed(byEntity, since) // --limit caps rows per run; the watermark resumes at the cut so the rest // arrive next run (no skip). Rows are sorted ascending by update_time. - const rows = flags.limit != null ? allRows.slice(0, flags.limit) : allRows + let rows = flags.limit != null ? allRows.slice(0, flags.limit) : allRows + + // update_time has SECONDS precision and the watermark advances to (max + 1s) + // below. If --limit cuts mid-second, that +1s would jump past unemitted rows + // sharing the cut second — silently lost. So on truncation, drop the trailing + // rows that share the last emitted row's second; they replay next run and the + // +1s advance (now landing on an earlier second) skips nothing. + const truncated = flags.limit != null && allRows.length > rows.length + const cutSecond = truncated + ? secondOf(rows[rows.length - 1].updateTime) + : null + // Only a problem when a DROPPED row shares the cut row's REAL second; if the + // next dropped row is in a later second the +1s advance is already skip-free, + // and a null cut second (-Infinity) can't be resumed by updated_since at all. + if ( + truncated && + cutSecond !== -Infinity && + secondOf(allRows[rows.length].updateTime) === cutSecond + ) { + const trimmed = rows.filter((r) => secondOf(r.updateTime) < cutSecond) + if (trimmed.length > 0) { + rows = trimmed + } else { + // A single second holds more rows than --limit; we cannot bound at the + // limit without either looping forever or skipping. Emit this second and + // advance past it, but say so — a loud warning beats silent feed loss. + process.stderr.write( + `Warning: --limit ${flags.limit} splits a single update-time second; ` + + `rows in that second beyond the limit are skipped. Raise --limit to avoid this.\n`, + ) + } + } // Emit BEFORE advancing: if rendering throws, the window replays next run // rather than being silently skipped. diff --git a/src/commands/deal/bulk-update.js b/src/commands/deal/bulk-update.js index 3ec4719..0403868 100644 --- a/src/commands/deal/bulk-update.js +++ b/src/commands/deal/bulk-update.js @@ -112,15 +112,22 @@ export default class DealBulkUpdateCommand extends BaseCommand { spinner.stop() } - this.log( + await this.outputAction( + { + updated: summary.succeeded.length, + total: targets.length, + failed: summary.failed.length, + }, chalk.green( `Updated ${summary.succeeded.length}/${targets.length} deals`, ), ) if (summary.failed.length > 0) { + // Per-row failure detail is diagnostic — to stderr so a piped stdout + // stays a clean parseable summary. for (const { item, error } of summary.failed) { - this.log(chalk.red(` ✘ deal ${item}: ${error}`)) + this.logToStderr(chalk.red(` ✘ deal ${item}: ${error}`)) } throw new CliError( `${summary.failed.length} of ${targets.length} updates failed`, diff --git a/src/commands/deal/convert.js b/src/commands/deal/convert.js index b96fa25..32198c7 100644 --- a/src/commands/deal/convert.js +++ b/src/commands/deal/convert.js @@ -67,9 +67,10 @@ export default class DealConvertCommand extends BaseCommand { const conversionId = res.data?.conversion_id if (!flags.wait) { - this.log(chalk.green(`Conversion started: ${conversionId}`)) - this.log( - `Check status: ${this.config.bin} api GET ` + + await this.outputAction( + { conversion_id: conversionId, status: 'started', deal_id: args.id }, + chalk.green(`Conversion started: ${conversionId}`) + + `\nCheck status: ${this.config.bin} api GET ` + `/api/v2/deals/${args.id}/convert/status/${conversionId}`, ) return @@ -84,7 +85,13 @@ export default class DealConvertCommand extends BaseCommand { ) const state = status.data?.status if (state === 'completed') { - this.log( + await this.outputAction( + { + conversion_id: conversionId, + status: 'completed', + deal_id: args.id, + lead_id: status.data?.lead_id, + }, chalk.green( `Conversion completed: deal ${args.id} → lead ${status.data?.lead_id}`, ), @@ -92,8 +99,10 @@ export default class DealConvertCommand extends BaseCommand { return } if (state === 'failed' || state === 'rejected') { + // A server-side conversion rejection is a bad-data outcome (65), not an + // internal pdcli bug — exit 70 is reserved for genuine CLI defects. throw new CliError(`Conversion ${state} for deal ${args.id}`, { - exitCode: 70, + exitCode: 65, }) } if (elapsed + POLL_INTERVAL_MS > timeoutMs) { diff --git a/src/commands/deal/delete.js b/src/commands/deal/delete.js index 4db5b5a..abc4c7e 100644 --- a/src/commands/deal/delete.js +++ b/src/commands/deal/delete.js @@ -34,6 +34,9 @@ export default class DealDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v2/deals/${args.id}`) - this.log(chalk.green(`Deleted deal ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted deal ${args.id}`), + ) } } diff --git a/src/commands/deal/follower/remove.js b/src/commands/deal/follower/remove.js index 09c4fe3..93c7244 100644 --- a/src/commands/deal/follower/remove.js +++ b/src/commands/deal/follower/remove.js @@ -39,6 +39,9 @@ export default class DealFollowerRemoveCommand extends BaseCommand { } await this.apiClient.del(`/api/v2/deals/${args.id}/followers/${flags.user}`) - this.log(chalk.green(`Removed follower ${flags.user} from deal ${args.id}`)) + await this.outputAction( + { id: args.id, user_id: flags.user, removed: true }, + chalk.green(`Removed follower ${flags.user} from deal ${args.id}`), + ) } } diff --git a/src/commands/deal/participant/remove.js b/src/commands/deal/participant/remove.js index 99e78c3..ed4eecf 100644 --- a/src/commands/deal/participant/remove.js +++ b/src/commands/deal/participant/remove.js @@ -44,7 +44,8 @@ export default class DealParticipantRemoveCommand extends BaseCommand { await this.apiClient.del( `/api/v1/deals/${args.id}/participants/${flags.participant}`, ) - this.log( + await this.outputAction( + { id: args.id, participant_id: flags.participant, removed: true }, chalk.green( `Removed participant ${flags.participant} from deal ${args.id}`, ), diff --git a/src/commands/deal/product/remove.js b/src/commands/deal/product/remove.js index 4be9b93..e7edc1e 100644 --- a/src/commands/deal/product/remove.js +++ b/src/commands/deal/product/remove.js @@ -43,7 +43,8 @@ export default class DealProductRemoveCommand extends BaseCommand { await this.apiClient.del( `/api/v2/deals/${args.id}/products/${flags.attachment}`, ) - this.log( + await this.outputAction( + { id: args.id, attachment_id: flags.attachment, removed: true }, chalk.green( `Removed product attachment ${flags.attachment} from deal ${args.id}`, ), diff --git a/src/commands/digest.js b/src/commands/digest.js index 05cf15c..5ecf50b 100644 --- a/src/commands/digest.js +++ b/src/commands/digest.js @@ -71,6 +71,15 @@ export default class DigestCommand extends BaseCommand { { exitCode: 64 }, ) } + // --format renders md|html and returns before the machine-format path, so an + // explicit --output would be silently ignored. Reject it (only when set, not + // when defaulted) rather than printing the artifact and dropping the format. + if (flags.format && flags.output) { + throw new CliError( + '--output does not apply with --format; use one or the other', + { exitCode: 64 }, + ) + } const { id: pipelineId, name: pipelineName } = await resolvePipelineWithName(this.apiClient, flags.pipeline) diff --git a/src/commands/field/delete.js b/src/commands/field/delete.js index ebe8f90..da9ee95 100644 --- a/src/commands/field/delete.js +++ b/src/commands/field/delete.js @@ -51,6 +51,9 @@ export default class FieldDeleteCommand extends BaseCommand { clearFieldsCache() - this.log(chalk.green(`Deleted field ${args.field} on ${args.entity}`)) + await this.outputAction( + { entity: args.entity, field: args.field, deleted: true }, + chalk.green(`Deleted field ${args.field} on ${args.entity}`), + ) } } diff --git a/src/commands/file/delete.js b/src/commands/file/delete.js index 452f6ae..68f387b 100644 --- a/src/commands/file/delete.js +++ b/src/commands/file/delete.js @@ -36,6 +36,9 @@ export default class FileDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v1/files/${args.id}`) - this.log(chalk.green(`Deleted file ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted file ${args.id}`), + ) } } diff --git a/src/commands/filter/delete.js b/src/commands/filter/delete.js index b4c4fe2..615d5b0 100644 --- a/src/commands/filter/delete.js +++ b/src/commands/filter/delete.js @@ -36,6 +36,9 @@ export default class FilterDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v1/filters/${args.id}`) - this.log(chalk.green(`Deleted filter ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted filter ${args.id}`), + ) } } diff --git a/src/commands/lead/convert.js b/src/commands/lead/convert.js index a0211e3..a73b140 100644 --- a/src/commands/lead/convert.js +++ b/src/commands/lead/convert.js @@ -59,9 +59,10 @@ export default class LeadConvertCommand extends BaseCommand { const conversionId = res.data?.conversion_id if (!flags.wait) { - this.log(chalk.green(`Conversion started: ${conversionId}`)) - this.log( - `Check status: ${this.config.bin} api GET ` + + await this.outputAction( + { conversion_id: conversionId, status: 'started', lead_id: args.id }, + chalk.green(`Conversion started: ${conversionId}`) + + `\nCheck status: ${this.config.bin} api GET ` + `/api/v2/leads/${args.id}/convert/status/${conversionId}`, ) return @@ -76,7 +77,13 @@ export default class LeadConvertCommand extends BaseCommand { ) const state = status.data?.status if (state === 'completed') { - this.log( + await this.outputAction( + { + conversion_id: conversionId, + status: 'completed', + lead_id: args.id, + deal_id: status.data?.deal_id, + }, chalk.green( `Conversion completed: lead ${args.id} → deal ${status.data?.deal_id}`, ), @@ -84,8 +91,10 @@ export default class LeadConvertCommand extends BaseCommand { return } if (state === 'failed' || state === 'rejected') { + // A server-side conversion rejection is a bad-data outcome (65), not an + // internal pdcli bug — exit 70 is reserved for genuine CLI defects. throw new CliError(`Conversion ${state} for lead ${args.id}`, { - exitCode: 70, + exitCode: 65, }) } if (elapsed + POLL_INTERVAL_MS > timeoutMs) { diff --git a/src/commands/lead/delete.js b/src/commands/lead/delete.js index bdf31b4..95d368c 100644 --- a/src/commands/lead/delete.js +++ b/src/commands/lead/delete.js @@ -34,6 +34,9 @@ export default class LeadDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v1/leads/${args.id}`) - this.log(chalk.green(`Deleted lead ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted lead ${args.id}`), + ) } } diff --git a/src/commands/note/comment/delete.js b/src/commands/note/comment/delete.js index 75b78c1..6e55173 100644 --- a/src/commands/note/comment/delete.js +++ b/src/commands/note/comment/delete.js @@ -44,7 +44,8 @@ export default class NoteCommentDeleteCommand extends BaseCommand { await this.apiClient.del( `/api/v1/notes/${args.noteId}/comments/${flags.comment}`, ) - this.log( + await this.outputAction( + { note_id: args.noteId, comment_id: flags.comment, deleted: true }, chalk.green(`Deleted comment ${flags.comment} from note ${args.noteId}`), ) } diff --git a/src/commands/note/delete.js b/src/commands/note/delete.js index d9d25be..fd922ea 100644 --- a/src/commands/note/delete.js +++ b/src/commands/note/delete.js @@ -34,6 +34,9 @@ export default class NoteDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v1/notes/${args.id}`) - this.log(chalk.green(`Deleted note ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted note ${args.id}`), + ) } } diff --git a/src/commands/org/delete.js b/src/commands/org/delete.js index 4ccbfba..2b8c745 100644 --- a/src/commands/org/delete.js +++ b/src/commands/org/delete.js @@ -34,6 +34,9 @@ export default class OrgDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v2/organizations/${args.id}`) - this.log(chalk.green(`Deleted organization ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted organization ${args.id}`), + ) } } diff --git a/src/commands/org/follower/remove.js b/src/commands/org/follower/remove.js index 86f9f21..a90d425 100644 --- a/src/commands/org/follower/remove.js +++ b/src/commands/org/follower/remove.js @@ -41,7 +41,8 @@ export default class OrgFollowerRemoveCommand extends BaseCommand { await this.apiClient.del( `/api/v2/organizations/${args.id}/followers/${flags.user}`, ) - this.log( + await this.outputAction( + { id: args.id, user_id: flags.user, removed: true }, chalk.green( `Removed follower ${flags.user} from organization ${args.id}`, ), diff --git a/src/commands/org/import.js b/src/commands/org/import.js index e2c7402..1f5f38a 100644 --- a/src/commands/org/import.js +++ b/src/commands/org/import.js @@ -4,7 +4,7 @@ import chalk from 'chalk' import ora from 'ora' import BaseCommand from '../../base-command.js' import { parseCsv } from '../../lib/csv-parse.js' -import { prepareImportBodies } from '../../lib/import.js' +import { prepareImportBodies, intCell } from '../../lib/import.js' import { bulkRun } from '../../lib/bulk.js' import { bulkUpsertRows } from '../../lib/upsert.js' import { getFields } from '../../lib/fields.js' @@ -16,7 +16,7 @@ const SPECIAL_COLUMNS = { typed.name = value }, owner_id: (typed, value) => { - typed.owner_id = Number(value) + typed.owner_id = intCell(value, 'owner_id') }, } @@ -123,7 +123,12 @@ export default class OrgImportCommand extends BaseCommand { spinner.stop() } - this.log( + await this.outputAction( + { + imported: summary.succeeded.length, + total: bodies.length, + failed: summary.failed.length, + }, chalk.green( `Imported ${summary.succeeded.length}/${bodies.length} organizations`, ), @@ -131,7 +136,7 @@ export default class OrgImportCommand extends BaseCommand { if (summary.failed.length > 0) { for (const { item, error } of summary.failed) { - this.log(chalk.red(` ✘ ${item.name ?? '(unnamed)'}: ${error}`)) + this.logToStderr(chalk.red(` ✘ ${item.name ?? '(unnamed)'}: ${error}`)) } throw new CliError( `${summary.failed.length} of ${bodies.length} rows failed`, @@ -175,7 +180,14 @@ export default class OrgImportCommand extends BaseCommand { const { created, updated, unchanged } = summary.counts const prefix = flags['dry-run'] ? '[dry-run] ' : '' - this.log( + await this.outputAction( + { + created, + updated, + unchanged, + failed: summary.failed.length, + dryRun: flags['dry-run'], + }, chalk.green( `${prefix}${created} created, ${updated} updated, ${unchanged} unchanged`, ), @@ -183,7 +195,7 @@ export default class OrgImportCommand extends BaseCommand { if (summary.failed.length > 0) { for (const { item, error } of summary.failed) { - this.log(chalk.red(` ✘ ${matchOn}="${item.value}": ${error}`)) + this.logToStderr(chalk.red(` ✘ ${matchOn}="${item.value}": ${error}`)) } // Surface 65 when every failure is a data-validation error (ambiguous // match, empty match value); fall back to 1 for mixed/transport errors. diff --git a/src/commands/org/relationship/remove.js b/src/commands/org/relationship/remove.js index ac00f4e..a87ce2c 100644 --- a/src/commands/org/relationship/remove.js +++ b/src/commands/org/relationship/remove.js @@ -41,6 +41,9 @@ export default class OrgRelationshipRemoveCommand extends BaseCommand { } await this.apiClient.del(`/api/v1/organizationRelationships/${args.id}`) - this.log(chalk.green(`Deleted organization relationship ${args.id}`)) + await this.outputAction( + { id: args.id, removed: true }, + chalk.green(`Deleted organization relationship ${args.id}`), + ) } } diff --git a/src/commands/person/delete.js b/src/commands/person/delete.js index d949e38..7b9772b 100644 --- a/src/commands/person/delete.js +++ b/src/commands/person/delete.js @@ -34,6 +34,9 @@ export default class PersonDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v2/persons/${args.id}`) - this.log(chalk.green(`Deleted person ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted person ${args.id}`), + ) } } diff --git a/src/commands/person/follower/remove.js b/src/commands/person/follower/remove.js index ec85723..96425c3 100644 --- a/src/commands/person/follower/remove.js +++ b/src/commands/person/follower/remove.js @@ -41,7 +41,8 @@ export default class PersonFollowerRemoveCommand extends BaseCommand { await this.apiClient.del( `/api/v2/persons/${args.id}/followers/${flags.user}`, ) - this.log( + await this.outputAction( + { id: args.id, user_id: flags.user, removed: true }, chalk.green(`Removed follower ${flags.user} from person ${args.id}`), ) } diff --git a/src/commands/person/import.js b/src/commands/person/import.js index c9202c3..56277cf 100644 --- a/src/commands/person/import.js +++ b/src/commands/person/import.js @@ -4,7 +4,7 @@ import chalk from 'chalk' import ora from 'ora' import BaseCommand from '../../base-command.js' import { parseCsv } from '../../lib/csv-parse.js' -import { prepareImportBodies } from '../../lib/import.js' +import { prepareImportBodies, intCell } from '../../lib/import.js' import { bulkRun } from '../../lib/bulk.js' import { bulkUpsertRows } from '../../lib/upsert.js' import { getFields } from '../../lib/fields.js' @@ -22,10 +22,10 @@ const SPECIAL_COLUMNS = { typed.phones = [{ value, primary: true }] }, org_id: (typed, value) => { - typed.org_id = Number(value) + typed.org_id = intCell(value, 'org_id') }, owner_id: (typed, value) => { - typed.owner_id = Number(value) + typed.owner_id = intCell(value, 'owner_id') }, } @@ -132,7 +132,12 @@ export default class PersonImportCommand extends BaseCommand { spinner.stop() } - this.log( + await this.outputAction( + { + imported: summary.succeeded.length, + total: bodies.length, + failed: summary.failed.length, + }, chalk.green( `Imported ${summary.succeeded.length}/${bodies.length} persons`, ), @@ -140,7 +145,7 @@ export default class PersonImportCommand extends BaseCommand { if (summary.failed.length > 0) { for (const { item, error } of summary.failed) { - this.log(chalk.red(` ✘ ${item.name ?? '(unnamed)'}: ${error}`)) + this.logToStderr(chalk.red(` ✘ ${item.name ?? '(unnamed)'}: ${error}`)) } throw new CliError( `${summary.failed.length} of ${bodies.length} rows failed`, @@ -184,7 +189,14 @@ export default class PersonImportCommand extends BaseCommand { const { created, updated, unchanged } = summary.counts const prefix = flags['dry-run'] ? '[dry-run] ' : '' - this.log( + await this.outputAction( + { + created, + updated, + unchanged, + failed: summary.failed.length, + dryRun: flags['dry-run'], + }, chalk.green( `${prefix}${created} created, ${updated} updated, ${unchanged} unchanged`, ), @@ -192,7 +204,7 @@ export default class PersonImportCommand extends BaseCommand { if (summary.failed.length > 0) { for (const { item, error } of summary.failed) { - this.log(chalk.red(` ✘ ${matchOn}="${item.value}": ${error}`)) + this.logToStderr(chalk.red(` ✘ ${matchOn}="${item.value}": ${error}`)) } // Surface 65 when every failure is a data-validation error (ambiguous // match, empty match value); fall back to 1 for mixed/transport errors. diff --git a/src/commands/product/delete.js b/src/commands/product/delete.js index d66273a..7152ae0 100644 --- a/src/commands/product/delete.js +++ b/src/commands/product/delete.js @@ -34,6 +34,9 @@ export default class ProductDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v2/products/${args.id}`) - this.log(chalk.green(`Deleted product ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted product ${args.id}`), + ) } } diff --git a/src/commands/project/delete.js b/src/commands/project/delete.js index b9d9557..c416960 100644 --- a/src/commands/project/delete.js +++ b/src/commands/project/delete.js @@ -34,6 +34,9 @@ export default class ProjectDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v2/projects/${args.id}`) - this.log(chalk.green(`Deleted project ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted project ${args.id}`), + ) } } diff --git a/src/commands/search.js b/src/commands/search.js index 7499d9d..ff488bd 100644 --- a/src/commands/search.js +++ b/src/commands/search.js @@ -11,8 +11,8 @@ const columns = { }, } -// Item types that have a dedicated v2 scoped-search endpoint (narrower OAuth -// scope, narrower scope + 500-row pages vs itemSearch). Singular type → scoped endpoint path. +// Item types that have a dedicated v2 scoped-search endpoint (a wrapper of +// itemSearch with a narrower OAuth scope). Singular type → scoped endpoint path. const SCOPED_PATHS = { deal: '/api/v2/deals/search', person: '/api/v2/persons/search', @@ -99,7 +99,9 @@ export default class SearchCommand extends BaseCommand { status: scopedType === 'deal' ? flags.status : undefined, person_id: scopedType === 'deal' ? flags.person : undefined, organization_id: scopedType === 'deal' ? flags.org : undefined, - limit: flags.limit, + // The per-entity /search endpoints cap limit at 100 (the live API + // 400s on more, despite the 500 list cap — see src/lib/lookup.js). + limit: flags.limit != null ? Math.min(flags.limit, 100) : undefined, }, }) } else { diff --git a/src/commands/task/delete.js b/src/commands/task/delete.js index 240db84..89e56af 100644 --- a/src/commands/task/delete.js +++ b/src/commands/task/delete.js @@ -36,6 +36,9 @@ export default class TaskDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v2/tasks/${args.id}`) - this.log(chalk.green(`Deleted task ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted task ${args.id}`), + ) } } diff --git a/src/commands/webhook/delete.js b/src/commands/webhook/delete.js index db92bcf..96c4435 100644 --- a/src/commands/webhook/delete.js +++ b/src/commands/webhook/delete.js @@ -34,6 +34,9 @@ export default class WebhookDeleteCommand extends BaseCommand { } await this.apiClient.del(`/api/v1/webhooks/${args.id}`) - this.log(chalk.green(`Deleted webhook ${args.id}`)) + await this.outputAction( + { id: args.id, deleted: true }, + chalk.green(`Deleted webhook ${args.id}`), + ) } } diff --git a/src/hooks/command-not-found.js b/src/hooks/command-not-found.js index 0a5693c..c6a9da6 100644 --- a/src/hooks/command-not-found.js +++ b/src/hooks/command-not-found.js @@ -63,7 +63,21 @@ export default async function commandNotFound(options) { process.exit(0) } catch (err) { debug('alias execution failed: %s', err.message) - process.exit(err.exitCode ?? 1) + // Map the exit code faithfully. Our CliError carries .exitCode; the oclif + // ExitError/CLIError that handleError re-throws carry it ONLY on + // .oclif.exit (parse errors use 2, which maps to the usage code 64). + // Reading .exitCode alone would collapse every aliased failure to 1 and + // break the sysexits contract (auth 77, rate-limit 75, watch's gate 8). + const code = + err.exitCode ?? (err.oclif?.exit === 2 ? 64 : err.oclif?.exit) ?? 1 + // A CLIError from cmd.error() holds the human message but relies on + // oclif's top-level handler to print it — which this hook bypasses, so + // surface it ourselves. An ExitError (code 'EEXIT', thrown by cmd.exit + // after the JSON envelope is already on stderr) has nothing to add. + if (err.code !== 'EEXIT' && err.message) { + process.stderr.write(`${chalk.red('Error:')} ${err.message}\n`) + } + process.exit(code) } finally { if (isRoot) aliasChain.clear() } diff --git a/src/lib/auth.js b/src/lib/auth.js index 2172b94..ae13e76 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -137,10 +137,26 @@ export async function refreshAccessToken({ clientSecret, }) { debug('refreshing access token') - return tokenRequest( - { grant_type: 'refresh_token', refresh_token: refreshToken }, - { clientId, clientSecret }, - ) + try { + return await tokenRequest( + { grant_type: 'refresh_token', refresh_token: refreshToken }, + { clientId, clientSecret }, + ) + } catch (err) { + // A rejected refresh (expired/revoked refresh token → 400 invalid_grant, + // or 401 invalid_client) is an auth problem, not bad data — surface it as + // 77 with re-auth guidance so an agent keyed to 77 re-authenticates. + if ( + err instanceof ApiError && + (err.statusCode === 400 || err.statusCode === 401) + ) { + throw new CliError( + `OAuth token refresh failed (${err.message}). Run: pdcli auth login`, + { exitCode: 77, cause: err }, + ) + } + throw err + } } async function tokenRequest(params, { clientId, clientSecret }) { diff --git a/src/lib/body.js b/src/lib/body.js index 14eb0cb..1670268 100644 --- a/src/lib/body.js +++ b/src/lib/body.js @@ -22,5 +22,10 @@ export async function resolveBody(flags) { return Buffer.concat(chunks).toString('utf8').trim() } - throw new CliError('--body is required', { exitCode: 2 }) + throw new CliError( + '--body is required (pass a value, @file, or pipe stdin)', + { + exitCode: 64, + }, + ) } diff --git a/src/lib/bulk.js b/src/lib/bulk.js index 6987e52..7baee37 100644 --- a/src/lib/bulk.js +++ b/src/lib/bulk.js @@ -21,7 +21,7 @@ export async function resolveTargets( listPath, ) { if (ids) { - return ids.split(',').map(parseId) + return requireTargets(splitNonEmpty(ids, ',').map(parseId)) } if (filter != null) { @@ -33,7 +33,7 @@ export async function resolveTargets( })) { targets.push(item.id) } - return targets + return requireTargets(targets) } if (!stdin.isTTY) { @@ -41,12 +41,18 @@ export async function resolveTargets( for await (const chunk of stdin) chunks.push(chunk) const text = Buffer.concat(chunks).toString('utf8').trim() if (text.startsWith('[')) { - const parsed = JSON.parse(text) - return parsed.map((entry) => - typeof entry === 'object' ? entry.id : parseId(String(entry)), - ) + let parsed + try { + parsed = JSON.parse(text) + } catch (cause) { + throw new CliError('Piped stdin is not valid JSON', { + exitCode: 65, + cause, + }) + } + return requireTargets(parsed.map(parseEntry)) } - return text.split('\n').map(parseId) + return requireTargets(splitNonEmpty(text, '\n').map(parseId)) } throw new CliError( @@ -55,12 +61,46 @@ export async function resolveTargets( ) } +/** Split on a separator, dropping empty / whitespace-only segments. */ +function splitNonEmpty(text, separator) { + return text + .split(separator) + .map((segment) => segment.trim()) + .filter((segment) => segment !== '') +} + +/** Fail with a no-targets usage error when nothing resolved. */ +function requireTargets(targets) { + if (targets.length === 0) { + throw new CliError( + 'No targets — pass --ids, --filter, or pipe ids on stdin', + { exitCode: 64 }, + ) + } + return targets +} + +/** Resolve one JSON-array entry (a bare id, or an object with an id). */ +function parseEntry(entry) { + const raw = typeof entry === 'object' && entry !== null ? entry.id : entry + const id = Number(raw) + if (!Number.isInteger(id) || id <= 0) { + throw new CliError( + `Invalid id ${JSON.stringify(raw)} — expected a positive integer`, + { exitCode: 65 }, + ) + } + return id +} + function parseId(raw) { - const id = Number(raw.trim()) - if (!Number.isInteger(id)) { - throw new CliError(`Invalid id "${raw.trim()}" — expected an integer`, { - exitCode: 64, - }) + const trimmed = raw.trim() + const id = Number(trimmed) + if (!Number.isInteger(id) || id <= 0) { + throw new CliError( + `Invalid id "${trimmed}" — expected a positive integer`, + { exitCode: 64 }, + ) } return id } diff --git a/src/lib/changelog.js b/src/lib/changelog.js index b218920..40dd5e9 100644 --- a/src/lib/changelog.js +++ b/src/lib/changelog.js @@ -23,6 +23,7 @@ export async function fetchChangelog(client, dealId, { limit } = {}) { client.pageV2(`/api/v1/deals/${dealId}/changelog`, { limit: limit ?? MAX_PAGE_LIMIT, }), + limit, ) } diff --git a/src/lib/client.js b/src/lib/client.js index 55460f2..37a3432 100644 --- a/src/lib/client.js +++ b/src/lib/client.js @@ -163,17 +163,53 @@ export function createClient({ const maxAttempts = retry ? 3 : 1 let attempts = 0 let sawRateLimit = false + let lastRateLimitWait = DEFAULT_RETRY_AFTER_S let refreshed = false while (attempts < maxAttempts) { attempts++ - const res = await fetch(url, { - method, - headers: { ...authHeaders(), ...extraHeaders }, - body: makeBody ? makeBody() : undefined, - signal: AbortSignal.timeout(timeout), - }) + let res + try { + res = await fetch(url, { + method, + headers: { ...authHeaders(), ...extraHeaders }, + body: makeBody ? makeBody() : undefined, + signal: AbortSignal.timeout(timeout), + // Never auto-follow redirects: the default fetch would re-send the + // x-api-token header to the redirect target, leaking it off the + // host-locked domain (Node only strips `authorization` cross-origin, + // not custom headers). We detect the opaqueredirect below and refuse. + redirect: 'manual', + }) + } catch (cause) { + // fetch rejects on DNS failure / connection refused (TypeError + // 'fetch failed') and on AbortSignal.timeout (a TimeoutError/AbortError + // DOMException). These are reachability problems → service-unavailable + // (69), NOT an internal CLI bug (70). Retry like a 5xx; on the last + // attempt surface the cause. + if (attempts < maxAttempts) { + const delay = Math.min(1000 * 2 ** attempts, 30_000) + jitter() + debug('fetch failed (%s), retrying in %dms', cause.name, delay) + await sleep(delay) + continue + } + throw new ServiceUnavailableError( + `Cannot reach ${baseOrigin}: ${cause.message}`, + { cause }, + ) + } + + // redirect:'manual' surfaces a 30x as a readable response that we never + // follow. Refuse it — auto-following would re-send the token to the + // redirect target, leaking it off the host-locked domain. + if (res.status >= 300 && res.status < 400) { + throw new CliError( + `Refusing to follow a redirect off your Pipedrive company host ` + + `(${baseOrigin}) — the API token is never re-sent to another host`, + { exitCode: 78 }, + ) + } debug('%s %s → %d', method, path, res.status) @@ -200,6 +236,7 @@ export function createClient({ ) if (!retry) throw new RateLimitError(wait) sawRateLimit = true + lastRateLimitWait = wait debug('rate limited, waiting %ds', wait) await sleep(wait * 1000) continue @@ -265,7 +302,11 @@ export function createClient({ return text ? JSON.parse(text) : null } - throw new ServiceUnavailableError() + // The only path that exhausts the loop is the 429 retry (every other + // terminal status returns or throws inside the loop: 5xx and network + // failures throw on their final attempt, success returns). A reachable API + // that kept throttling us is rate-limited (75), not unavailable (69). + throw new RateLimitError(lastRateLimitWait) } /** diff --git a/src/lib/config.js b/src/lib/config.js index 60a3806..3d93d13 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -12,7 +12,14 @@ const schema = { let _conf export function getConf() { - _conf ??= new Conf({ projectName: 'pdcli', schema }) + // PDCLI_CONFIG_DIR overrides the config directory (used to isolate the store + // in tests/CI/sandboxes so a run never touches the user's real profiles); + // undefined falls back to conf's default OS config path. + _conf ??= new Conf({ + projectName: 'pdcli', + schema, + cwd: process.env.PDCLI_CONFIG_DIR, + }) return _conf } diff --git a/src/lib/csv-parse.js b/src/lib/csv-parse.js index 00af1b6..be52272 100644 --- a/src/lib/csv-parse.js +++ b/src/lib/csv-parse.js @@ -7,6 +7,11 @@ import { CliError } from './errors.js' * @returns {{ headers: string[], rows: string[][] }} */ export function parseCsv(text) { + // Strip a leading UTF-8 BOM (U+FEFF) so the first header is clean — Excel's + // "CSV UTF-8" export and many Windows/Sheets tools emit one, which would + // otherwise prepend U+FEFF to the first column name and break header matching. + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1) + const records = [] let record = [] let field = '' diff --git a/src/lib/errors.js b/src/lib/errors.js index 3da27e7..8b5d140 100644 --- a/src/lib/errors.js +++ b/src/lib/errors.js @@ -28,8 +28,9 @@ export class RateLimitError extends CliError { } export class ServiceUnavailableError extends CliError { - constructor() { - super('Pipedrive API is unavailable', { exitCode: 69 }) + /** @param {string} [message] @param {{cause?: Error}} [options] */ + constructor(message = 'Pipedrive API is unavailable', { cause } = {}) { + super(message, { exitCode: 69, cause }) } } @@ -79,6 +80,23 @@ export class ApiError extends CliError { } } +/** + * Recover the --output / -o value from raw argv. On an oclif PARSE failure + * this.flags is never populated, so handleError would otherwise lose an + * explicit --output and fall back to TTY/stored resolution — meaning a usage + * error in a TTY ignored `--output json`. Reads cmd.argv (the raw args), which + * is set on the command instance before parsing. + * @param {string[]} [argv] + * @returns {string | undefined} + */ +function outputFromArgv(argv = []) { + for (let i = 0; i < argv.length; i++) { + const m = /^(?:--output|-o)(?:=(.+))?$/.exec(argv[i]) + if (m) return m[1] ?? argv[i + 1] + } + return undefined +} + /** * @param {Error} err * @param {import('@oclif/core').Command} cmd @@ -102,7 +120,10 @@ export function handleError(err, cmd) { ? cmd.storedDefaultOutput() : undefined const format = - flags.output ?? stored ?? (process.stdout.isTTY ? 'table' : 'json') + flags.output ?? + outputFromArgv(cmd.argv) ?? + stored ?? + (process.stdout.isTTY ? 'table' : 'json') if (format !== 'table') { const payload = { diff --git a/src/lib/import.js b/src/lib/import.js index 6b59225..cd583a7 100644 --- a/src/lib/import.js +++ b/src/lib/import.js @@ -13,6 +13,23 @@ import { CliError } from './errors.js' * @param {object[]} [options.defs] field definitions for non-special headers * @returns {object[]} one request body per row */ +/** + * Parse a CSV cell that must be an integer id (org_id, owner_id, …). A + * non-integer (`Number('N/A')` → NaN, `'1.5'` → not integer) would otherwise + * serialize as `null` in the write body and silently clear a relation — + * refuse it as a data error instead. + * @param {string} value + * @param {string} field + * @returns {number} + */ +export function intCell(value, field) { + const n = Number(value) + if (!Number.isInteger(n)) { + throw new CliError(`"${value}" is not a valid ${field}`, { exitCode: 65 }) + } + return n +} + export function prepareImportBodies({ headers, rows, @@ -35,23 +52,25 @@ export function prepareImportBodies({ } return rows.map((row, index) => { - const typed = {} - const fields = [] + try { + const typed = {} + const fields = [] - headers.forEach((header, i) => { - const value = row[i] - if (value === '') return - const special = specials[header.toLowerCase()] - if (special) { - special(typed, value) - return - } - fields.push(`${header}=${value}`) - }) + headers.forEach((header, i) => { + const value = row[i] + if (value === '') return + const special = specials[header.toLowerCase()] + if (special) { + special(typed, value) + return + } + fields.push(`${header}=${value}`) + }) - try { return buildWriteBody({ typed, fields, defs }) } catch (err) { + // Prefix the row number onto any per-row failure (special-column + // validation or buildWriteBody) so the user can find the bad cell. throw new CliError(`CSV row ${index + 2}: ${err.message}`, { exitCode: 65, }) diff --git a/src/lib/input.js b/src/lib/input.js index cb59c2e..1d3180f 100644 --- a/src/lib/input.js +++ b/src/lib/input.js @@ -90,7 +90,16 @@ function coerceValue(def, rawValue) { return rawValue.split(',').map((label) => resolveOption(def, label.trim())) } if (NUMERIC_TYPES.has(def.field_type)) { - return Number(rawValue) + const n = Number(rawValue) + // A non-numeric value coerces to NaN, which JSON.stringify serializes as + // null — silently writing an empty value. Refuse it as a data error. + if (!Number.isFinite(n)) { + throw new CliError( + `"${rawValue}" is not a valid number for field "${def.field_name ?? def.field_code}"`, + { exitCode: 65 }, + ) + } + return n } return rawValue } diff --git a/src/lib/lookup.js b/src/lib/lookup.js index b93235c..2765a93 100644 --- a/src/lib/lookup.js +++ b/src/lib/lookup.js @@ -1,13 +1,18 @@ import { CliError } from './errors.js' -/** v2 custom-field types that the search endpoints can match on. */ +/** + * v2 custom-field types that the search endpoints can match on. NOTE: + * `monetary` and `address` are deliberately excluded — v2 returns them as + * objects ({ value, currency } / { value, … }) on the full record, so the + * scalar comparison below never matches and every upsert would create a + * duplicate. Until the object shape is supported on both read and write, + * --by on those types is refused (exit 64) rather than silently duplicating. + */ const SEARCHABLE_TYPES = new Set([ - 'address', 'varchar', 'varchar_auto', 'text', 'double', - 'monetary', 'phone', ]) const NUMERIC_TYPES = new Set(['double', 'monetary']) @@ -114,7 +119,7 @@ export async function lookupByField({ if (!SEARCHABLE_TYPES.has(def.field_type)) { throw new CliError( `Field "${field}" (${def.field_type}) is not searchable — --by needs a ` + - `built-in key or a searchable custom field (text/number/phone/address)`, + `built-in key or a searchable custom field (text/number/phone)`, { exitCode: 64 }, ) } diff --git a/src/lib/output/csv.js b/src/lib/output/csv.js index 2dba15c..c58c7d9 100644 --- a/src/lib/output/csv.js +++ b/src/lib/output/csv.js @@ -5,19 +5,45 @@ */ export function formatCsv(data, columns) { if (!data || data.length === 0) return '' - const entries = Object.entries(columns) + let entries = Object.entries(columns) + if (entries.length === 0) { + // No explicit columns (the machine-format path passes {}). Derive a stable + // column set from the union of the rows' own top-level keys, so csv emits + // the data instead of a silently-blank header+rows — which, in a feed like + // `changes --output csv`, would advance the watermark over rows that were + // never written (silent data loss). + const keys = [] + const seen = new Set() + for (const row of data) { + for (const k of Object.keys(row)) { + if (!seen.has(k)) { + seen.add(k) + keys.push(k) + } + } + } + entries = keys.map((k) => [k, { header: k }]) + } const header = entries.map(([, col]) => col.header).join(',') const rows = data.map((row) => entries .map(([key, col]) => { const val = col.get ? col.get(row) : row[col.key ?? key] - return csvEscape(val != null ? String(val) : '') + return csvEscape(stringifyValue(val)) }) .join(','), ) return [header, ...rows].join('\n') } +/** Primitives stringify directly; objects/arrays become JSON (never the + * useless "[object Object]"); null/undefined become an empty cell. */ +function stringifyValue(val) { + if (val == null) return '' + if (typeof val === 'object') return JSON.stringify(val) + return String(val) +} + function csvEscape(val) { if (val.includes(',') || val.includes('"') || val.includes('\n')) { return '"' + val.replace(/"/g, '""') + '"' diff --git a/src/lib/upsert.js b/src/lib/upsert.js index 1d17a40..4dce2bd 100644 --- a/src/lib/upsert.js +++ b/src/lib/upsert.js @@ -14,36 +14,70 @@ const WRITE_PATH = { const NUMERIC_TYPES = new Set(['double', 'monetary']) +/** + * Resolve which body key the match field `field` writes to: a root key + * (emails/phones/name/title) or a `custom_fields` hash code. Shared by + * injectMatch (create) and stripMatchField (update) so the two never drift. + * @returns {{ root?: string, custom?: string, def?: object }} + */ +function matchFieldTarget(entity, field, defs) { + const key = field.toLowerCase() + if (entity === 'person' && key === 'email') return { root: 'emails' } + if (entity === 'person' && key === 'phone') return { root: 'phones' } + if ((entity === 'person' || entity === 'org') && key === 'name') { + return { root: 'name' } + } + if (entity === 'deal' && key === 'title') return { root: 'title' } + const def = defs.find( + (d) => d.field_name?.toLowerCase() === key || d.field_code === field, + ) + return { custom: def?.field_code, def } +} + /** * Inject the match field+value into a CREATE body (so a created record actually * carries the value it was matched on). `??=` so an explicit body value wins. */ function injectMatch(body, entity, field, value, defs) { - const key = field.toLowerCase() - if (entity === 'person' && key === 'email') { + const t = matchFieldTarget(entity, field, defs) + if (t.root === 'emails') { body.emails ??= [{ value, primary: true }] - return - } - if (entity === 'person' && key === 'phone') { + } else if (t.root === 'phones') { body.phones ??= [{ value, primary: true }] - return + } else if (t.root) { + body[t.root] ??= value + } else { + body.custom_fields ??= {} + body.custom_fields[t.custom] ??= NUMERIC_TYPES.has(t.def.field_type) + ? Number(value) + : value } - if ((entity === 'person' || entity === 'org') && key === 'name') { - body.name ??= value - return - } - if (entity === 'deal' && key === 'title') { - body.title ??= value - return +} + +/** + * Keep the match (identity) field from narrowing the record on UPDATE. For + * emails/phones the CSV path injects a single-element array holding just the + * match value; diffBody compares value-SETS, so {match} vs the record's + * {match, other} would emit [match] and PATCH `other` away (the v0.18 CRITICAL). + * Drop only that auto-injected single value. A multi-entry body is an explicit + * full set the caller wants written — leave it for diffBody. Scalar (name/title) + * and custom-field matches need no handling here: diffBody already drops them + * when equal and emits a genuine rename when not. + */ +function stripMatchField(body, entity, field, value, defs) { + const t = matchFieldTarget(entity, field, defs) + if (t.root !== 'emails' && t.root !== 'phones') return body + const arr = body[t.root] + if ( + Array.isArray(arr) && + arr.length === 1 && + String(arr[0]?.value ?? '').toLowerCase() === String(value).toLowerCase() + ) { + const out = { ...body } + delete out[t.root] + return out } - // custom field - const def = defs.find( - (d) => d.field_name?.toLowerCase() === key || d.field_code === field, - ) - body.custom_fields ??= {} - body.custom_fields[def.field_code] ??= NUMERIC_TYPES.has(def.field_type) - ? Number(value) - : value + return body } /** True if every element of an array is a primitive (or null). */ @@ -151,8 +185,11 @@ export async function runUpsert({ return { action: 'created', id: res.data?.id, record: res.data } } - // unique → diff-before-PATCH - const changed = diffBody(body, match.record) + // unique → diff-before-PATCH (don't let the matched email/phone narrow the set) + const changed = diffBody( + stripMatchField(body, entity, by, value, defs), + match.record, + ) if (Object.keys(changed).length === 0) { return { action: 'unchanged', id: match.id } } diff --git a/test/base-command.test.js b/test/base-command.test.js index f4bd432..a92da0e 100644 --- a/test/base-command.test.js +++ b/test/base-command.test.js @@ -360,6 +360,103 @@ describe('BaseCommand output formats and filters', () => { expect(stdout).toContain('f@a.com') expect(stdout).not.toContain('Field User') }) + + it('--fields projects keys for json output (not silently ignored)', async () => { + nock(API_BASE) + .get('/api/v2/users/me') + .reply(200, { + success: true, + data: { id: 1, name: 'Field User', email: 'f@a.com' }, + }) + + const stdout = await captureLogs(ApiCmd, [ + '--fields', + 'id,email', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual({ id: 1, email: 'f@a.com' }) + }) + + it('--fields projects keys across an array for json output', async () => { + class ListCmd extends BaseCommand { + static skipAuth = true + async run() { + await this.parse(ListCmd) + await this.outputResults( + [ + { id: 1, name: 'A', email: 'a@x.com' }, + { id: 2, name: 'B', email: 'b@x.com' }, + ], + { id: { header: 'ID' }, name: { header: 'Name' } }, + ) + } + } + const stdout = await captureLogs(ListCmd, [ + '--fields', + 'id,email', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual([ + { id: 1, email: 'a@x.com' }, + { id: 2, email: 'b@x.com' }, + ]) + }) + + it('--fields projects keys for yaml output', async () => { + nock(API_BASE) + .get('/api/v2/users/me') + .reply(200, { + success: true, + data: { id: 1, name: 'Field User', email: 'f@a.com' }, + }) + + const stdout = await captureLogs(ApiCmd, [ + '--fields', + 'id,email', + '--output', + 'yaml', + ]) + expect(stdout).toContain('email: f@a.com') + expect(stdout).not.toContain('Field User') + }) +}) + +describe('outputAction (mutation output)', () => { + class ActionCmd extends BaseCommand { + static skipAuth = true + async run() { + await this.parse(ActionCmd) + await this.outputAction({ deleted: 42 }, 'Deleted deal 42') + } + } + + beforeEach(() => { + nock.cleanAll() + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + }) + + it('prints the human one-liner in interactive table mode', async () => { + process.stdout.isTTY = true + const stdout = await captureLogs(ActionCmd, []) + expect(stdout).toBe('Deleted deal 42') + }) + + it('emits the machine object as JSON when piped/--output json', async () => { + const stdout = await captureLogs(ActionCmd, ['--output', 'json']) + expect(JSON.parse(stdout)).toEqual({ deleted: 42 }) + }) + + it('honors --jq on the machine object', async () => { + const stdout = await captureLogs(ActionCmd, [ + '--output', + 'json', + '--jq', + '.deleted', + ]) + expect(stdout.trim()).toBe('42') + }) }) describe('--jq with array data', () => { @@ -516,6 +613,46 @@ describe('resolveFormat with default_output', () => { expect(payload.message).toMatch(/nonexistent flag/i) }) + it('honors explicit --output json on a TTY parse error (recovers from argv)', async () => { + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + class ParseCmd extends BaseCommand { + static skipAuth = true + async run() {} + } + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true // interactive, but --output json is explicit + const writes = [] + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) + await ParseCmd.run(['--output', 'json', '--no-such-flag']).catch(() => {}) + spy.mockRestore() + process.stdout.isTTY = origIsTTY + const payload = JSON.parse(writes.join('')) + expect(payload.exitCode).toBe(64) + }) + + it('recovers --output=yaml form from argv on a parse error', async () => { + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + class ParseCmd extends BaseCommand { + static skipAuth = true + async run() {} + } + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true + const writes = [] + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) + await ParseCmd.run(['--output=yaml', '--no-such-flag']).catch(() => {}) + spy.mockRestore() + process.stdout.isTTY = origIsTTY + // yaml error envelope is the JSON envelope (no yaml error serializer) + expect(writes.join('')).toMatch(/"exitCode": 64/) + }) + it('emits JSON errors when piped, even with no flag or stored default', async () => { mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) class FailCmd extends BaseCommand { diff --git a/test/commands/activity/delete.test.js b/test/commands/activity/delete.test.js index e3f5b4e..145fd9c 100644 --- a/test/commands/activity/delete.test.js +++ b/test/commands/activity/delete.test.js @@ -73,4 +73,20 @@ describe('activity delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/activities/9') + .reply(200, { success: true, data: { id: 9 } }) + + const stdout = await runCmd(ActivityDeleteCommand, [ + '9', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 9, deleted: true }) + }) }) diff --git a/test/commands/api.test.js b/test/commands/api.test.js index 2b5e2b8..d822733 100644 --- a/test/commands/api.test.js +++ b/test/commands/api.test.js @@ -65,6 +65,42 @@ describe('api', () => { expect(JSON.parse(stdout).data.id).toBe(9) }) + it('rejects malformed --body JSON with exit 65 (not an internal 70)', async () => { + const err = await ApiCommand.run([ + 'POST', + '/api/v2/deals', + '--body', + '{not json', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(65) + expect(err.message).toMatch(/JSON/i) + }) + + it('reads the request body from piped stdin', async () => { + const { Readable } = await import('node:stream') + const origStdin = process.stdin + const mockStdin = Readable.from([Buffer.from('{"title":"Piped"}\n')]) + mockStdin.isTTY = false + Object.defineProperty(process, 'stdin', { + value: mockStdin, + writable: true, + configurable: true, + }) + try { + mockApi() + .post('/api/v2/deals', { title: 'Piped' }) + .reply(201, { success: true, data: { id: 7 } }) + const stdout = await runCmd(ApiCommand, ['POST', '/api/v2/deals']) + expect(JSON.parse(stdout).data.id).toBe(7) + } finally { + Object.defineProperty(process, 'stdin', { + value: origStdin, + writable: true, + configurable: true, + }) + } + }) + it('refuses off-host absolute URLs (host-lock)', async () => { nock.disableNetConnect() try { diff --git a/test/commands/backup.test.js b/test/commands/backup.test.js index 6565012..83575e3 100644 --- a/test/commands/backup.test.js +++ b/test/commands/backup.test.js @@ -71,6 +71,23 @@ describe('backup', () => { expect(stdout).toContain('16 skipped') }) + it('emits a JSON summary with --output json (skipped defaults to 0)', async () => { + mockRunBackup.mockResolvedValue({ total: 18, exported: 18, counts: {} }) + + const stdout = await runCmd(BackupCommand, [ + '--dir', + '/tmp/pd-backup', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual({ + exported: 18, + total: 18, + skipped: 0, + dir: '/tmp/pd-backup', + }) + }) + it('defaults the directory to ./pipedrive-backup', async () => { mockRunBackup.mockResolvedValue({ total: 18, diff --git a/test/commands/changes.test.js b/test/commands/changes.test.js index e05b1cd..8499264 100644 --- a/test/commands/changes.test.js +++ b/test/commands/changes.test.js @@ -198,6 +198,131 @@ describe('changes', () => { ) }) + it('does not skip rows sharing the cut second when --limit truncates mid-second', async () => { + const T1 = '2026-06-10T12:00:00Z' + const T2 = '2026-06-10T12:00:05Z' + mockEntities({ + deals: [ + { id: 1, title: 'a', add_time: T1, update_time: T1 }, + { id: 2, title: 'b', add_time: T2, update_time: T2 }, + { id: 3, title: 'c', add_time: T2, update_time: T2 }, + ], + persons: [], + organizations: [], + activities: [], + products: [], + }) + + const stdout = await runCmd(ChangesCommand, [ + '--since', + '30d', + '--limit', + '2', + '--output', + 'json', + ]) + const rows = JSON.parse(stdout) + // The two T2 rows would be split by --limit 2; rather than skip one, only + // the T1 row is emitted and the T2 pair replays next run. + expect(rows).toHaveLength(1) + expect(rows[0].id).toBe(1) + expect(watermark).toBe( + formatApiDatetime(new Date(new Date(T1).getTime() + 1000)), + ) + }) + + it('tolerates a dropped row with no update_time at the cut (no false trim)', async () => { + const T1 = '2026-06-10T12:00:00Z' + mockEntities({ + deals: [ + { id: 1, title: 'a', add_time: T1, update_time: T1 }, + { id: 2, title: 'b', add_time: T1 }, // no update_time → sorts last + ], + persons: [], + organizations: [], + activities: [], + products: [], + }) + + const stdout = await runCmd(ChangesCommand, [ + '--since', + '30d', + '--limit', + '1', + '--output', + 'json', + ]) + const rows = JSON.parse(stdout) + expect(rows).toHaveLength(1) + expect(rows[0].id).toBe(1) + }) + + it('does not warn when truncation lands on null-update_time rows', async () => { + const T = '2026-06-10T12:00:00Z' + mockEntities({ + deals: [ + { id: 1, title: 'a', add_time: T, update_time: T }, + { id: 2, title: 'b', add_time: T }, // null update_time → sorts last + { id: 3, title: 'c', add_time: T }, // null update_time → sorts last + ], + persons: [], + organizations: [], + activities: [], + products: [], + }) + + const writes = [] + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) + const stdout = await runCmd(ChangesCommand, [ + '--since', + '30d', + '--limit', + '2', + '--output', + 'json', + ]) + spy.mockRestore() + expect(JSON.parse(stdout)).toHaveLength(2) + // The cut row has no update_time (can't be resumed by updated_since); the + // "single second" warning would be misleading, so it must not fire. + expect(writes.join('')).not.toMatch(/splits a single/i) + }) + + it('warns (does not silently skip) when one second exceeds --limit', async () => { + const T = '2026-06-10T12:00:00Z' + mockEntities({ + deals: [ + { id: 1, title: 'a', add_time: T, update_time: T }, + { id: 2, title: 'b', add_time: T, update_time: T }, + { id: 3, title: 'c', add_time: T, update_time: T }, + ], + persons: [], + organizations: [], + activities: [], + products: [], + }) + + const writes = [] + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) + const stdout = await runCmd(ChangesCommand, [ + '--since', + '30d', + '--limit', + '2', + '--output', + 'json', + ]) + spy.mockRestore() + expect(JSON.parse(stdout)).toHaveLength(2) + expect(writes.join('')).toMatch(/splits a single update-time second/i) + }) + it('does not advance the watermark with --peek', async () => { watermark = '2026-01-01T00:00:00Z' mockEntities() diff --git a/test/commands/deal/convert.test.js b/test/commands/deal/convert.test.js index bd16b7b..6bf1178 100644 --- a/test/commands/deal/convert.test.js +++ b/test/commands/deal/convert.test.js @@ -128,9 +128,12 @@ describe('deal convert', () => { }) DealConvertCommand.sleepFn = vi.fn().mockResolvedValue(undefined) - await expect( - DealConvertCommand.run(['42', '--yes', '--wait']), - ).rejects.toThrow(/failed/i) + const err = await DealConvertCommand.run(['42', '--yes', '--wait']).catch( + (e) => e, + ) + expect(err.message).toMatch(/failed/i) + // A server-rejected conversion is bad data (65), NOT an internal bug (70). + expect(err.exitCode ?? err.oclif?.exit).toBe(65) }) it('--wait throws when rejected', async () => { @@ -145,9 +148,38 @@ describe('deal convert', () => { }) DealConvertCommand.sleepFn = vi.fn().mockResolvedValue(undefined) - await expect( - DealConvertCommand.run(['42', '--yes', '--wait']), - ).rejects.toThrow(/reject/i) + const err = await DealConvertCommand.run(['42', '--yes', '--wait']).catch( + (e) => e, + ) + expect(err.message).toMatch(/reject/i) + expect(err.exitCode ?? err.oclif?.exit).toBe(65) + }) + + it('--output json exposes conversion_id and the new lead_id (not prose-only)', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .post('/api/v2/deals/42/convert/lead', {}) + .reply(200, { success: true, data: { conversion_id: CONVERSION } }) + .get(`/api/v2/deals/42/convert/status/${CONVERSION}`) + .reply(200, { + success: true, + data: { conversion_id: CONVERSION, status: 'completed', lead_id: LEAD }, + }) + + DealConvertCommand.sleepFn = vi.fn().mockResolvedValue(undefined) + const stdout = await runCmd(DealConvertCommand, [ + '42', + '--yes', + '--wait', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual({ + conversion_id: CONVERSION, + status: 'completed', + deal_id: 42, + lead_id: LEAD, + }) }) it('--wait times out after --timeout-secs without a terminal status', async () => { diff --git a/test/commands/deal/delete.test.js b/test/commands/deal/delete.test.js index aa8b86d..5998653 100644 --- a/test/commands/deal/delete.test.js +++ b/test/commands/deal/delete.test.js @@ -73,4 +73,20 @@ describe('deal delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/deals/42') + .reply(200, { success: true, data: { id: 42 } }) + + const stdout = await runCmd(DealDeleteCommand, [ + '42', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 42, deleted: true }) + }) }) diff --git a/test/commands/deal/follower/remove.test.js b/test/commands/deal/follower/remove.test.js index b501582..615d077 100644 --- a/test/commands/deal/follower/remove.test.js +++ b/test/commands/deal/follower/remove.test.js @@ -92,4 +92,22 @@ describe('deal follower remove', () => { DealFollowerRemoveCommand.run(['--user', '5']), ).rejects.toThrow() }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/deals/42/followers/5') + .reply(200, { success: true, data: { user_id: 5 } }) + + const stdout = await runCmd(DealFollowerRemoveCommand, [ + '42', + '--user', + '5', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 42, user_id: 5, removed: true }) + }) }) diff --git a/test/commands/deal/participant/remove.test.js b/test/commands/deal/participant/remove.test.js index 4791781..42de55f 100644 --- a/test/commands/deal/participant/remove.test.js +++ b/test/commands/deal/participant/remove.test.js @@ -97,4 +97,26 @@ describe('deal participant remove', () => { DealParticipantRemoveCommand.run(['--participant', '3']), ).rejects.toThrow() }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v1/deals/42/participants/3') + .reply(200, { success: true, data: { id: 3 } }) + + const stdout = await runCmd(DealParticipantRemoveCommand, [ + '42', + '--participant', + '3', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ + id: 42, + participant_id: 3, + removed: true, + }) + }) }) diff --git a/test/commands/deal/product/remove.test.js b/test/commands/deal/product/remove.test.js index 795caf4..1eddbe1 100644 --- a/test/commands/deal/product/remove.test.js +++ b/test/commands/deal/product/remove.test.js @@ -89,4 +89,26 @@ describe('deal product remove', () => { DealProductRemoveCommand.run(['--attachment', '3']), ).rejects.toThrow() }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/deals/42/products/3') + .reply(200, { success: true, data: { id: 3 } }) + + const stdout = await runCmd(DealProductRemoveCommand, [ + '42', + '--attachment', + '3', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ + id: 42, + attachment_id: 3, + removed: true, + }) + }) }) diff --git a/test/commands/digest.test.js b/test/commands/digest.test.js index eae8af5..c8ad609 100644 --- a/test/commands/digest.test.js +++ b/test/commands/digest.test.js @@ -361,6 +361,31 @@ describe('digest', () => { expect(err.message).toMatch(/--format/) }) + it('errors with exit 64 when an explicit --output is combined with --format', async () => { + // An explicit --output routes the error through the JSON envelope, so the + // thrown EEXIT carries only the code; assert the message on the envelope. + const writes = [] + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) + let err + try { + err = await DigestCommand.run([ + '--pipeline', + '1', + '--format', + 'md', + '--output', + 'json', + ]).catch((e) => e) + } finally { + spy.mockRestore() + } + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + expect(writes.join('')).toMatch(/--format/) + }) + it('routes --jq through the whole packet even with --output table', async () => { mockCore() mockGoal() diff --git a/test/commands/field/delete.test.js b/test/commands/field/delete.test.js index 8f91484..ebaeb44 100644 --- a/test/commands/field/delete.test.js +++ b/test/commands/field/delete.test.js @@ -106,4 +106,25 @@ describe('field delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete(`/api/v2/dealFields/${HASH}`) + .reply(200, { success: true, data: { field_code: HASH } }) + + const stdout = await runCmd(FieldDeleteCommand, [ + 'deal', + HASH, + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ + entity: 'deal', + field: HASH, + deleted: true, + }) + }) }) diff --git a/test/commands/file/delete.test.js b/test/commands/file/delete.test.js index 9023192..2ce9bbe 100644 --- a/test/commands/file/delete.test.js +++ b/test/commands/file/delete.test.js @@ -76,4 +76,20 @@ describe('file delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v1/files/5') + .reply(200, { success: true, data: { id: 5 } }) + + const stdout = await runCmd(FileDeleteCommand, [ + '5', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 5, deleted: true }) + }) }) diff --git a/test/commands/filter/delete.test.js b/test/commands/filter/delete.test.js index c96bc51..55c71bb 100644 --- a/test/commands/filter/delete.test.js +++ b/test/commands/filter/delete.test.js @@ -76,4 +76,20 @@ describe('filter delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v1/filters/5') + .reply(200, { success: true, data: { id: 5 } }) + + const stdout = await runCmd(FilterDeleteCommand, [ + '5', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 5, deleted: true }) + }) }) diff --git a/test/commands/lead/convert.test.js b/test/commands/lead/convert.test.js index 336f64e..8f1954b 100644 --- a/test/commands/lead/convert.test.js +++ b/test/commands/lead/convert.test.js @@ -105,9 +105,9 @@ describe('lead convert', () => { }) LeadConvertCommand.sleepFn = vi.fn().mockResolvedValue(undefined) - await expect(LeadConvertCommand.run([LEAD, '--wait'])).rejects.toThrow( - /failed/i, - ) + const err = await LeadConvertCommand.run([LEAD, '--wait']).catch((e) => e) + expect(err.message).toMatch(/failed/i) + expect(err.exitCode ?? err.oclif?.exit).toBe(65) }) it('--wait throws when the conversion is rejected', async () => { @@ -121,9 +121,34 @@ describe('lead convert', () => { }) LeadConvertCommand.sleepFn = vi.fn().mockResolvedValue(undefined) - await expect(LeadConvertCommand.run([LEAD, '--wait'])).rejects.toThrow( - /reject/i, - ) + const err = await LeadConvertCommand.run([LEAD, '--wait']).catch((e) => e) + expect(err.message).toMatch(/reject/i) + expect(err.exitCode ?? err.oclif?.exit).toBe(65) + }) + + it('--output json exposes conversion_id and the new deal_id (not prose-only)', async () => { + mockApi() + .post(`/api/v2/leads/${LEAD}/convert/deal`, {}) + .reply(200, { success: true, data: { conversion_id: CONVERSION } }) + .get(`/api/v2/leads/${LEAD}/convert/status/${CONVERSION}`) + .reply(200, { + success: true, + data: { conversion_id: CONVERSION, status: 'completed', deal_id: 33 }, + }) + + LeadConvertCommand.sleepFn = vi.fn().mockResolvedValue(undefined) + const stdout = await runCmd(LeadConvertCommand, [ + LEAD, + '--wait', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual({ + conversion_id: CONVERSION, + status: 'completed', + lead_id: LEAD, + deal_id: 33, + }) }) it('--wait times out after --timeout-secs without a terminal status', async () => { diff --git a/test/commands/lead/delete.test.js b/test/commands/lead/delete.test.js index faec461..ddc8894 100644 --- a/test/commands/lead/delete.test.js +++ b/test/commands/lead/delete.test.js @@ -75,4 +75,20 @@ describe('lead delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete(`/api/v1/leads/${ID}`) + .reply(200, { success: true, data: { id: ID } }) + + const stdout = await runCmd(LeadDeleteCommand, [ + ID, + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: ID, deleted: true }) + }) }) diff --git a/test/commands/note/comment/delete.test.js b/test/commands/note/comment/delete.test.js index 543a178..460d223 100644 --- a/test/commands/note/comment/delete.test.js +++ b/test/commands/note/comment/delete.test.js @@ -84,4 +84,26 @@ describe('note comment delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete(`/api/v1/notes/5/comments/${UUID}`) + .reply(200, { success: true, data: true }) + + const stdout = await runCmd(NoteCommentDeleteCommand, [ + '5', + '--comment', + UUID, + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ + note_id: 5, + comment_id: UUID, + deleted: true, + }) + }) }) diff --git a/test/commands/note/delete.test.js b/test/commands/note/delete.test.js index e57cd34..4b2f76d 100644 --- a/test/commands/note/delete.test.js +++ b/test/commands/note/delete.test.js @@ -73,4 +73,20 @@ describe('note delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v1/notes/5') + .reply(200, { success: true, data: { id: 5 } }) + + const stdout = await runCmd(NoteDeleteCommand, [ + '5', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 5, deleted: true }) + }) }) diff --git a/test/commands/org/delete.test.js b/test/commands/org/delete.test.js index 491222f..2752427 100644 --- a/test/commands/org/delete.test.js +++ b/test/commands/org/delete.test.js @@ -73,4 +73,20 @@ describe('org delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/organizations/7') + .reply(200, { success: true, data: { id: 7 } }) + + const stdout = await runCmd(OrgDeleteCommand, [ + '7', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 7, deleted: true }) + }) }) diff --git a/test/commands/org/follower/remove.test.js b/test/commands/org/follower/remove.test.js index 5a6fc69..5a6a834 100644 --- a/test/commands/org/follower/remove.test.js +++ b/test/commands/org/follower/remove.test.js @@ -88,4 +88,22 @@ describe('org follower remove', () => { OrgFollowerRemoveCommand.run(['--user', '5']), ).rejects.toThrow() }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/organizations/42/followers/5') + .reply(200, { success: true, data: { user_id: 5 } }) + + const stdout = await runCmd(OrgFollowerRemoveCommand, [ + '42', + '--user', + '5', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 42, user_id: 5, removed: true }) + }) }) diff --git a/test/commands/org/relationship/remove.test.js b/test/commands/org/relationship/remove.test.js index ae428ff..8800c80 100644 --- a/test/commands/org/relationship/remove.test.js +++ b/test/commands/org/relationship/remove.test.js @@ -82,4 +82,20 @@ describe('org relationship remove', () => { it('requires the relationship id positional', async () => { await expect(OrgRelationshipRemoveCommand.run([])).rejects.toThrow() }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v1/organizationRelationships/7') + .reply(200, { success: true, data: { id: 7 } }) + + const stdout = await runCmd(OrgRelationshipRemoveCommand, [ + '7', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 7, removed: true }) + }) }) diff --git a/test/commands/person/delete.test.js b/test/commands/person/delete.test.js index f9ca75d..445def6 100644 --- a/test/commands/person/delete.test.js +++ b/test/commands/person/delete.test.js @@ -73,4 +73,20 @@ describe('person delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/persons/42') + .reply(200, { success: true, data: { id: 42 } }) + + const stdout = await runCmd(PersonDeleteCommand, [ + '42', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 42, deleted: true }) + }) }) diff --git a/test/commands/person/follower/remove.test.js b/test/commands/person/follower/remove.test.js index f888d8c..6ba5f94 100644 --- a/test/commands/person/follower/remove.test.js +++ b/test/commands/person/follower/remove.test.js @@ -92,4 +92,22 @@ describe('person follower remove', () => { PersonFollowerRemoveCommand.run(['--user', '5']), ).rejects.toThrow() }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/persons/42/followers/5') + .reply(200, { success: true, data: { user_id: 5 } }) + + const stdout = await runCmd(PersonFollowerRemoveCommand, [ + '42', + '--user', + '5', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 42, user_id: 5, removed: true }) + }) }) diff --git a/test/commands/product/delete.test.js b/test/commands/product/delete.test.js index 64c918f..4556a90 100644 --- a/test/commands/product/delete.test.js +++ b/test/commands/product/delete.test.js @@ -73,4 +73,20 @@ describe('product delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/products/42') + .reply(200, { success: true, data: { id: 42 } }) + + const stdout = await runCmd(ProductDeleteCommand, [ + '42', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 42, deleted: true }) + }) }) diff --git a/test/commands/product/list.test.js b/test/commands/product/list.test.js index b852e9d..f5f6bed 100644 --- a/test/commands/product/list.test.js +++ b/test/commands/product/list.test.js @@ -88,14 +88,15 @@ describe('product list', () => { }) it('does not expose an --updated-until flag (unsupported by v2 products)', async () => { - await expect( - runCmd(ProductListCommand, [ - '--updated-until', - '2025-02-01T10:20:00Z', - '--output', - 'json', - ]), - ).rejects.toThrow(/Nonexistent flag/) + // With --output json the unknown-flag parse error surfaces as the JSON + // error envelope (exit 64), not a human message — assert the usage code. + const err = await runCmd(ProductListCommand, [ + '--updated-until', + '2025-02-01T10:20:00Z', + '--output', + 'json', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) }) it('rejects more than 100 ids with exit code 64', async () => { diff --git a/test/commands/project/delete.test.js b/test/commands/project/delete.test.js index ba8a8f9..40fad86 100644 --- a/test/commands/project/delete.test.js +++ b/test/commands/project/delete.test.js @@ -73,4 +73,20 @@ describe('project delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/projects/7') + .reply(200, { success: true, data: { id: 7 } }) + + const stdout = await runCmd(ProjectDeleteCommand, [ + '7', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 7, deleted: true }) + }) }) diff --git a/test/commands/search.test.js b/test/commands/search.test.js index 7b25055..d243546 100644 --- a/test/commands/search.test.js +++ b/test/commands/search.test.js @@ -226,6 +226,25 @@ describe('search scoped routing', () => { expect(JSON.parse(stdout)).toEqual([]) }) + it('clamps --limit above 100 to 100 on the scoped endpoint (live API 400s above 100)', async () => { + mockApi() + .get('/api/v2/deals/search') + .query({ term: 'acme', limit: '100' }) + .reply(200, { success: true, data: { items: [] } }) + + const stdout = await runCmd(SearchCommand, [ + 'acme', + '--item-types', + 'deal', + '--limit', + '500', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual([]) + }) + it('passes deal-only filters (status/person/org) to /api/v2/deals/search', async () => { mockApi() .get('/api/v2/deals/search') diff --git a/test/commands/task/delete.test.js b/test/commands/task/delete.test.js index edaddcb..9f30054 100644 --- a/test/commands/task/delete.test.js +++ b/test/commands/task/delete.test.js @@ -76,4 +76,20 @@ describe('task delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v2/tasks/7') + .reply(200, { success: true, data: { id: 7 } }) + + const stdout = await runCmd(TaskDeleteCommand, [ + '7', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 7, deleted: true }) + }) }) diff --git a/test/commands/webhook/delete.test.js b/test/commands/webhook/delete.test.js index caa3643..7932668 100644 --- a/test/commands/webhook/delete.test.js +++ b/test/commands/webhook/delete.test.js @@ -73,4 +73,20 @@ describe('webhook delete', () => { nock.enableNetConnect() } }) + + it('emits a JSON object with --output json', async () => { + mockConfirmAction.mockResolvedValue(true) + mockApi() + .delete('/api/v1/webhooks/3') + .reply(200, { success: true, data: { id: 3 } }) + + const stdout = await runCmd(WebhookDeleteCommand, [ + '3', + '--yes', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout)).toEqual({ id: 3, deleted: true }) + }) }) diff --git a/test/hooks/command-not-found.test.js b/test/hooks/command-not-found.test.js index 24bb359..46a524f 100644 --- a/test/hooks/command-not-found.test.js +++ b/test/hooks/command-not-found.test.js @@ -138,6 +138,57 @@ describe('command-not-found hook', () => { expect(exitCalls[0]).toBe(1) }) + it('maps a real oclif CLIError exit code (cmd.error) instead of collapsing to 1', async () => { + process.stdout.isTTY = false // oclif screen.js reads getWindowSize on a TTY + getAlias.mockReturnValue('deal list') + const { Errors } = await import('@oclif/core') + // What handleError throws in table mode: a CLIError carrying the code on + // .oclif.exit (NOT .exitCode) and the human message — which oclif's + // top-level handler would print but the hook bypasses. + const runCommand = vi + .fn() + .mockRejectedValue(new Errors.CLIError('not authenticated', { exit: 77 })) + const findCommand = vi.fn((id) => (id === 'deal:list' ? {} : null)) + + await expect( + hook({ id: 'wd', argv: [], config: { runCommand, findCommand } }), + ).rejects.toBeInstanceOf(ExitSignal) + + expect(exitCalls[0]).toBe(77) + const writes = stderrSpy.mock.calls.map((c) => c[0]).join('') + expect(writes).toContain('not authenticated') + }) + + it('maps a real oclif parse error (ExitError exit 2) to usage exit 64', async () => { + process.stdout.isTTY = false + getAlias.mockReturnValue('deal list') + const { Errors } = await import('@oclif/core') + const runCommand = vi.fn().mockRejectedValue(new Errors.ExitError(2)) + const findCommand = vi.fn((id) => (id === 'deal:list' ? {} : null)) + + await expect( + hook({ id: 'wd', argv: [], config: { runCommand, findCommand } }), + ).rejects.toBeInstanceOf(ExitSignal) + + expect(exitCalls[0]).toBe(64) + }) + + it('preserves a non-2 ExitError code (watch exit 8) without printing EEXIT', async () => { + process.stdout.isTTY = false + getAlias.mockReturnValue('watch') + const { Errors } = await import('@oclif/core') + const runCommand = vi.fn().mockRejectedValue(new Errors.ExitError(8)) + const findCommand = vi.fn(() => null) + + await expect( + hook({ id: 'w', argv: [], config: { runCommand, findCommand } }), + ).rejects.toBeInstanceOf(ExitSignal) + + expect(exitCalls[0]).toBe(8) + const writes = stderrSpy.mock.calls.map((c) => c[0]).join('') + expect(writes).not.toMatch(/EEXIT/) + }) + it('writes error to stderr and exits 127 when no alias matches', async () => { getAlias.mockReturnValue(undefined) const runCommand = vi.fn() diff --git a/test/lib/auth-oauth.test.js b/test/lib/auth-oauth.test.js index 5b5ba6f..2ab7936 100644 --- a/test/lib/auth-oauth.test.js +++ b/test/lib/auth-oauth.test.js @@ -110,18 +110,47 @@ describe('refreshAccessToken', () => { expect(scope.isDone()).toBe(true) }) - it('throws ApiError when the refresh is rejected', async () => { + it('maps a 400 invalid_grant refresh to exit 77 with re-auth guidance', async () => { + // An expired/revoked refresh token is the most common failure; it must be + // an auth problem (77, "run auth login"), not bad data (65), so an agent + // keyed to re-auth on 77 recovers. + nock(OAUTH_BASE).post('/oauth/token').reply(400, { error: 'invalid_grant' }) + + const err = await refreshAccessToken({ + refreshToken: 'rt', + clientId: 'cid', + clientSecret: 'csec', + }).catch((e) => e) + expect(err.exitCode).toBe(77) + expect(err.message).toMatch(/invalid_grant/) + expect(err.message).toMatch(/auth login/i) + }) + + it('rethrows a 5xx refresh failure as-is (service-unavailable 69, not auth 77)', async () => { + nock(OAUTH_BASE) + .post('/oauth/token') + .reply(503, { error: 'temporarily_unavailable' }) + + const err = await refreshAccessToken({ + refreshToken: 'rt', + clientId: 'cid', + clientSecret: 'csec', + }).catch((e) => e) + expect(err.exitCode).toBe(69) + }) + + it('maps a 401 invalid_client refresh to exit 77', async () => { nock(OAUTH_BASE) .post('/oauth/token') .reply(401, { error: 'invalid_client' }) - await expect( - refreshAccessToken({ - refreshToken: 'rt', - clientId: 'cid', - clientSecret: 'csec', - }), - ).rejects.toThrow(/invalid_client/) + const err = await refreshAccessToken({ + refreshToken: 'rt', + clientId: 'cid', + clientSecret: 'csec', + }).catch((e) => e) + expect(err.exitCode).toBe(77) + expect(err.message).toMatch(/invalid_client/) }) }) diff --git a/test/lib/body.test.js b/test/lib/body.test.js index 6dd63ca..3fc3df2 100644 --- a/test/lib/body.test.js +++ b/test/lib/body.test.js @@ -21,10 +21,12 @@ describe('resolveBody', () => { expect(result).toBe('file contents here') }) - it('throws CliError when no body and stdin is TTY', async () => { + it('throws a usage error (exit 64) when no body and stdin is TTY', async () => { const origIsTTY = process.stdin.isTTY process.stdin.isTTY = true - await expect(resolveBody({})).rejects.toThrow('--body is required') + const err = await resolveBody({}).catch((e) => e) + expect(err.message).toMatch(/--body is required/) + expect(err.exitCode).toBe(64) process.stdin.isTTY = origIsTTY }) diff --git a/test/lib/bulk.test.js b/test/lib/bulk.test.js index cbe98b7..cc04c55 100644 --- a/test/lib/bulk.test.js +++ b/test/lib/bulk.test.js @@ -75,6 +75,42 @@ describe('resolveTargets', () => { resolveTargets({ ids: '1,abc' }, client(), '/api/v2/deals'), ).rejects.toMatchObject({ exitCode: 64 }) }) + + it('ignores a trailing comma in --ids (no phantom id 0)', async () => { + const ids = await resolveTargets({ ids: '1,2,' }, client(), '/api/v2/deals') + expect(ids).toEqual([1, 2]) + }) + + it('ignores blank interior lines from piped stdin (no phantom id 0)', async () => { + const stdin = Readable.from([Buffer.from('4\n\n5\n')]) + stdin.isTTY = false + const ids = await resolveTargets({ stdin }, client(), '/api/v2/deals') + expect(ids).toEqual([4, 5]) + }) + + it('throws 64 when piped stdin is empty (no targets)', async () => { + const stdin = Readable.from([Buffer.from('')]) + stdin.isTTY = false + await expect( + resolveTargets({ stdin }, client(), '/api/v2/deals'), + ).rejects.toMatchObject({ exitCode: 64 }) + }) + + it('throws 65 when piped stdin is malformed JSON', async () => { + const stdin = Readable.from([Buffer.from('[1, 2,')]) + stdin.isTTY = false + await expect( + resolveTargets({ stdin }, client(), '/api/v2/deals'), + ).rejects.toMatchObject({ exitCode: 65 }) + }) + + it('throws 65 when a JSON entry lacks an integer id', async () => { + const stdin = Readable.from([Buffer.from('[{"name": "no id here"}]')]) + stdin.isTTY = false + await expect( + resolveTargets({ stdin }, client(), '/api/v2/deals'), + ).rejects.toMatchObject({ exitCode: 65 }) + }) }) describe('bulkRun', () => { diff --git a/test/lib/changelog.test.js b/test/lib/changelog.test.js index bb57225..ff09c7f 100644 --- a/test/lib/changelog.test.js +++ b/test/lib/changelog.test.js @@ -63,6 +63,32 @@ describe('fetchChangelog', () => { expect(client.calls[0].query).toMatchObject({ limit: 500 }) }) + + it('stops collecting once the limit is reached instead of walking every page', async () => { + // Three full pages are available, but a limit of 2 must bound the work: + // collection should stop after the cap, leaving later pages unyielded. + let yielded = 0 + const client = { + calls: [], + async *pageV2(path, query = {}) { + this.calls.push({ path, query }) + for (let page = 0; page < 3; page++) { + for (const row of [ + { field_key: 'stage_id', old_value: String(page), new_value: 'x' }, + { field_key: 'status', old_value: String(page), new_value: 'y' }, + ]) { + yielded++ + yield row + } + } + }, + } + + const rows = await fetchChangelog(client, 7, { limit: 2 }) + + expect(rows).toHaveLength(2) + expect(yielded).toBe(2) + }) }) describe('mineMany', () => { diff --git a/test/lib/client.test.js b/test/lib/client.test.js index 8cb3dbe..9beeadb 100644 --- a/test/lib/client.test.js +++ b/test/lib/client.test.js @@ -261,7 +261,7 @@ describe('createClient', () => { expect(scope.isDone()).toBe(true) }, 30_000) - it('throws ServiceUnavailableError when 429 retries exhaust the loop', async () => { + it('throws RateLimitError (75) when 429 retries exhaust the loop', async () => { const retryClient = createClient({ companyDomain: 'acme', token: 'test-token', @@ -278,11 +278,86 @@ describe('createClient', () => { await retryClient.get('/api/v2/throttled') expect.unreachable('should have thrown') } catch (err) { - expect(err.message).toBe('Pipedrive API is unavailable') - expect(err.exitCode).toBe(69) + // The API answered (it throttled us) — that is rate-limited (75), + // not service-unavailable (69). A sleep-and-retry script keys on 75. + expect(err).toBeInstanceOf(RateLimitError) + expect(err.exitCode).toBe(75) + expect(err.retryAfter).toBe(0) } expect(scope.isDone()).toBe(true) }) + + it('throws ServiceUnavailableError (69) when 5xx retries exhaust (not 429)', async () => { + const retryClient = createClient({ + companyDomain: 'acme', + token: 'test-token', + retry: true, + timeout: 5000, + }) + + // A 5xx final attempt already throws ApiError(69) inline; this guards the + // loop fall-through so it never misclassifies as rate-limited. + const scope = nock(API_BASE) + .get('/api/v2/down') + .times(3) + .reply(503, { success: false, error: 'Service Unavailable' }) + + const err = await retryClient.get('/api/v2/down').catch((e) => e) + expect(err.exitCode).toBe(69) + expect(scope.isDone()).toBe(true) + }, 30_000) + }) + + describe('network / transport failures', () => { + it('maps a connection failure to ServiceUnavailableError (69), not 70', async () => { + nock(API_BASE) + .get('/api/v2/unreachable') + .replyWithError( + Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + }), + ) + + const err = await client.get('/api/v2/unreachable').catch((e) => e) + // No exitCode/oclif on a raw fetch reject would default to 70 (internal + // bug). Network unreachability is 69 — the documented contract. + expect(err.exitCode).toBe(69) + expect(err.message).toMatch(/acme\.pipedrive\.com/) + }) + + it('retries a transient connection failure, then succeeds', async () => { + const retryClient = createClient({ + companyDomain: 'acme', + token: 'test-token', + retry: true, + timeout: 5000, + }) + + const scope = nock(API_BASE) + .get('/api/v2/blip') + .replyWithError( + Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }), + ) + .get('/api/v2/blip') + .reply(200, { success: true, data: { ok: true } }) + + const result = await retryClient.get('/api/v2/blip') + expect(result.data).toEqual({ ok: true }) + expect(scope.isDone()).toBe(true) + }, 30_000) + + it('refuses to follow a cross-host redirect (never re-sends the token)', async () => { + // Default fetch would follow a 30x and re-send x-api-token to the new + // host. We must refuse rather than leak the token off the locked domain. + const scope = nock(API_BASE) + .get('/api/v2/redirect') + .reply(302, '', { Location: 'https://evil.example.com/steal' }) + + const err = await client.get('/api/v2/redirect').catch((e) => e) + expect(err.exitCode).toBe(78) + expect(err.message).toMatch(/redirect/i) + expect(scope.isDone()).toBe(true) + }) }) describe('pageV2 (cursor pagination)', () => { diff --git a/test/lib/config.test.js b/test/lib/config.test.js index 4a2af3d..975c62f 100644 --- a/test/lib/config.test.js +++ b/test/lib/config.test.js @@ -1,5 +1,14 @@ -import { describe, it, expect, afterEach } from 'vitest' -import { +import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Isolate the conf store to a throwaway dir BEFORE importing config.js, so +// these tests never read or mutate the developer's real pdcli profiles. +const TMP_CONFIG_DIR = mkdtempSync(join(tmpdir(), 'pdcli-config-test-')) +process.env.PDCLI_CONFIG_DIR = TMP_CONFIG_DIR + +const { getConf, getActiveProfile, setActiveProfile, @@ -9,9 +18,20 @@ import { getAllProfiles, getProfileData, deleteProfileConfig, -} from '../../src/lib/config.js' +} = await import('../../src/lib/config.js') describe('config', () => { + beforeAll(() => { + // Confirm the isolation actually took effect — guard against a regression + // that would point the tests back at the real config store. + expect(getConf().path).toContain(TMP_CONFIG_DIR) + }) + + afterAll(() => { + delete process.env.PDCLI_CONFIG_DIR + rmSync(TMP_CONFIG_DIR, { recursive: true, force: true }) + }) + afterEach(() => { // Clean up any test profiles we created const conf = getConf() diff --git a/test/lib/csv-parse.test.js b/test/lib/csv-parse.test.js index a028133..4d1b4a5 100644 --- a/test/lib/csv-parse.test.js +++ b/test/lib/csv-parse.test.js @@ -55,6 +55,12 @@ describe('parseCsv', () => { it('throws 65 on an unterminated quote', () => { expect(() => parseCsv('a\n"oops\n')).toThrow(/unterminated/i) }) + + it('strips a leading UTF-8 BOM so the first header is clean', () => { + const { headers, rows } = parseCsv('name,email\nJane,j@a.com\n') + expect(headers).toEqual(['name', 'email']) + expect(rows).toEqual([['Jane', 'j@a.com']]) + }) }) describe('parseCsv without trailing newline', () => { diff --git a/test/lib/import.test.js b/test/lib/import.test.js index 104ef2d..9c4a73b 100644 --- a/test/lib/import.test.js +++ b/test/lib/import.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { prepareImportBodies } from '../../src/lib/import.js' +import { prepareImportBodies, intCell } from '../../src/lib/import.js' const HASH = 'dcf558aac1ae4e8c4f849ba5e668430d8df9be12' @@ -29,6 +29,48 @@ const PERSON_SPECIALS = { }, } +describe('intCell', () => { + it('parses an integer id cell', () => { + expect(intCell('42', 'org_id')).toBe(42) + }) + + it.each(['N/A', '12a', '1.5', 'abc'])( + 'throws 65 on a non-integer cell %j', + (value) => { + let caught + try { + intCell(value, 'org_id') + } catch (e) { + caught = e + } + expect(caught.exitCode).toBe(65) + expect(caught.message).toMatch(/org_id/) + }, + ) +}) + +describe('prepareImportBodies special-column validation', () => { + it('reports the CSV row number when a special column rejects a value', () => { + const specials = { + ...PERSON_SPECIALS, + org_id: (typed, value) => { + typed.org_id = intCell(value, 'org_id') + }, + } + expect(() => + prepareImportBodies({ + headers: ['name', 'org_id'], + rows: [ + ['Ok', '5'], + ['Bad', 'N/A'], + ], + specialColumns: specials, + defs: [], + }), + ).toThrow(/row 3/i) + }) +}) + describe('prepareImportBodies duplicate headers', () => { it('rejects a duplicate column header with exit 65 instead of losing data', () => { expect(() => diff --git a/test/lib/input.test.js b/test/lib/input.test.js index c81cc27..b4bfffe 100644 --- a/test/lib/input.test.js +++ b/test/lib/input.test.js @@ -109,6 +109,28 @@ describe('buildWriteBody', () => { expect(body.custom_fields).toBeUndefined() }) + it('throws 65 for a non-numeric value on a numeric field', () => { + expect(() => + buildWriteBody({ fields: ['Score=not-a-number'], defs: DEFS }), + ).toThrow(/number/i) + try { + buildWriteBody({ fields: ['Score=not-a-number'], defs: DEFS }) + } catch (err) { + expect(err.exitCode).toBe(65) + } + }) + + it('names a numeric field by its hash code when it has no field_name', () => { + const defs = [{ field_code: HASH2, field_type: 'double' }] // no field_name + try { + buildWriteBody({ fields: [`${HASH2}=nope`], defs }) + throw new Error('should have thrown') + } catch (err) { + expect(err.exitCode).toBe(65) + expect(err.message).toContain(HASH2) + } + }) + it('throws 65 for an unknown field name with a hint', () => { expect(() => buildWriteBody({ fields: ['Nope=1'], defs: DEFS })).toThrow( /field list/, diff --git a/test/lib/lookup.test.js b/test/lib/lookup.test.js index 0d9e175..c3f3545 100644 --- a/test/lib/lookup.test.js +++ b/test/lib/lookup.test.js @@ -362,6 +362,22 @@ describe('lookupByField', () => { expect(err.message).toMatch(/not searchable/i) }) + it.each(['monetary', 'address'])( + 'rejects matching on a %s custom field (object shape never matches) with exit 64', + async (field_type) => { + const defs = [{ field_name: 'Budget', field_code: 'bd', field_type }] + const err = await lookupByField({ + client: fakeClient({}), + entity: 'deal', + defs, + field: 'Budget', + value: '500', + }).catch((e) => e) + expect(err.exitCode).toBe(64) + expect(err.message).toMatch(/not searchable/i) + }, + ) + it('rejects an unknown field with exit 64', async () => { const err = await lookupByField({ client: fakeClient({}), diff --git a/test/lib/output-csv.test.js b/test/lib/output-csv.test.js index b5e0be8..89c0c16 100644 --- a/test/lib/output-csv.test.js +++ b/test/lib/output-csv.test.js @@ -56,4 +56,25 @@ describe('formatCsv', () => { const result = formatCsv([{ emailAddress: 'x@y.com' }], cols) expect(result).toBe('Mail\nx@y.com') }) + + it('derives columns from object keys when none are given (no silent blank output)', () => { + const result = formatCsv( + [ + { id: 1, title: 'x' }, + { id: 2, title: 'y' }, + ], + {}, + ) + expect(result).toBe('id,title\n1,x\n2,y') + }) + + it('unions keys across rows when deriving columns', () => { + const result = formatCsv([{ id: 1 }, { id: 2, extra: 'z' }], {}) + expect(result).toBe('id,extra\n1,\n2,z') + }) + + it('JSON-encodes nested object/array values in derived columns', () => { + const result = formatCsv([{ id: 1, custom_fields: { k: 5 } }], {}) + expect(result).toBe('id,custom_fields\n1,"{""k"":5}"') + }) }) diff --git a/test/lib/upsert.test.js b/test/lib/upsert.test.js index f2402c8..cbf287f 100644 --- a/test/lib/upsert.test.js +++ b/test/lib/upsert.test.js @@ -203,6 +203,127 @@ describe('runUpsert', () => { expect(err.message).toMatch(/7.*8|8.*7/) }) + it("never narrows the matched email set on update — keeps the record's other emails", async () => { + // CRITICAL: matching by email and re-asserting only the match email must + // not PATCH emails:[a] over the record's [a,b] and silently delete b. + const client = fakeClient({ + items: [ + { + id: 7, + emails: [{ value: 'a@x.com', primary: true }, { value: 'b@x.com' }], + }, + ], + }) + const r = await runUpsert({ + client, + entity: 'person', + by: 'email', + value: 'a@x.com', + body: { emails: [{ value: 'a@x.com', primary: true }] }, + }) + expect(r).toMatchObject({ action: 'unchanged', id: 7 }) + expect(client.calls.patch).toHaveLength(0) + }) + + it('patches other fields but excludes the match field from the update body', async () => { + const client = fakeClient({ + items: [ + { + id: 7, + emails: [{ value: 'a@x.com' }, { value: 'b@x.com' }], + owner_id: 1, + }, + ], + }) + const r = await runUpsert({ + client, + entity: 'person', + by: 'email', + value: 'a@x.com', + body: { emails: [{ value: 'a@x.com', primary: true }], owner_id: 42 }, + }) + expect(r).toMatchObject({ action: 'updated', id: 7 }) + expect(client.calls.patch[0].body).toEqual({ owner_id: 42 }) + }) + + it('writes a multi-value email body in full when matching by email (no over-strip)', async () => { + // A raw body that supplies more than the single match value is an explicit + // full set — it must be written, not silently dropped by the strip. + const client = fakeClient({ + items: [{ id: 7, emails: [{ value: 'a@x.com' }] }], + }) + const r = await runUpsert({ + client, + entity: 'person', + by: 'email', + value: 'a@x.com', + body: { + emails: [{ value: 'a@x.com', primary: true }, { value: 'b@x.com' }], + }, + }) + expect(r).toMatchObject({ action: 'updated', id: 7 }) + expect(client.calls.patch[0].body).toEqual({ + emails: [{ value: 'a@x.com', primary: true }, { value: 'b@x.com' }], + }) + }) + + it('does not strip a single email entry that is not the match value', async () => { + // A malformed/value-less single entry isn't the injected match value, so it + // is left for diffBody rather than stripped as identity. + const client = fakeClient({ + items: [{ id: 7, emails: [{ value: 'a@x.com' }] }], + }) + const r = await runUpsert({ + client, + entity: 'person', + by: 'email', + value: 'a@x.com', + body: { emails: [null] }, + }) + expect(r).toMatchObject({ action: 'updated', id: 7 }) + expect(client.calls.patch[0].body).toHaveProperty('emails') + }) + + it('excludes a custom match field from the update body', async () => { + const defs = [ + { field_name: 'External ID', field_code: 'ext', field_type: 'varchar' }, + ] + const client = fakeClient({ + items: [{ id: 7, custom_fields: { ext: 'D-42', other: 'old' } }], + }) + const r = await runUpsert({ + client, + entity: 'deal', + by: 'External ID', + value: 'D-42', + body: { custom_fields: { ext: 'D-42', other: 'new' } }, + defs, + }) + expect(r).toMatchObject({ action: 'updated', id: 7 }) + expect(client.calls.patch[0].body).toEqual({ + custom_fields: { other: 'new' }, + }) + }) + + it('strips a custom match field even when the update body has no custom_fields', async () => { + const defs = [ + { field_name: 'External ID', field_code: 'ext', field_type: 'varchar' }, + ] + const client = fakeClient({ + items: [{ id: 7, value: 1, custom_fields: { ext: 'D-42' } }], + }) + const r = await runUpsert({ + client, + entity: 'deal', + by: 'External ID', + value: 'D-42', + body: { value: 2 }, // only a top-level change, no custom_fields + defs, + }) + expect(r).toMatchObject({ action: 'updated', id: 7 }) + expect(client.calls.patch[0].body).toEqual({ value: 2 }) + }) + it('dry-run create writes nothing', async () => { const client = fakeClient({ items: [] }) const r = await runUpsert({ diff --git a/website/src/content/docs/automation/ci.mdx b/website/src/content/docs/automation/ci.mdx index b2d84b4..6b7d17c 100644 --- a/website/src/content/docs/automation/ci.mdx +++ b/website/src/content/docs/automation/ci.mdx @@ -112,9 +112,9 @@ Pass the previous run's timestamp on each schedule tick and you sync deltas inst full set. (`product list` supports `--updated-since` only — products have no `--updated-until`.) -If the **daily** request budget runs out, pdcli fails fast: a `429` carrying +If the **daily** token budget runs out, pdcli fails fast: a `429` carrying `x-daily-ratelimit-token-remaining: 0` is not retried (backoff would stall until the daily reset at midnight server time), -so the command exits `75` immediately with a clear `Daily API request budget exhausted` +so the command exits `75` immediately with a clear `Daily API token budget exhausted` message. Add `--verbose` to log the remaining daily budget after each request, so a job can warn before it hits the wall. diff --git a/website/src/content/docs/automation/cookbook.mdx b/website/src/content/docs/automation/cookbook.mdx index 9d5e950..8bc5185 100644 --- a/website/src/content/docs/automation/cookbook.mdx +++ b/website/src/content/docs/automation/cookbook.mdx @@ -49,10 +49,12 @@ Add `--exact` for an exact match. `search` routes by how many item types you ask for: - **A single routable type** — `--item-types deal`, `person`, `organization`, or `product` — - hits that entity's dedicated v2 search endpoint (e.g. `/api/v2/persons/search`). That's a - narrower OAuth scope and cheaper: **20 rate-limit tokens vs. 40**. + hits that entity's dedicated v2 search endpoint (e.g. `/api/v2/persons/search`), which + needs only that entity's OAuth scope and accepts entity-specific server-side filters. Its + `--limit` is capped at 100 (the endpoint rejects more). - **Anything else** — no `--item-types`, multiple types, or a non-routable type like `lead`, - `file`, or `project` — stays on the generic `itemSearch` (40 tokens). + `file`, or `project` — stays on the generic `itemSearch`. Both search endpoints cost the + same 20 rate-limit tokens. When the scope is a single **deal** search, three filters narrow it server-side: `--status` (`open`/`won`/`lost`), `--person` (a person ID), and `--org` (an organization ID). These are diff --git a/website/src/content/docs/automation/exit-codes.mdx b/website/src/content/docs/automation/exit-codes.mdx index a5b09bc..4c79ec6 100644 --- a/website/src/content/docs/automation/exit-codes.mdx +++ b/website/src/content/docs/automation/exit-codes.mdx @@ -12,27 +12,29 @@ command shares the same ladder. | Code | Meaning | Pipedrive trigger | What a script should do | | ---- | ------- | ----------------- | ----------------------- | | `0` | Success | 2xx | Continue. | -| `1` | Generic error | — | Inspect the message; safe to surface and stop. `audit --strict` returns 1 on must-severity findings. | -| `64` | Invalid input value | — | A value pdcli validated and rejected (e.g. an invalid `--period`, an unknown `--checks` name, several pipelines with no `--pipeline`, a CSV missing its name column). Fix the invocation — retrying won't help. Note: an unknown flag or missing argument is caught by the parser and exits `70`. | -| `65` | Data / validation | `400`, `422` | The request body was rejected. Fix the payload (bad field value, missing required field). Don't retry unchanged. | -| `69` | Service unavailable | `5xx` | Pipedrive is down or erroring. pdcli already retried 5xx with backoff; safe to retry later. | -| `70` | Internal CLI bug | — | An unexpected error inside pdcli. File an issue; retrying won't help. | -| `75` | Rate limited | `429` | Token budget exhausted. With retries on, pdcli only surfaces this under `--no-retry`. Back off and retry after the reset window. | -| `77` | Auth / permission | `401`, `403` | Token is invalid, expired, or lacks scope. Re-authenticate (`pdcli auth login`). Don't loop. A `403` after repeated `429`s is a rate-limit hard stop — wait for the reset, don't retry. | -| `78` | Config / account | `402`, missing domain/token, host-lock violation, no keychain | The CLI or account is misconfigured: no company domain, no keychain to write to, a `pdcli api` URL outside your host, or a `402` (plan lacks the feature). Fix config or the account — don't retry. | +| `1` | Generic error | — | Inspect the message; safe to surface and stop. `audit --strict` returns 1 on must-severity findings; a partial bulk/import failure returns 1 when some rows failed for non-data reasons. | +| `64` | Usage / bad input | — | A bad invocation: an unknown flag or a missing/invalid argument (caught by the parser), or a value pdcli validated and rejected (invalid `--period`, unknown `--checks` name, several pipelines with no `--pipeline`, a CSV missing its name column, `--ids` over 100). Fix the invocation — retrying won't help. | +| `65` | Data / validation | `400`, `422` | The request body or input data was rejected: a bad field value, a non-numeric id cell in a CSV, an ambiguous upsert match, malformed `--body` JSON, or a conversion the API rejected. Fix the payload; don't retry unchanged. | +| `69` | Service unavailable | `5xx`, network | Pipedrive is down/erroring, **or the host is unreachable** (DNS failure, connection refused, or a `--timeout`). pdcli already retried with backoff; safe to retry later. | +| `70` | Internal CLI bug | — | An unexpected error inside pdcli — and nothing else (network, usage, and API failures all map elsewhere). File an issue; retrying won't help. | +| `75` | Rate limited | `429` | Token budget exhausted. pdcli retries 429s with backoff; if they never clear it surfaces `75` (not `69`), and `--no-retry` surfaces the first one. Back off and retry after the reset window. | +| `77` | Auth / permission | `401`, `403` | Token is invalid, expired, or lacks scope — including a **failed OAuth refresh** (`invalid_grant`). Re-authenticate (`pdcli auth login`). Don't loop. A `403` after repeated `429`s is a rate-limit hard stop — wait for the reset, don't retry. | +| `78` | Config / account | `402`, missing domain/token, host-lock violation, no keychain | The CLI or account is misconfigured: no company domain, no keychain to write to, a `pdcli api` URL outside your host, a redirect off your host, or a `402` (plan lacks the feature). Fix config or the account — don't retry. | +| `8` | Findings present (`watch` only) | — | `pdcli watch` exits `8` when it surfaces new anomalies, so `pdcli watch \|\| notify` fires only on findings. Not part of the general ladder — specific to `watch`. | -The exact mapping lives in `src/lib/errors.js` (`exitCodeForStatus`): `400/422 → 65`, -`401/403 → 77`, `402 → 78`, `429 → 75`, `5xx → 69`. Anything without an HTTP status falls -back to `1`, and an unexpected throw inside the CLI exits `70`. +The HTTP-status mapping lives in `src/lib/errors.js` (`exitCodeForStatus`): `400/422 → 65`, +`401/403 → 77`, `402 → 78`, `429 → 75`, `5xx → 69`. On top of that: an oclif parse error +(unknown flag, missing/invalid argument) exits `64`; an unreachable host or timeout exits +`69`; and only a genuinely unexpected throw exits `70`. ## Error output -In **table mode** the message goes to stderr in human form; `--verbose` adds the request -path, status code, and `error_info` from Pipedrive's error envelope. - -In **JSON mode** (an explicit `--output json`, or `default_output json` set in the -profile) the error is written to **stderr** as a single JSON object — stdout stays -clean so a successful-looking pipe can't swallow a failure: +The error format **mirrors the success output format** (the same `--output` / +`default_output` / TTY rule). Whenever output is **not** an interactive table — an explicit +`--output json|yaml|csv`, a non-table `default_output` in the profile, **or stdout piped** — +the error is written to **stderr** as a single JSON object, so a machine consumer that gets +JSON on success always gets a parseable failure. stdout stays clean so a successful-looking +pipe can't swallow a failure: ```json { @@ -47,7 +49,8 @@ clean so a successful-looking pipe can't swallow a failure: `error` is the error class name. `statusCode`, `path`, and `errorInfo` appear only for API errors (they're omitted for usage/config errors). Under `--verbose` the full Pipedrive -`body` is added too. +`body` is added too. Only an **interactive table** context prints the message to stderr in +human form; `--verbose` there adds the request path, status code, and `error_info`. ## Branching on the code @@ -55,6 +58,7 @@ errors (they're omitted for usage/config errors). Under `--verbose` the full Pip pdcli deal get 42 --output json > deal.json case $? in 0) echo "ok" ;; + 64) echo "bad invocation — fix flags/args" ; exit 1 ;; 65) echo "bad request — not retrying" ; exit 1 ;; 75|69) echo "transient — retry later" ; exit 1 ;; 77) echo "re-auth needed" ; pdcli auth status ; exit 1 ;; @@ -63,8 +67,8 @@ case $? in esac ``` -A few non-zero codes you'll hit that aren't in the table above: `pdcli` prints `127` (via the -command-not-found hook) when you invoke a command name that doesn't exist. +`pdcli` also prints `127` (via the command-not-found hook) when you invoke a command name +that doesn't exist. See also: [Troubleshooting](/pdcli/reference/troubleshooting/) for what to do per failure, and [How pdcli talks to Pipedrive](/pdcli/concepts/api-model/) for the retry/backoff rules. diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index d6b4684..e8bad58 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -5,7 +5,7 @@ description: Every pdcli command, flag, and example — generated from the CLI m {/* AUTO-GENERATED from the oclif manifest by scripts/gen-commands.mjs — do not edit by hand. */} -All 145 commands in `pdcli` v0.17.0. Every command also +All 145 commands in `pdcli` v0.18.0. Every command also accepts the [global flags](/pdcli/reference/config/) `--output`, `--jq`, `--fields`, `--profile`, `--limit`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. Run `pdcli --help` for the live version. diff --git a/website/src/data/cli-stats.json b/website/src/data/cli-stats.json index e9f6253..60faa12 100644 --- a/website/src/data/cli-stats.json +++ b/website/src/data/cli-stats.json @@ -1,5 +1,5 @@ { - "version": "0.17.0", + "version": "0.18.0", "commands": 145, "topics": 26, "formats": 4