Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <one> --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
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Full command reference for the pdcli command-line interface.

<!-- AUTO-GENERATED from the oclif manifest by scripts/gen-commands.mjs — do not edit by hand. -->

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

Expand Down
16 changes: 3 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 33 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wavyx/pdcli",
"version": "0.17.0",
"version": "0.18.0",
"publishConfig": {
"access": "public"
},
Expand Down Expand Up @@ -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"
},
Expand Down Expand Up @@ -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",
Expand All @@ -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 .",
Expand Down
34 changes: 32 additions & 2 deletions src/base-command.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion src/commands/activity/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`),
)
}
}
15 changes: 13 additions & 2 deletions src/commands/api.js
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion src/commands/backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}` +
Expand Down
41 changes: 40 additions & 1 deletion src/commands/changes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions src/commands/deal/bulk-update.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading
Loading