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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ All notable changes to `pdcli` are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versions follow
[SemVer](https://semver.org/).

## [0.16.0] - 2026-06-11

### Added

- Idempotent writes — match-or-create that never guesses:
- `person upsert`, `org upsert`, `deal upsert` — match a record by `--by`
(a built-in key — person email/name/phone, org name, deal title — or a
searchable custom field), then **create** it if absent or **PATCH only the
changed fields** if exactly one matches. More than one match **refuses with
exit 65** rather than writing the wrong record: search `exact_match` is not
a unique key, so every candidate is re-verified client-side before the
count decides. `--dry-run` previews the action; table prints a one-line
summary, `--output json` emits the full action result.
- `person import --upsert --match-on <field>` and the same on `org import` —
the CSV equivalent: each row is matched on its `--match-on` value, then
created or PATCHed. Per-row failures (ambiguous matches, empty match
values) are collected without aborting the batch and reported as
`created / updated / unchanged` counts; a batch whose failures are all
data-validation errors exits 65, otherwise 1. `--dry-run` looks up and
reports the counts without writing.

### Changed

- `diffBody` (upsert) compares emails/phones by their value set
(case-insensitive, ignoring the `primary`/`label` flags the API echoes) and
treats `label_ids` and multi-option custom fields order-insensitively, so
re-running an unchanged upsert issues no PATCH.
- CSV import now rejects duplicate column headers (exit 65) instead of silently
keeping only the last cell.

## [0.15.0] - 2026-06-11

### Added
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ pdcli person import people.csv --dry-run # CSV headers map to fiel
pdcli person import people.csv # custom fields by name
```

## Idempotent writes

Match-or-create, safe to re-run. More than one match **refuses** (exit 65) —
never guesses which record to write.

```bash
pdcli person upsert a@x.com --by email --field "Tier=Gold" # create or PATCH only what changed
pdcli deal upsert "Acme expansion" --by title --body '{"value":5000}'
pdcli org upsert "D-42" --by "External ID" --field "Status=Active" --dry-run # preview
pdcli person import contacts.csv --upsert --match-on email # CSV: per-row create-or-update
```

## Analytics & housekeeping

```bash
Expand Down
69 changes: 68 additions & 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.15.0 (142 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.16.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 Expand Up @@ -958,6 +958,27 @@ pdcli deal update 42 --status won
pdcli deal update 42 --field "Deal Size=Large"
```

### `pdcli deal upsert`

Idempotent deal upsert: match by --by, then create or PATCH only the changed fields. Refuses (exit 65) if more than one record matches.

```
pdcli deal upsert <value> [flags]
```

- `--by <value>` _(required)_ — Match field: title, or a searchable custom field
- `--field <value>` — Field to set as "Name=Value" (repeatable)
- `--body <value>` — Raw JSON body to merge
- `--dry-run` — Preview the action without writing

Examples:

```bash
pdcli deal upsert "Acme expansion" --by title --field "Stage=Won"
pdcli deal upsert "D-42" --by "External ID" --body '{"value":5000}'
pdcli deal upsert "Acme expansion" --by title --field "Stage=Won" --dry-run
```

## pdcli field

### `pdcli field create`
Expand Down Expand Up @@ -1798,6 +1819,8 @@ Bulk-create organizations from a CSV (headers map to fields, custom fields by na
pdcli org import <file> [flags]
```

- `--upsert` — Match each row on --match-on, then create or update
- `--match-on <value>` — Field to match rows on in --upsert mode (e.g. name)
- `--dry-run` — Validate every row without creating anything
- `-y, --yes` — Skip the confirmation prompt

Expand Down Expand Up @@ -1923,6 +1946,27 @@ pdcli org update 7 --owner 9
pdcli org update 7 --field "Tier=Gold"
```

### `pdcli org upsert`

Idempotent organization upsert: match by --by, then create or PATCH only the changed fields. Refuses (exit 65) if more than one record matches.

```
pdcli org upsert <value> [flags]
```

- `--by <value>` _(required)_ — Match field: name, or a searchable custom field
- `--field <value>` — Field to set as "Name=Value" (repeatable)
- `--body <value>` — Raw JSON body to merge
- `--dry-run` — Preview the action without writing

Examples:

```bash
pdcli org upsert Acme --by name --field "Tier=Gold"
pdcli org upsert "D-42" --by "External ID" --body '{"owner_id":42}'
pdcli org upsert Acme --by name --field "Tier=Gold" --dry-run
```

## pdcli person

### `pdcli person create`
Expand Down Expand Up @@ -2039,6 +2083,8 @@ Bulk-create persons from a CSV (headers map to fields, custom fields by name)
pdcli person import <file> [flags]
```

- `--upsert` — Match each row on --match-on, then create or update
- `--match-on <value>` — Field to match rows on in --upsert mode (e.g. email)
- `--dry-run` — Validate every row without creating anything
- `-y, --yes` — Skip the confirmation prompt

Expand Down Expand Up @@ -2115,6 +2161,27 @@ pdcli person update 42 --email new@acme.com
pdcli person update 42 --field "Segment=Enterprise"
```

### `pdcli person upsert`

Idempotent person upsert: match by --by, then create or PATCH only the changed fields. Refuses (exit 65) if more than one record matches.

```
pdcli person upsert <value> [flags]
```

- `--by <value>` _(required)_ — Match field: email, name, phone, or a searchable custom field
- `--field <value>` — Field to set as "Name=Value" (repeatable)
- `--body <value>` — Raw JSON body to merge
- `--dry-run` — Preview the action without writing

Examples:

```bash
pdcli person upsert a@x.com --by email --field "Tier=Gold"
pdcli person upsert "Jane Doe" --by name --body '{"owner_id":42}'
pdcli person upsert a@x.com --by email --field "Tier=Gold" --dry-run
```

## pdcli pipeline

### `pdcli pipeline get`
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wavyx/pdcli",
"version": "0.15.0",
"version": "0.16.0",
"publishConfig": {
"access": "public"
},
Expand Down
54 changes: 54 additions & 0 deletions src/commands/deal/upsert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Args, Flags } from '@oclif/core'
import BaseCommand from '../../base-command.js'
import { upsertWithDefs, summarizeUpsert } from '../../lib/upsert.js'

export default class DealUpsertCommand extends BaseCommand {
static description =
'Idempotent deal upsert: match by --by, then create or PATCH only the ' +
'changed fields. Refuses (exit 65) if more than one record matches.'

static examples = [
'<%= config.bin %> deal upsert "Acme expansion" --by title --field "Stage=Won"',
'<%= config.bin %> deal upsert "D-42" --by "External ID" --body \'{"value":5000}\'',
'<%= config.bin %> deal upsert "Acme expansion" --by title --field "Stage=Won" --dry-run',
]

static args = {
value: Args.string({ required: true, description: 'value to match on' }),
}

static flags = {
...BaseCommand.baseFlags,
by: Flags.string({
required: true,
description: 'Match field: title, or a searchable custom field',
}),
field: Flags.string({
multiple: true,
description: 'Field to set as "Name=Value" (repeatable)',
}),
body: Flags.string({ description: 'Raw JSON body to merge' }),
'dry-run': Flags.boolean({
description: 'Preview the action without writing',
}),
}

async run() {
const { args, flags } = await this.parse(DealUpsertCommand)
const result = await upsertWithDefs({
client: this.apiClient,
entity: 'deal',
by: flags.by,
value: args.value,
fields: flags.field,
rawBody: flags.body,
dryRun: flags['dry-run'],
})

if (this.resolveFormat() !== 'table') {
await this.outputResults(result, {})
return
}
this.log(summarizeUpsert(result, 'deal'))
}
}
90 changes: 89 additions & 1 deletion src/commands/org/import.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import BaseCommand from '../../base-command.js'
import { parseCsv } from '../../lib/csv-parse.js'
import { prepareImportBodies } from '../../lib/import.js'
import { bulkRun } from '../../lib/bulk.js'
import { bulkUpsertRows } from '../../lib/upsert.js'
import { getFields } from '../../lib/fields.js'
import { confirmAction } from '../../lib/confirm.js'
import { CliError } from '../../lib/errors.js'
Expand Down Expand Up @@ -34,6 +35,13 @@ export default class OrgImportCommand extends BaseCommand {

static flags = {
...BaseCommand.baseFlags,
upsert: Flags.boolean({
description: 'Match each row on --match-on, then create or update',
default: false,
}),
'match-on': Flags.string({
description: 'Field to match rows on in --upsert mode (e.g. name)',
}),
'dry-run': Flags.boolean({
description: 'Validate every row without creating anything',
default: false,
Expand All @@ -53,14 +61,39 @@ export default class OrgImportCommand extends BaseCommand {
throw new CliError('CSV must include a "name" column', { exitCode: 64 })
}

let matchIdx
if (flags.upsert) {
if (!flags['match-on']) {
throw new CliError('--upsert requires --match-on <field>', {
exitCode: 64,
})
}
matchIdx = headers.findIndex(
(h) => h.toLowerCase() === flags['match-on'].toLowerCase(),
)
if (matchIdx < 0) {
throw new CliError(
`--match-on "${flags['match-on']}" is not a column in ${args.file}`,
{ exitCode: 64 },
)
}
}

const needsDefs = headers.some((h) => !(h.toLowerCase() in SPECIAL_COLUMNS))
const defs =
needsDefs || flags.upsert ? await getFields(this.apiClient, 'org') : []
const bodies = prepareImportBodies({
headers,
rows,
specialColumns: SPECIAL_COLUMNS,
defs: needsDefs ? await getFields(this.apiClient, 'org') : [],
defs,
})

if (flags.upsert) {
await this.upsertRows({ args, flags, rows, bodies, matchIdx, defs })
return
}

if (flags['dry-run']) {
this.log(chalk.green(`${bodies.length} rows valid — nothing created`))
return
Expand Down Expand Up @@ -106,4 +139,59 @@ export default class OrgImportCommand extends BaseCommand {
)
}
}

/** Idempotent CSV path: match each row on --match-on, then create or PATCH. */
async upsertRows({ args, flags, rows, bodies, matchIdx, defs }) {
const matchOn = flags['match-on']
const items = bodies.map((body, i) => ({ body, value: rows[i][matchIdx] }))

if (!flags['dry-run']) {
const ok = await confirmAction(
`Upsert ${items.length} organizations from ${args.file} (match on ${matchOn})?`,
flags.yes,
)
if (!ok) {
throw new CliError('Aborted', { exitCode: 1 })
}
}

const spinner = ora(`Upserting ${items.length} organizations...`).start()
let summary
try {
summary = await bulkUpsertRows({
client: this.apiClient,
entity: 'org',
matchOn,
rows: items,
defs,
dryRun: flags['dry-run'],
onProgress: (done, total) => {
spinner.text = `Upserting organizations ${done}/${total}`
},
})
} finally {
spinner.stop()
}

const { created, updated, unchanged } = summary.counts
const prefix = flags['dry-run'] ? '[dry-run] ' : ''
this.log(
chalk.green(
`${prefix}${created} created, ${updated} updated, ${unchanged} unchanged`,
),
)

if (summary.failed.length > 0) {
for (const { item, error } of summary.failed) {
this.log(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.
const allData = summary.failed.every((f) => f.exitCode === 65)
throw new CliError(
`${summary.failed.length} of ${items.length} rows failed`,
{ exitCode: allData ? 65 : 1 },
)
}
}
}
Loading
Loading