@wavyx/pdcli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to `pdcli` are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions follow
5
5
  [SemVer](https://semver.org/).
6
6
 
7
+ ## [0.4.0] - 2026-06-04
8
+
9
+ ### Added
10
+
11
+ - `deal bulk-update` — update many deals at once by `--ids`, a Pipedrive
12
+ saved `--filter`, or ids piped on stdin. Paced sequentially inside the
13
+ rate-limit burst window, confirms before writing (`--yes` to skip),
14
+ `--dry-run` previews targets, partial failures are listed per deal and
15
+ exit 1.
16
+ - `person import` / `org import` — bulk-create from CSV. Headers map to
17
+ fields, including custom fields by human name with option-label
18
+ resolution; `--dry-run` validates every row without writing.
19
+
20
+ ### Changed
21
+
22
+ - CI actions bumped to checkout/setup-node v5.
23
+
7
24
  ## [0.3.0] - 2026-06-04
8
25
 
9
26
  ### Added
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # pdcli
2
2
 
3
+ [![CI](https://github.com/wavyx/pdcli/actions/workflows/ci.yml/badge.svg)](https://github.com/wavyx/pdcli/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/wavyx/pdcli/branch/main/graph/badge.svg)](https://codecov.io/gh/wavyx/pdcli)
5
+ [![npm](https://img.shields.io/npm/v/%40wavyx%2Fpdcli)](https://www.npmjs.com/package/@wavyx/pdcli)
6
+
3
7
  Command-line interface for [Pipedrive](https://www.pipedrive.com/) — fast, scriptable, built for terminals, CI pipelines, and AI agents.
4
8
 
5
9
  > Not affiliated with or endorsed by Pipedrive.
@@ -55,6 +59,16 @@ pdcli deal create --title "Sized" --field "Deal Size=Large" --field "Score=4.5"
55
59
  pdcli deal update 42 --body '{"probability":75}' # raw JSON escape hatch
56
60
  ```
57
61
 
62
+ ## Bulk
63
+
64
+ ```bash
65
+ pdcli deal bulk-update --filter 9 --stage 5 # saved filter → stage move
66
+ pdcli deal bulk-update --ids 1,2,3 --status won --yes
67
+ pdcli deal list --status open --jq '.[].id' | pdcli deal bulk-update --owner 42
68
+ pdcli person import people.csv --dry-run # CSV headers map to fields,
69
+ pdcli person import people.csv # custom fields by name
70
+ ```
71
+
58
72
  ## Files, webhooks, backup
59
73
 
60
74
  ```bash
@@ -1724,6 +1724,191 @@
1724
1724
  "status.js"
1725
1725
  ]
1726
1726
  },
1727
+ "deal:bulk-update": {
1728
+ "aliases": [],
1729
+ "args": {},
1730
+ "description": "Update many deals at once (by --ids, a saved --filter, or ids piped on stdin)",
1731
+ "examples": [
1732
+ "<%= config.bin %> deal bulk-update --ids 1,2,3 --stage 5",
1733
+ "<%= config.bin %> deal bulk-update --filter 9 --status won",
1734
+ "<%= config.bin %> deal list --status open --jq '.[].id' | <%= config.bin %> deal bulk-update --owner 42",
1735
+ "<%= config.bin %> deal bulk-update --filter 9 --stage 5 --dry-run"
1736
+ ],
1737
+ "flags": {
1738
+ "output": {
1739
+ "char": "o",
1740
+ "description": "Output format",
1741
+ "helpGroup": "GLOBAL",
1742
+ "name": "output",
1743
+ "hasDynamicHelp": false,
1744
+ "multiple": false,
1745
+ "options": [
1746
+ "table",
1747
+ "json",
1748
+ "yaml",
1749
+ "csv"
1750
+ ],
1751
+ "type": "option"
1752
+ },
1753
+ "jq": {
1754
+ "description": "jq expression to filter JSON output",
1755
+ "helpGroup": "GLOBAL",
1756
+ "name": "jq",
1757
+ "hasDynamicHelp": false,
1758
+ "multiple": false,
1759
+ "type": "option"
1760
+ },
1761
+ "fields": {
1762
+ "description": "Comma-separated fields to display",
1763
+ "helpGroup": "GLOBAL",
1764
+ "name": "fields",
1765
+ "hasDynamicHelp": false,
1766
+ "multiple": false,
1767
+ "type": "option"
1768
+ },
1769
+ "profile": {
1770
+ "description": "Named auth profile to use",
1771
+ "env": "PDCLI_PROFILE",
1772
+ "helpGroup": "GLOBAL",
1773
+ "name": "profile",
1774
+ "hasDynamicHelp": false,
1775
+ "multiple": false,
1776
+ "type": "option"
1777
+ },
1778
+ "no-color": {
1779
+ "description": "Disable color output",
1780
+ "helpGroup": "GLOBAL",
1781
+ "name": "no-color",
1782
+ "allowNo": false,
1783
+ "type": "boolean"
1784
+ },
1785
+ "verbose": {
1786
+ "description": "Show detailed API request/response on errors",
1787
+ "helpGroup": "GLOBAL",
1788
+ "name": "verbose",
1789
+ "allowNo": false,
1790
+ "type": "boolean"
1791
+ },
1792
+ "no-retry": {
1793
+ "description": "Disable automatic retry on rate limits and 5xx errors",
1794
+ "helpGroup": "GLOBAL",
1795
+ "name": "no-retry",
1796
+ "allowNo": false,
1797
+ "type": "boolean"
1798
+ },
1799
+ "timeout": {
1800
+ "description": "Request timeout in milliseconds",
1801
+ "helpGroup": "GLOBAL",
1802
+ "name": "timeout",
1803
+ "hasDynamicHelp": false,
1804
+ "multiple": false,
1805
+ "type": "option"
1806
+ },
1807
+ "limit": {
1808
+ "description": "Maximum number of items to return (lists)",
1809
+ "helpGroup": "GLOBAL",
1810
+ "name": "limit",
1811
+ "hasDynamicHelp": false,
1812
+ "multiple": false,
1813
+ "type": "option"
1814
+ },
1815
+ "ids": {
1816
+ "description": "Comma-separated deal IDs",
1817
+ "exclusive": [
1818
+ "filter"
1819
+ ],
1820
+ "name": "ids",
1821
+ "hasDynamicHelp": false,
1822
+ "multiple": false,
1823
+ "type": "option"
1824
+ },
1825
+ "filter": {
1826
+ "description": "Pipedrive saved filter ID to select deals",
1827
+ "exclusive": [
1828
+ "ids"
1829
+ ],
1830
+ "name": "filter",
1831
+ "hasDynamicHelp": false,
1832
+ "multiple": false,
1833
+ "type": "option"
1834
+ },
1835
+ "stage": {
1836
+ "description": "Move to stage ID",
1837
+ "name": "stage",
1838
+ "hasDynamicHelp": false,
1839
+ "multiple": false,
1840
+ "type": "option"
1841
+ },
1842
+ "pipeline": {
1843
+ "description": "Move to pipeline ID",
1844
+ "name": "pipeline",
1845
+ "hasDynamicHelp": false,
1846
+ "multiple": false,
1847
+ "type": "option"
1848
+ },
1849
+ "status": {
1850
+ "description": "Set status",
1851
+ "name": "status",
1852
+ "hasDynamicHelp": false,
1853
+ "multiple": false,
1854
+ "options": [
1855
+ "open",
1856
+ "won",
1857
+ "lost"
1858
+ ],
1859
+ "type": "option"
1860
+ },
1861
+ "owner": {
1862
+ "description": "Assign owner (user) ID",
1863
+ "name": "owner",
1864
+ "hasDynamicHelp": false,
1865
+ "multiple": false,
1866
+ "type": "option"
1867
+ },
1868
+ "field": {
1869
+ "description": "Custom/standard field as \"Name=Value\" (repeatable)",
1870
+ "name": "field",
1871
+ "hasDynamicHelp": false,
1872
+ "multiple": true,
1873
+ "type": "option"
1874
+ },
1875
+ "body": {
1876
+ "description": "Raw JSON body to merge (flags win)",
1877
+ "name": "body",
1878
+ "hasDynamicHelp": false,
1879
+ "multiple": false,
1880
+ "type": "option"
1881
+ },
1882
+ "dry-run": {
1883
+ "description": "List the targets without updating anything",
1884
+ "name": "dry-run",
1885
+ "allowNo": false,
1886
+ "type": "boolean"
1887
+ },
1888
+ "yes": {
1889
+ "char": "y",
1890
+ "description": "Skip the confirmation prompt",
1891
+ "name": "yes",
1892
+ "allowNo": false,
1893
+ "type": "boolean"
1894
+ }
1895
+ },
1896
+ "hasDynamicHelp": false,
1897
+ "hiddenAliases": [],
1898
+ "id": "deal:bulk-update",
1899
+ "pluginAlias": "@wavyx/pdcli",
1900
+ "pluginName": "@wavyx/pdcli",
1901
+ "pluginType": "core",
1902
+ "strict": true,
1903
+ "enableJsonFlag": false,
1904
+ "isESM": true,
1905
+ "relativePath": [
1906
+ "src",
1907
+ "commands",
1908
+ "deal",
1909
+ "bulk-update.js"
1910
+ ]
1911
+ },
1727
1912
  "deal:create": {
1728
1913
  "aliases": [],
1729
1914
  "args": {},
@@ -5542,6 +5727,128 @@
5542
5727
  "get.js"
5543
5728
  ]
5544
5729
  },
5730
+ "org:import": {
5731
+ "aliases": [],
5732
+ "args": {
5733
+ "file": {
5734
+ "description": "CSV file path",
5735
+ "name": "file",
5736
+ "required": true
5737
+ }
5738
+ },
5739
+ "description": "Bulk-create organizations from a CSV (headers map to fields, custom fields by name)",
5740
+ "examples": [
5741
+ "<%= config.bin %> org import orgs.csv",
5742
+ "<%= config.bin %> org import orgs.csv --dry-run"
5743
+ ],
5744
+ "flags": {
5745
+ "output": {
5746
+ "char": "o",
5747
+ "description": "Output format",
5748
+ "helpGroup": "GLOBAL",
5749
+ "name": "output",
5750
+ "hasDynamicHelp": false,
5751
+ "multiple": false,
5752
+ "options": [
5753
+ "table",
5754
+ "json",
5755
+ "yaml",
5756
+ "csv"
5757
+ ],
5758
+ "type": "option"
5759
+ },
5760
+ "jq": {
5761
+ "description": "jq expression to filter JSON output",
5762
+ "helpGroup": "GLOBAL",
5763
+ "name": "jq",
5764
+ "hasDynamicHelp": false,
5765
+ "multiple": false,
5766
+ "type": "option"
5767
+ },
5768
+ "fields": {
5769
+ "description": "Comma-separated fields to display",
5770
+ "helpGroup": "GLOBAL",
5771
+ "name": "fields",
5772
+ "hasDynamicHelp": false,
5773
+ "multiple": false,
5774
+ "type": "option"
5775
+ },
5776
+ "profile": {
5777
+ "description": "Named auth profile to use",
5778
+ "env": "PDCLI_PROFILE",
5779
+ "helpGroup": "GLOBAL",
5780
+ "name": "profile",
5781
+ "hasDynamicHelp": false,
5782
+ "multiple": false,
5783
+ "type": "option"
5784
+ },
5785
+ "no-color": {
5786
+ "description": "Disable color output",
5787
+ "helpGroup": "GLOBAL",
5788
+ "name": "no-color",
5789
+ "allowNo": false,
5790
+ "type": "boolean"
5791
+ },
5792
+ "verbose": {
5793
+ "description": "Show detailed API request/response on errors",
5794
+ "helpGroup": "GLOBAL",
5795
+ "name": "verbose",
5796
+ "allowNo": false,
5797
+ "type": "boolean"
5798
+ },
5799
+ "no-retry": {
5800
+ "description": "Disable automatic retry on rate limits and 5xx errors",
5801
+ "helpGroup": "GLOBAL",
5802
+ "name": "no-retry",
5803
+ "allowNo": false,
5804
+ "type": "boolean"
5805
+ },
5806
+ "timeout": {
5807
+ "description": "Request timeout in milliseconds",
5808
+ "helpGroup": "GLOBAL",
5809
+ "name": "timeout",
5810
+ "hasDynamicHelp": false,
5811
+ "multiple": false,
5812
+ "type": "option"
5813
+ },
5814
+ "limit": {
5815
+ "description": "Maximum number of items to return (lists)",
5816
+ "helpGroup": "GLOBAL",
5817
+ "name": "limit",
5818
+ "hasDynamicHelp": false,
5819
+ "multiple": false,
5820
+ "type": "option"
5821
+ },
5822
+ "dry-run": {
5823
+ "description": "Validate every row without creating anything",
5824
+ "name": "dry-run",
5825
+ "allowNo": false,
5826
+ "type": "boolean"
5827
+ },
5828
+ "yes": {
5829
+ "char": "y",
5830
+ "description": "Skip the confirmation prompt",
5831
+ "name": "yes",
5832
+ "allowNo": false,
5833
+ "type": "boolean"
5834
+ }
5835
+ },
5836
+ "hasDynamicHelp": false,
5837
+ "hiddenAliases": [],
5838
+ "id": "org:import",
5839
+ "pluginAlias": "@wavyx/pdcli",
5840
+ "pluginName": "@wavyx/pdcli",
5841
+ "pluginType": "core",
5842
+ "strict": true,
5843
+ "enableJsonFlag": false,
5844
+ "isESM": true,
5845
+ "relativePath": [
5846
+ "src",
5847
+ "commands",
5848
+ "org",
5849
+ "import.js"
5850
+ ]
5851
+ },
5545
5852
  "org:list": {
5546
5853
  "aliases": [],
5547
5854
  "args": {},
@@ -6169,6 +6476,128 @@
6169
6476
  "get.js"
6170
6477
  ]
6171
6478
  },
6479
+ "person:import": {
6480
+ "aliases": [],
6481
+ "args": {
6482
+ "file": {
6483
+ "description": "CSV file path",
6484
+ "name": "file",
6485
+ "required": true
6486
+ }
6487
+ },
6488
+ "description": "Bulk-create persons from a CSV (headers map to fields, custom fields by name)",
6489
+ "examples": [
6490
+ "<%= config.bin %> person import people.csv",
6491
+ "<%= config.bin %> person import people.csv --dry-run"
6492
+ ],
6493
+ "flags": {
6494
+ "output": {
6495
+ "char": "o",
6496
+ "description": "Output format",
6497
+ "helpGroup": "GLOBAL",
6498
+ "name": "output",
6499
+ "hasDynamicHelp": false,
6500
+ "multiple": false,
6501
+ "options": [
6502
+ "table",
6503
+ "json",
6504
+ "yaml",
6505
+ "csv"
6506
+ ],
6507
+ "type": "option"
6508
+ },
6509
+ "jq": {
6510
+ "description": "jq expression to filter JSON output",
6511
+ "helpGroup": "GLOBAL",
6512
+ "name": "jq",
6513
+ "hasDynamicHelp": false,
6514
+ "multiple": false,
6515
+ "type": "option"
6516
+ },
6517
+ "fields": {
6518
+ "description": "Comma-separated fields to display",
6519
+ "helpGroup": "GLOBAL",
6520
+ "name": "fields",
6521
+ "hasDynamicHelp": false,
6522
+ "multiple": false,
6523
+ "type": "option"
6524
+ },
6525
+ "profile": {
6526
+ "description": "Named auth profile to use",
6527
+ "env": "PDCLI_PROFILE",
6528
+ "helpGroup": "GLOBAL",
6529
+ "name": "profile",
6530
+ "hasDynamicHelp": false,
6531
+ "multiple": false,
6532
+ "type": "option"
6533
+ },
6534
+ "no-color": {
6535
+ "description": "Disable color output",
6536
+ "helpGroup": "GLOBAL",
6537
+ "name": "no-color",
6538
+ "allowNo": false,
6539
+ "type": "boolean"
6540
+ },
6541
+ "verbose": {
6542
+ "description": "Show detailed API request/response on errors",
6543
+ "helpGroup": "GLOBAL",
6544
+ "name": "verbose",
6545
+ "allowNo": false,
6546
+ "type": "boolean"
6547
+ },
6548
+ "no-retry": {
6549
+ "description": "Disable automatic retry on rate limits and 5xx errors",
6550
+ "helpGroup": "GLOBAL",
6551
+ "name": "no-retry",
6552
+ "allowNo": false,
6553
+ "type": "boolean"
6554
+ },
6555
+ "timeout": {
6556
+ "description": "Request timeout in milliseconds",
6557
+ "helpGroup": "GLOBAL",
6558
+ "name": "timeout",
6559
+ "hasDynamicHelp": false,
6560
+ "multiple": false,
6561
+ "type": "option"
6562
+ },
6563
+ "limit": {
6564
+ "description": "Maximum number of items to return (lists)",
6565
+ "helpGroup": "GLOBAL",
6566
+ "name": "limit",
6567
+ "hasDynamicHelp": false,
6568
+ "multiple": false,
6569
+ "type": "option"
6570
+ },
6571
+ "dry-run": {
6572
+ "description": "Validate every row without creating anything",
6573
+ "name": "dry-run",
6574
+ "allowNo": false,
6575
+ "type": "boolean"
6576
+ },
6577
+ "yes": {
6578
+ "char": "y",
6579
+ "description": "Skip the confirmation prompt",
6580
+ "name": "yes",
6581
+ "allowNo": false,
6582
+ "type": "boolean"
6583
+ }
6584
+ },
6585
+ "hasDynamicHelp": false,
6586
+ "hiddenAliases": [],
6587
+ "id": "person:import",
6588
+ "pluginAlias": "@wavyx/pdcli",
6589
+ "pluginName": "@wavyx/pdcli",
6590
+ "pluginType": "core",
6591
+ "strict": true,
6592
+ "enableJsonFlag": false,
6593
+ "isESM": true,
6594
+ "relativePath": [
6595
+ "src",
6596
+ "commands",
6597
+ "person",
6598
+ "import.js"
6599
+ ]
6600
+ },
6172
6601
  "person:list": {
6173
6602
  "aliases": [],
6174
6603
  "args": {},
@@ -8329,19 +8758,12 @@
8329
8758
  "update.js"
8330
8759
  ]
8331
8760
  },
8332
- "stage:get": {
8761
+ "user:me": {
8333
8762
  "aliases": [],
8334
- "args": {
8335
- "id": {
8336
- "description": "Stage ID",
8337
- "name": "id",
8338
- "required": true
8339
- }
8340
- },
8341
- "description": "Get a stage by ID",
8763
+ "args": {},
8764
+ "description": "Show the authenticated user",
8342
8765
  "examples": [
8343
- "<%= config.bin %> stage get 5",
8344
- "<%= config.bin %> stage get 5 --output json"
8766
+ "<%= config.bin %> user me"
8345
8767
  ],
8346
8768
  "flags": {
8347
8769
  "output": {
@@ -8424,7 +8846,7 @@
8424
8846
  },
8425
8847
  "hasDynamicHelp": false,
8426
8848
  "hiddenAliases": [],
8427
- "id": "stage:get",
8849
+ "id": "user:me",
8428
8850
  "pluginAlias": "@wavyx/pdcli",
8429
8851
  "pluginName": "@wavyx/pdcli",
8430
8852
  "pluginType": "core",
@@ -8434,17 +8856,23 @@
8434
8856
  "relativePath": [
8435
8857
  "src",
8436
8858
  "commands",
8437
- "stage",
8438
- "get.js"
8859
+ "user",
8860
+ "me.js"
8439
8861
  ]
8440
8862
  },
8441
- "stage:list": {
8863
+ "stage:get": {
8442
8864
  "aliases": [],
8443
- "args": {},
8444
- "description": "List stages",
8865
+ "args": {
8866
+ "id": {
8867
+ "description": "Stage ID",
8868
+ "name": "id",
8869
+ "required": true
8870
+ }
8871
+ },
8872
+ "description": "Get a stage by ID",
8445
8873
  "examples": [
8446
- "<%= config.bin %> stage list",
8447
- "<%= config.bin %> stage list --pipeline 1 --output json"
8874
+ "<%= config.bin %> stage get 5",
8875
+ "<%= config.bin %> stage get 5 --output json"
8448
8876
  ],
8449
8877
  "flags": {
8450
8878
  "output": {
@@ -8523,18 +8951,11 @@
8523
8951
  "hasDynamicHelp": false,
8524
8952
  "multiple": false,
8525
8953
  "type": "option"
8526
- },
8527
- "pipeline": {
8528
- "description": "Filter by pipeline ID",
8529
- "name": "pipeline",
8530
- "hasDynamicHelp": false,
8531
- "multiple": false,
8532
- "type": "option"
8533
8954
  }
8534
8955
  },
8535
8956
  "hasDynamicHelp": false,
8536
8957
  "hiddenAliases": [],
8537
- "id": "stage:list",
8958
+ "id": "stage:get",
8538
8959
  "pluginAlias": "@wavyx/pdcli",
8539
8960
  "pluginName": "@wavyx/pdcli",
8540
8961
  "pluginType": "core",
@@ -8545,15 +8966,16 @@
8545
8966
  "src",
8546
8967
  "commands",
8547
8968
  "stage",
8548
- "list.js"
8969
+ "get.js"
8549
8970
  ]
8550
8971
  },
8551
- "user:me": {
8972
+ "stage:list": {
8552
8973
  "aliases": [],
8553
8974
  "args": {},
8554
- "description": "Show the authenticated user",
8975
+ "description": "List stages",
8555
8976
  "examples": [
8556
- "<%= config.bin %> user me"
8977
+ "<%= config.bin %> stage list",
8978
+ "<%= config.bin %> stage list --pipeline 1 --output json"
8557
8979
  ],
8558
8980
  "flags": {
8559
8981
  "output": {
@@ -8632,11 +9054,18 @@
8632
9054
  "hasDynamicHelp": false,
8633
9055
  "multiple": false,
8634
9056
  "type": "option"
9057
+ },
9058
+ "pipeline": {
9059
+ "description": "Filter by pipeline ID",
9060
+ "name": "pipeline",
9061
+ "hasDynamicHelp": false,
9062
+ "multiple": false,
9063
+ "type": "option"
8635
9064
  }
8636
9065
  },
8637
9066
  "hasDynamicHelp": false,
8638
9067
  "hiddenAliases": [],
8639
- "id": "user:me",
9068
+ "id": "stage:list",
8640
9069
  "pluginAlias": "@wavyx/pdcli",
8641
9070
  "pluginName": "@wavyx/pdcli",
8642
9071
  "pluginType": "core",
@@ -8646,8 +9075,8 @@
8646
9075
  "relativePath": [
8647
9076
  "src",
8648
9077
  "commands",
8649
- "user",
8650
- "me.js"
9078
+ "stage",
9079
+ "list.js"
8651
9080
  ]
8652
9081
  },
8653
9082
  "webhook:create": {
@@ -9051,5 +9480,5 @@
9051
9480
  ]
9052
9481
  }
9053
9482
  },
9054
- "version": "0.3.0"
9483
+ "version": "0.4.0"
9055
9484
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavyx/pdcli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,131 @@
1
+ import { Flags } from '@oclif/core'
2
+ import chalk from 'chalk'
3
+ import ora from 'ora'
4
+ import BaseCommand from '../../base-command.js'
5
+ import { resolveTargets, bulkRun } from '../../lib/bulk.js'
6
+ import { buildWriteBody } from '../../lib/input.js'
7
+ import { defsForFields } from '../../lib/entity-view.js'
8
+ import { confirmAction } from '../../lib/confirm.js'
9
+ import { CliError } from '../../lib/errors.js'
10
+
11
+ export default class DealBulkUpdateCommand extends BaseCommand {
12
+ static description =
13
+ 'Update many deals at once (by --ids, a saved --filter, or ids piped on stdin)'
14
+
15
+ static examples = [
16
+ '<%= config.bin %> deal bulk-update --ids 1,2,3 --stage 5',
17
+ '<%= config.bin %> deal bulk-update --filter 9 --status won',
18
+ "<%= config.bin %> deal list --status open --jq '.[].id' | <%= config.bin %> deal bulk-update --owner 42",
19
+ '<%= config.bin %> deal bulk-update --filter 9 --stage 5 --dry-run',
20
+ ]
21
+
22
+ static flags = {
23
+ ...BaseCommand.baseFlags,
24
+ ids: Flags.string({
25
+ description: 'Comma-separated deal IDs',
26
+ exclusive: ['filter'],
27
+ }),
28
+ filter: Flags.integer({
29
+ description: 'Pipedrive saved filter ID to select deals',
30
+ exclusive: ['ids'],
31
+ }),
32
+ stage: Flags.integer({ description: 'Move to stage ID' }),
33
+ pipeline: Flags.integer({ description: 'Move to pipeline ID' }),
34
+ status: Flags.string({
35
+ description: 'Set status',
36
+ options: ['open', 'won', 'lost'],
37
+ }),
38
+ owner: Flags.integer({ description: 'Assign owner (user) ID' }),
39
+ field: Flags.string({
40
+ multiple: true,
41
+ description: 'Custom/standard field as "Name=Value" (repeatable)',
42
+ }),
43
+ body: Flags.string({ description: 'Raw JSON body to merge (flags win)' }),
44
+ 'dry-run': Flags.boolean({
45
+ description: 'List the targets without updating anything',
46
+ default: false,
47
+ }),
48
+ yes: Flags.boolean({
49
+ char: 'y',
50
+ description: 'Skip the confirmation prompt',
51
+ default: false,
52
+ }),
53
+ }
54
+
55
+ async run() {
56
+ const { flags } = await this.parse(DealBulkUpdateCommand)
57
+
58
+ const body = buildWriteBody({
59
+ typed: {
60
+ stage_id: flags.stage,
61
+ pipeline_id: flags.pipeline,
62
+ status: flags.status,
63
+ owner_id: flags.owner,
64
+ },
65
+ fields: flags.field,
66
+ rawBody: flags.body,
67
+ defs: await defsForFields(this, 'deal', flags.field),
68
+ })
69
+
70
+ if (Object.keys(body).length === 0) {
71
+ throw new CliError(
72
+ 'Nothing to update — pass at least one change flag, --field, or --body',
73
+ { exitCode: 64 },
74
+ )
75
+ }
76
+
77
+ const targets = await resolveTargets(
78
+ { ids: flags.ids, filter: flags.filter },
79
+ this.apiClient,
80
+ '/api/v2/deals',
81
+ )
82
+
83
+ if (flags['dry-run']) {
84
+ this.log(
85
+ `Would update ${chalk.bold(targets.length)} deals: ${targets.join(', ')}`,
86
+ )
87
+ this.log(chalk.dim(`Change: ${JSON.stringify(body)}`))
88
+ return
89
+ }
90
+
91
+ const ok = await confirmAction(
92
+ `Update ${targets.length} deals with ${JSON.stringify(body)}?`,
93
+ flags.yes,
94
+ )
95
+ if (!ok) {
96
+ throw new CliError('Aborted', { exitCode: 1 })
97
+ }
98
+
99
+ const spinner = ora(`Updating ${targets.length} deals...`).start()
100
+ let summary
101
+ try {
102
+ summary = await bulkRun(
103
+ targets,
104
+ (id) => this.apiClient.patch(`/api/v2/deals/${id}`, { body }),
105
+ {
106
+ onProgress: (done, total) => {
107
+ spinner.text = `Updating deals ${done}/${total}`
108
+ },
109
+ },
110
+ )
111
+ } finally {
112
+ spinner.stop()
113
+ }
114
+
115
+ this.log(
116
+ chalk.green(
117
+ `Updated ${summary.succeeded.length}/${targets.length} deals`,
118
+ ),
119
+ )
120
+
121
+ if (summary.failed.length > 0) {
122
+ for (const { item, error } of summary.failed) {
123
+ this.log(chalk.red(` ✘ deal ${item}: ${error}`))
124
+ }
125
+ throw new CliError(
126
+ `${summary.failed.length} of ${targets.length} updates failed`,
127
+ { exitCode: 1 },
128
+ )
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,109 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { Args, Flags } from '@oclif/core'
3
+ import chalk from 'chalk'
4
+ import ora from 'ora'
5
+ import BaseCommand from '../../base-command.js'
6
+ import { parseCsv } from '../../lib/csv-parse.js'
7
+ import { prepareImportBodies } from '../../lib/import.js'
8
+ import { bulkRun } from '../../lib/bulk.js'
9
+ import { getFields } from '../../lib/fields.js'
10
+ import { confirmAction } from '../../lib/confirm.js'
11
+ import { CliError } from '../../lib/errors.js'
12
+
13
+ const SPECIAL_COLUMNS = {
14
+ name: (typed, value) => {
15
+ typed.name = value
16
+ },
17
+ owner_id: (typed, value) => {
18
+ typed.owner_id = Number(value)
19
+ },
20
+ }
21
+
22
+ export default class OrgImportCommand extends BaseCommand {
23
+ static description =
24
+ 'Bulk-create organizations from a CSV (headers map to fields, custom fields by name)'
25
+
26
+ static examples = [
27
+ '<%= config.bin %> org import orgs.csv',
28
+ '<%= config.bin %> org import orgs.csv --dry-run',
29
+ ]
30
+
31
+ static args = {
32
+ file: Args.string({ required: true, description: 'CSV file path' }),
33
+ }
34
+
35
+ static flags = {
36
+ ...BaseCommand.baseFlags,
37
+ 'dry-run': Flags.boolean({
38
+ description: 'Validate every row without creating anything',
39
+ default: false,
40
+ }),
41
+ yes: Flags.boolean({
42
+ char: 'y',
43
+ description: 'Skip the confirmation prompt',
44
+ default: false,
45
+ }),
46
+ }
47
+
48
+ async run() {
49
+ const { args, flags } = await this.parse(OrgImportCommand)
50
+
51
+ const { headers, rows } = parseCsv(readFileSync(args.file, 'utf8'))
52
+ if (!headers.some((h) => h.toLowerCase() === 'name')) {
53
+ throw new CliError('CSV must include a "name" column', { exitCode: 64 })
54
+ }
55
+
56
+ const needsDefs = headers.some((h) => !(h.toLowerCase() in SPECIAL_COLUMNS))
57
+ const bodies = prepareImportBodies({
58
+ headers,
59
+ rows,
60
+ specialColumns: SPECIAL_COLUMNS,
61
+ defs: needsDefs ? await getFields(this.apiClient, 'org') : [],
62
+ })
63
+
64
+ if (flags['dry-run']) {
65
+ this.log(chalk.green(`${bodies.length} rows valid — nothing created`))
66
+ return
67
+ }
68
+
69
+ const ok = await confirmAction(
70
+ `Create ${bodies.length} organizations from ${args.file}?`,
71
+ flags.yes,
72
+ )
73
+ if (!ok) {
74
+ throw new CliError('Aborted', { exitCode: 1 })
75
+ }
76
+
77
+ const spinner = ora(`Importing ${bodies.length} organizations...`).start()
78
+ let summary
79
+ try {
80
+ summary = await bulkRun(
81
+ bodies,
82
+ (body) => this.apiClient.post('/api/v2/organizations', { body }),
83
+ {
84
+ onProgress: (done, total) => {
85
+ spinner.text = `Importing organizations ${done}/${total}`
86
+ },
87
+ },
88
+ )
89
+ } finally {
90
+ spinner.stop()
91
+ }
92
+
93
+ this.log(
94
+ chalk.green(
95
+ `Imported ${summary.succeeded.length}/${bodies.length} organizations`,
96
+ ),
97
+ )
98
+
99
+ if (summary.failed.length > 0) {
100
+ for (const { item, error } of summary.failed) {
101
+ this.log(chalk.red(` ✘ ${item.name ?? '(unnamed)'}: ${error}`))
102
+ }
103
+ throw new CliError(
104
+ `${summary.failed.length} of ${bodies.length} rows failed`,
105
+ { exitCode: 1 },
106
+ )
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,118 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { Args, Flags } from '@oclif/core'
3
+ import chalk from 'chalk'
4
+ import ora from 'ora'
5
+ import BaseCommand from '../../base-command.js'
6
+ import { parseCsv } from '../../lib/csv-parse.js'
7
+ import { prepareImportBodies } from '../../lib/import.js'
8
+ import { bulkRun } from '../../lib/bulk.js'
9
+ import { getFields } from '../../lib/fields.js'
10
+ import { confirmAction } from '../../lib/confirm.js'
11
+ import { CliError } from '../../lib/errors.js'
12
+
13
+ const SPECIAL_COLUMNS = {
14
+ name: (typed, value) => {
15
+ typed.name = value
16
+ },
17
+ email: (typed, value) => {
18
+ typed.emails = [{ value, primary: true }]
19
+ },
20
+ phone: (typed, value) => {
21
+ typed.phones = [{ value, primary: true }]
22
+ },
23
+ org_id: (typed, value) => {
24
+ typed.org_id = Number(value)
25
+ },
26
+ owner_id: (typed, value) => {
27
+ typed.owner_id = Number(value)
28
+ },
29
+ }
30
+
31
+ export default class PersonImportCommand extends BaseCommand {
32
+ static description =
33
+ 'Bulk-create persons from a CSV (headers map to fields, custom fields by name)'
34
+
35
+ static examples = [
36
+ '<%= config.bin %> person import people.csv',
37
+ '<%= config.bin %> person import people.csv --dry-run',
38
+ ]
39
+
40
+ static args = {
41
+ file: Args.string({ required: true, description: 'CSV file path' }),
42
+ }
43
+
44
+ static flags = {
45
+ ...BaseCommand.baseFlags,
46
+ 'dry-run': Flags.boolean({
47
+ description: 'Validate every row without creating anything',
48
+ default: false,
49
+ }),
50
+ yes: Flags.boolean({
51
+ char: 'y',
52
+ description: 'Skip the confirmation prompt',
53
+ default: false,
54
+ }),
55
+ }
56
+
57
+ async run() {
58
+ const { args, flags } = await this.parse(PersonImportCommand)
59
+
60
+ const { headers, rows } = parseCsv(readFileSync(args.file, 'utf8'))
61
+ if (!headers.some((h) => h.toLowerCase() === 'name')) {
62
+ throw new CliError('CSV must include a "name" column', { exitCode: 64 })
63
+ }
64
+
65
+ const needsDefs = headers.some((h) => !(h.toLowerCase() in SPECIAL_COLUMNS))
66
+ const bodies = prepareImportBodies({
67
+ headers,
68
+ rows,
69
+ specialColumns: SPECIAL_COLUMNS,
70
+ defs: needsDefs ? await getFields(this.apiClient, 'person') : [],
71
+ })
72
+
73
+ if (flags['dry-run']) {
74
+ this.log(chalk.green(`${bodies.length} rows valid — nothing created`))
75
+ return
76
+ }
77
+
78
+ const ok = await confirmAction(
79
+ `Create ${bodies.length} persons from ${args.file}?`,
80
+ flags.yes,
81
+ )
82
+ if (!ok) {
83
+ throw new CliError('Aborted', { exitCode: 1 })
84
+ }
85
+
86
+ const spinner = ora(`Importing ${bodies.length} persons...`).start()
87
+ let summary
88
+ try {
89
+ summary = await bulkRun(
90
+ bodies,
91
+ (body) => this.apiClient.post('/api/v2/persons', { body }),
92
+ {
93
+ onProgress: (done, total) => {
94
+ spinner.text = `Importing persons ${done}/${total}`
95
+ },
96
+ },
97
+ )
98
+ } finally {
99
+ spinner.stop()
100
+ }
101
+
102
+ this.log(
103
+ chalk.green(
104
+ `Imported ${summary.succeeded.length}/${bodies.length} persons`,
105
+ ),
106
+ )
107
+
108
+ if (summary.failed.length > 0) {
109
+ for (const { item, error } of summary.failed) {
110
+ this.log(chalk.red(` ✘ ${item.name ?? '(unnamed)'}: ${error}`))
111
+ }
112
+ throw new CliError(
113
+ `${summary.failed.length} of ${bodies.length} rows failed`,
114
+ { exitCode: 1 },
115
+ )
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,106 @@
1
+ import createDebug from 'debug'
2
+ import { CliError } from './errors.js'
3
+
4
+ const debug = createDebug('pd:bulk')
5
+
6
+ /**
7
+ * Resolve the target ids for a bulk operation from exactly one selector:
8
+ * --ids "1,2,3", a Pipedrive saved filter (--filter <id>), or piped stdin
9
+ * (newline-separated ids, a JSON array of ids, or JSON objects with an id).
10
+ * @param {object} selectors
11
+ * @param {string} [selectors.ids]
12
+ * @param {number} [selectors.filter]
13
+ * @param {NodeJS.ReadStream} [selectors.stdin] defaults to process.stdin
14
+ * @param {ReturnType<import('./client.js').createClient>} client
15
+ * @param {string} listPath v2 list endpoint supporting filter_id (e.g. /api/v2/deals)
16
+ * @returns {Promise<number[]>}
17
+ */
18
+ export async function resolveTargets(
19
+ { ids, filter, stdin = process.stdin },
20
+ client,
21
+ listPath,
22
+ ) {
23
+ if (ids) {
24
+ return ids.split(',').map(parseId)
25
+ }
26
+
27
+ if (filter != null) {
28
+ debug('resolving targets from filter %d', filter)
29
+ const targets = []
30
+ for await (const item of client.pageV2(listPath, {
31
+ filter_id: filter,
32
+ limit: 500,
33
+ })) {
34
+ targets.push(item.id)
35
+ }
36
+ return targets
37
+ }
38
+
39
+ if (!stdin.isTTY) {
40
+ const chunks = []
41
+ for await (const chunk of stdin) chunks.push(chunk)
42
+ const text = Buffer.concat(chunks).toString('utf8').trim()
43
+ if (text.startsWith('[')) {
44
+ const parsed = JSON.parse(text)
45
+ return parsed.map((entry) =>
46
+ typeof entry === 'object' ? entry.id : parseId(String(entry)),
47
+ )
48
+ }
49
+ return text.split('\n').map(parseId)
50
+ }
51
+
52
+ throw new CliError(
53
+ 'No targets — pass --ids, --filter, or pipe ids on stdin',
54
+ { exitCode: 64 },
55
+ )
56
+ }
57
+
58
+ function parseId(raw) {
59
+ const id = Number(raw.trim())
60
+ if (!Number.isInteger(id)) {
61
+ throw new CliError(`Invalid id "${raw.trim()}" — expected an integer`, {
62
+ exitCode: 64,
63
+ })
64
+ }
65
+ return id
66
+ }
67
+
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms))
70
+ }
71
+
72
+ /**
73
+ * Run an async operation per item, sequentially with a pacing gap so bulk
74
+ * writes stay inside Pipedrive's 2-second burst window (writes cost 10
75
+ * tokens each; the client's 429 backoff covers anything that slips through).
76
+ * Per-item failures are collected, never thrown.
77
+ * @template T
78
+ * @param {T[]} items
79
+ * @param {(item: T) => Promise<unknown>} operation
80
+ * @param {object} [options]
81
+ * @param {number} [options.gapMs] delay between requests (default 200)
82
+ * @param {(done: number, total: number) => void} [options.onProgress]
83
+ * @returns {Promise<{ succeeded: { item: T, result: unknown }[], failed: { item: T, error: string }[] }>}
84
+ */
85
+ export async function bulkRun(
86
+ items,
87
+ operation,
88
+ { gapMs = 200, onProgress } = {},
89
+ ) {
90
+ const succeeded = []
91
+ const failed = []
92
+
93
+ for (const [index, item] of items.entries()) {
94
+ if (index > 0 && gapMs > 0) await sleep(gapMs)
95
+ try {
96
+ const result = await operation(item)
97
+ succeeded.push({ item, result })
98
+ } catch (err) {
99
+ debug('bulk item %o failed: %s', item, err.message)
100
+ failed.push({ item, error: err.message })
101
+ }
102
+ onProgress?.(index + 1, items.length)
103
+ }
104
+
105
+ return { succeeded, failed }
106
+ }
@@ -0,0 +1,88 @@
1
+ import { CliError } from './errors.js'
2
+
3
+ /**
4
+ * Minimal RFC 4180 CSV parser (quoted fields, escaped quotes, embedded
5
+ * commas/newlines, CRLF). First record is the header row.
6
+ * @param {string} text
7
+ * @returns {{ headers: string[], rows: string[][] }}
8
+ */
9
+ export function parseCsv(text) {
10
+ const records = []
11
+ let record = []
12
+ let field = ''
13
+ let inQuotes = false
14
+ let i = 0
15
+
16
+ while (i < text.length) {
17
+ const char = text[i]
18
+
19
+ if (inQuotes) {
20
+ if (char === '"') {
21
+ if (text[i + 1] === '"') {
22
+ field += '"'
23
+ i += 2
24
+ continue
25
+ }
26
+ inQuotes = false
27
+ i++
28
+ continue
29
+ }
30
+ field += char
31
+ i++
32
+ continue
33
+ }
34
+
35
+ if (char === '"') {
36
+ inQuotes = true
37
+ i++
38
+ continue
39
+ }
40
+ if (char === ',') {
41
+ record.push(field)
42
+ field = ''
43
+ i++
44
+ continue
45
+ }
46
+ if (char === '\n' || char === '\r') {
47
+ if (char === '\r' && text[i + 1] === '\n') i++
48
+ record.push(field)
49
+ field = ''
50
+ if (record.length > 1 || record[0] !== '') records.push(record)
51
+ record = []
52
+ i++
53
+ continue
54
+ }
55
+ field += char
56
+ i++
57
+ }
58
+
59
+ if (inQuotes) {
60
+ throw new CliError('Unterminated quoted field in CSV', { exitCode: 65 })
61
+ }
62
+ if (field !== '' || record.length > 0) {
63
+ // A tail record only reaches here when non-empty (a bare trailing
64
+ // newline never starts a record), so push unconditionally.
65
+ record.push(field)
66
+ records.push(record)
67
+ }
68
+
69
+ if (records.length === 0) {
70
+ throw new CliError('CSV file is empty', { exitCode: 65 })
71
+ }
72
+
73
+ const [headers, ...rows] = records
74
+
75
+ return {
76
+ headers,
77
+ rows: rows.map((row, index) => {
78
+ if (row.length > headers.length) {
79
+ throw new CliError(
80
+ `CSV row ${index + 2} has ${row.length} cells but the header has ${headers.length}`,
81
+ { exitCode: 65 },
82
+ )
83
+ }
84
+ while (row.length < headers.length) row.push('')
85
+ return row
86
+ }),
87
+ }
88
+ }
@@ -0,0 +1,49 @@
1
+ import { buildWriteBody } from './input.js'
2
+ import { CliError } from './errors.js'
3
+
4
+ /**
5
+ * Turn parsed CSV rows into write bodies. Special columns (matched
6
+ * case-insensitively) build typed values; every other header resolves
7
+ * through the entity's field definitions — names, hash keys, and option
8
+ * labels included. Empty cells are skipped.
9
+ * @param {object} options
10
+ * @param {string[]} options.headers
11
+ * @param {string[][]} options.rows
12
+ * @param {Record<string, (typed: object, value: string) => void>} [options.specialColumns]
13
+ * @param {object[]} [options.defs] field definitions for non-special headers
14
+ * @returns {object[]} one request body per row
15
+ */
16
+ export function prepareImportBodies({
17
+ headers,
18
+ rows,
19
+ specialColumns = {},
20
+ defs,
21
+ }) {
22
+ const specials = Object.fromEntries(
23
+ Object.entries(specialColumns).map(([k, v]) => [k.toLowerCase(), v]),
24
+ )
25
+
26
+ return rows.map((row, index) => {
27
+ const typed = {}
28
+ const fields = []
29
+
30
+ headers.forEach((header, i) => {
31
+ const value = row[i]
32
+ if (value === '') return
33
+ const special = specials[header.toLowerCase()]
34
+ if (special) {
35
+ special(typed, value)
36
+ return
37
+ }
38
+ fields.push(`${header}=${value}`)
39
+ })
40
+
41
+ try {
42
+ return buildWriteBody({ typed, fields, defs })
43
+ } catch (err) {
44
+ throw new CliError(`CSV row ${index + 2}: ${err.message}`, {
45
+ exitCode: 65,
46
+ })
47
+ }
48
+ })
49
+ }