@wavyx/pdcli 0.9.0 → 0.10.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.
@@ -1,6 +1,7 @@
1
1
  import { Flags } from '@oclif/core'
2
2
  import BaseCommand from '../../base-command.js'
3
3
  import { collectPages } from '../../lib/pagination.js'
4
+ import { CliError } from '../../lib/errors.js'
4
5
 
5
6
  function primary(list) {
6
7
  if (!Array.isArray(list) || list.length === 0) return ''
@@ -28,16 +29,53 @@ export default class PersonListCommand extends BaseCommand {
28
29
  ...BaseCommand.baseFlags,
29
30
  owner: Flags.integer({ description: 'Filter by owner (user) ID' }),
30
31
  org: Flags.integer({ description: 'Filter by organization ID' }),
32
+ filter: Flags.integer({ description: 'Filter by saved filter ID' }),
33
+ ids: Flags.string({
34
+ description: 'Comma-separated IDs to fetch (max 100)',
35
+ // The API silently drops `ids` when filter_id is present — refuse
36
+ // the combination instead (matches deal bulk-update).
37
+ exclusive: ['filter'],
38
+ }),
39
+ 'sort-by': Flags.string({
40
+ description: 'Sort field',
41
+ options: ['id', 'update_time', 'add_time'],
42
+ }),
43
+ 'sort-direction': Flags.string({
44
+ description: 'Sort direction',
45
+ options: ['asc', 'desc'],
46
+ }),
47
+ 'updated-since': Flags.string({
48
+ description:
49
+ 'Only items updated at/after this RFC3339 time (no fractional seconds)',
50
+ }),
51
+ 'updated-until': Flags.string({
52
+ description:
53
+ 'Only items updated before this RFC3339 time (no fractional seconds)',
54
+ }),
31
55
  }
32
56
 
33
57
  async run() {
34
58
  const { flags } = await this.parse(PersonListCommand)
35
- const limit = flags.limit ?? 100
59
+ const limit = flags.limit ?? 500
60
+
61
+ const idList = flags.ids
62
+ ?.split(',')
63
+ .map((v) => v.trim())
64
+ .filter(Boolean)
65
+ if (idList && idList.length > 100) {
66
+ throw new CliError('--ids accepts at most 100 IDs', { exitCode: 64 })
67
+ }
36
68
 
37
69
  const query = {
38
70
  owner_id: flags.owner,
39
71
  org_id: flags.org,
40
- limit: Math.min(limit, 100),
72
+ filter_id: flags.filter,
73
+ ids: idList?.join(','),
74
+ sort_by: flags['sort-by'],
75
+ sort_direction: flags['sort-direction'],
76
+ updated_since: flags['updated-since'],
77
+ updated_until: flags['updated-until'],
78
+ limit: Math.min(limit, 500),
41
79
  }
42
80
 
43
81
  const items = await collectPages(
@@ -1,6 +1,7 @@
1
1
  import { Flags } from '@oclif/core'
2
2
  import BaseCommand from '../../base-command.js'
3
3
  import { collectPages } from '../../lib/pagination.js'
4
+ import { CliError } from '../../lib/errors.js'
4
5
 
5
6
  const columns = {
6
7
  id: { header: 'ID' },
@@ -25,15 +26,47 @@ export default class ProductListCommand extends BaseCommand {
25
26
  static flags = {
26
27
  ...BaseCommand.baseFlags,
27
28
  owner: Flags.integer({ description: 'Filter by owner (user) ID' }),
29
+ filter: Flags.integer({ description: 'Filter by saved filter ID' }),
30
+ ids: Flags.string({
31
+ description: 'Comma-separated IDs to fetch (max 100)',
32
+ // The API silently drops `ids` when filter_id is present — refuse
33
+ // the combination instead (matches deal bulk-update).
34
+ exclusive: ['filter'],
35
+ }),
36
+ 'sort-by': Flags.string({
37
+ description: 'Sort field',
38
+ options: ['id', 'name', 'add_time', 'update_time'],
39
+ }),
40
+ 'sort-direction': Flags.string({
41
+ description: 'Sort direction',
42
+ options: ['asc', 'desc'],
43
+ }),
44
+ 'updated-since': Flags.string({
45
+ description:
46
+ 'Only items updated at/after this RFC3339 time (no fractional seconds)',
47
+ }),
28
48
  }
29
49
 
30
50
  async run() {
31
51
  const { flags } = await this.parse(ProductListCommand)
32
- const limit = flags.limit ?? 100
52
+ const limit = flags.limit ?? 500
53
+
54
+ const idList = flags.ids
55
+ ?.split(',')
56
+ .map((v) => v.trim())
57
+ .filter(Boolean)
58
+ if (idList && idList.length > 100) {
59
+ throw new CliError('--ids accepts at most 100 IDs', { exitCode: 64 })
60
+ }
33
61
 
34
62
  const query = {
35
63
  owner_id: flags.owner,
36
- limit: Math.min(limit, 100),
64
+ filter_id: flags.filter,
65
+ ids: idList?.join(','),
66
+ sort_by: flags['sort-by'],
67
+ sort_direction: flags['sort-direction'],
68
+ updated_since: flags['updated-since'],
69
+ limit: Math.min(limit, 500),
37
70
  }
38
71
 
39
72
  const items = await collectPages(
@@ -0,0 +1,50 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import BaseCommand from '../../base-command.js'
3
+
4
+ const columns = {
5
+ id: { header: 'ID' },
6
+ name: { header: 'Name' },
7
+ email: { header: 'Email' },
8
+ active_flag: {
9
+ header: 'Active',
10
+ get: (row) => (row.active_flag ? 'yes' : 'no'),
11
+ },
12
+ is_admin: { header: 'Admin', get: (row) => (row.is_admin ? 'yes' : 'no') },
13
+ }
14
+
15
+ export default class UserFindCommand extends BaseCommand {
16
+ static description = 'Find users by name'
17
+
18
+ static examples = [
19
+ '<%= config.bin %> user find "jane"',
20
+ '<%= config.bin %> user find "jane@acme.com" --by-email --output json',
21
+ ]
22
+
23
+ static args = {
24
+ term: Args.string({ required: true, description: 'Search term' }),
25
+ }
26
+
27
+ static flags = {
28
+ ...BaseCommand.baseFlags,
29
+ 'by-email': Flags.boolean({
30
+ description: 'Match the term against email addresses only',
31
+ default: false,
32
+ }),
33
+ }
34
+
35
+ async run() {
36
+ const { args, flags } = await this.parse(UserFindCommand)
37
+
38
+ // Users API is v1-only; /users/find returns one unpaginated array.
39
+ const body = await this.apiClient.get('/api/v1/users/find', {
40
+ query: {
41
+ term: args.term,
42
+ search_by_email: flags['by-email'] ? 1 : undefined,
43
+ },
44
+ })
45
+ let users = body.data ?? []
46
+ if (flags.limit) users = users.slice(0, flags.limit)
47
+
48
+ await this.outputResults(users, columns)
49
+ }
50
+ }
@@ -0,0 +1,36 @@
1
+ import BaseCommand from '../../base-command.js'
2
+
3
+ const columns = {
4
+ id: { header: 'ID' },
5
+ name: { header: 'Name' },
6
+ email: { header: 'Email' },
7
+ active_flag: {
8
+ header: 'Active',
9
+ get: (row) => (row.active_flag ? 'yes' : 'no'),
10
+ },
11
+ is_admin: { header: 'Admin', get: (row) => (row.is_admin ? 'yes' : 'no') },
12
+ }
13
+
14
+ export default class UserListCommand extends BaseCommand {
15
+ static description = 'List all users'
16
+
17
+ static examples = [
18
+ '<%= config.bin %> user list',
19
+ '<%= config.bin %> user list --output json',
20
+ ]
21
+
22
+ static flags = {
23
+ ...BaseCommand.baseFlags,
24
+ }
25
+
26
+ async run() {
27
+ const { flags } = await this.parse(UserListCommand)
28
+
29
+ // Users API is v1-only and returns every user in one unpaginated array.
30
+ const body = await this.apiClient.get('/api/v1/users')
31
+ let users = body.data ?? []
32
+ if (flags.limit) users = users.slice(0, flags.limit)
33
+
34
+ await this.outputResults(users, columns)
35
+ }
36
+ }
@@ -27,15 +27,21 @@ export default class WebhookCreateCommand extends BaseCommand {
27
27
  description: 'Event object to subscribe to',
28
28
  options: [
29
29
  'activity',
30
+ 'board',
30
31
  'deal',
32
+ 'deal_installment',
33
+ 'deal_product',
31
34
  'lead',
32
35
  'note',
33
36
  'organization',
34
37
  'person',
35
- 'product',
36
- 'user',
38
+ 'phase',
37
39
  'pipeline',
40
+ 'product',
41
+ 'project',
38
42
  'stage',
43
+ 'task',
44
+ 'user',
39
45
  '*',
40
46
  ],
41
47
  }),
@@ -0,0 +1,85 @@
1
+ import { collectPages } from './pagination.js'
2
+ import { ApiError } from './errors.js'
3
+
4
+ /** Token cost of one GET /deals/{id}/changelog request (rate-limit budget). */
5
+ export const CHANGELOG_COST = 20
6
+ /** Above this deal count, mining gets expensive — warn before proceeding. */
7
+ export const MINE_WARN_THRESHOLD = 100
8
+ /** Pipedrive caps list page sizes at 500; the changelog uses the same cap. */
9
+ const MAX_PAGE_LIMIT = 500
10
+
11
+ /**
12
+ * Fetch a single deal's field-change history. The deal changelog lives on a v1
13
+ * path but pages with a flat v2-style cursor (additional_data.next_cursor), so
14
+ * the v2 pager works directly. Rows arrive newest-first (the API's native order)
15
+ * and carry { field_key, old_value, new_value, actor_user_id, time, ... }.
16
+ * @param {ReturnType<import('./client.js').createClient>} client
17
+ * @param {number} dealId
18
+ * @param {{ limit?: number }} [options]
19
+ * @returns {Promise<object[]>}
20
+ */
21
+ export async function fetchChangelog(client, dealId, { limit } = {}) {
22
+ return collectPages(
23
+ client.pageV2(`/api/v1/deals/${dealId}/changelog`, {
24
+ limit: limit ?? MAX_PAGE_LIMIT,
25
+ }),
26
+ )
27
+ }
28
+
29
+ /**
30
+ * Mine the changelog of many deals, one request each. Warns on stderr before
31
+ * mining a large set — each request costs tokens — then lets the client's rate
32
+ * limiter pace it. A single bad changelog request must not abort the whole mine:
33
+ * deals whose fetch throws an ApiError are skipped, counted, and reported once
34
+ * after mining completes. Non-ApiError failures (e.g. socket hangups) rethrow.
35
+ * @param {ReturnType<import('./client.js').createClient>} client
36
+ * @param {object[]} deals deals to mine (id + current stage_id needed per deal)
37
+ * @param {{ limit?: number, warnThreshold?: number, costPerRequest?: number }} [options]
38
+ * @returns {Promise<{ dealId: number, stageId: number, rows: object[] }[]>}
39
+ */
40
+ export async function mineMany(
41
+ client,
42
+ deals,
43
+ {
44
+ limit,
45
+ warnThreshold = MINE_WARN_THRESHOLD,
46
+ costPerRequest = CHANGELOG_COST,
47
+ } = {},
48
+ ) {
49
+ if (deals.length > warnThreshold) {
50
+ process.stderr.write(
51
+ `Mining stage history for ${deals.length} deals ` +
52
+ `(~${deals.length} requests, ${costPerRequest} tokens each); ` +
53
+ `rate limiting may slow this down.\n`,
54
+ )
55
+ }
56
+
57
+ const transitionsByDeal = []
58
+ let skipped = 0
59
+ for (const deal of deals) {
60
+ try {
61
+ const rows = await fetchChangelog(client, deal.id, { limit })
62
+ transitionsByDeal.push({
63
+ dealId: deal.id,
64
+ stageId: deal.stage_id,
65
+ rows,
66
+ })
67
+ } catch (err) {
68
+ // One bad changelog request must not abort the whole mine: skip the
69
+ // deal, count it, and warn once after mining completes.
70
+ if (err instanceof ApiError) {
71
+ skipped++
72
+ continue
73
+ }
74
+ throw err
75
+ }
76
+ }
77
+
78
+ if (skipped > 0) {
79
+ process.stderr.write(
80
+ `skipped ${skipped} deal(s) whose changelog could not be fetched\n`,
81
+ )
82
+ }
83
+
84
+ return transitionsByDeal
85
+ }
package/src/lib/client.js CHANGED
@@ -177,6 +177,21 @@ export function createClient({
177
177
  debug('%s %s → %d', method, path, res.status)
178
178
 
179
179
  if (res.status === 429) {
180
+ // Daily-budget exhaustion has no useful reset window — backoff would
181
+ // stall until the daily reset. Fail fast with an actionable message.
182
+ // The live API reports the token budget as
183
+ // x-daily-ratelimit-token-remaining (verified on the sandbox);
184
+ // x-daily-requests-left is the older POST/PUT fair-use header.
185
+ const dailyRemaining =
186
+ res.headers.get('x-daily-ratelimit-token-remaining') ??
187
+ res.headers.get('x-daily-requests-left')
188
+ if (dailyRemaining === '0') {
189
+ const err = new RateLimitError(0)
190
+ err.message =
191
+ 'Daily API token budget exhausted — resets at midnight server ' +
192
+ 'time (UTC-based; may differ from your local timezone)'
193
+ throw err
194
+ }
180
195
  const wait = Number(
181
196
  res.headers.get('x-ratelimit-reset') ||
182
197
  res.headers.get('retry-after') ||
@@ -189,6 +204,16 @@ export function createClient({
189
204
  continue
190
205
  }
191
206
 
207
+ // Surface the remaining daily budget under --verbose (DEBUG=pd:*).
208
+ const dailyLeft = res.headers.get('x-daily-ratelimit-token-remaining')
209
+ if (dailyLeft != null) {
210
+ debug(
211
+ 'daily token budget: %s remaining of %s',
212
+ dailyLeft,
213
+ res.headers.get('x-daily-ratelimit-token-limit') ?? '?',
214
+ )
215
+ }
216
+
192
217
  // OAuth access tokens expire (~1h) — refresh once and retry.
193
218
  if (res.status === 401 && onRefresh && attempts === 1) {
194
219
  debug('401, attempting OAuth token refresh')