@wavyx/pdcli 0.8.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.
- package/CHANGELOG.md +67 -0
- package/README.md +9 -0
- package/oclif.manifest.json +1960 -706
- package/package.json +4 -1
- package/src/base-command.js +22 -3
- package/src/commands/activity/list.js +48 -6
- package/src/commands/deal/history.js +73 -0
- package/src/commands/deal/list.js +41 -3
- package/src/commands/deal/product/add.js +69 -0
- package/src/commands/deal/product/list.js +56 -0
- package/src/commands/deal/product/remove.js +52 -0
- package/src/commands/deal/product/update.js +78 -0
- package/src/commands/funnel.js +7 -49
- package/src/commands/org/list.js +41 -3
- package/src/commands/person/list.js +41 -3
- package/src/commands/product/list.js +36 -3
- package/src/commands/user/find.js +50 -0
- package/src/commands/user/list.js +36 -0
- package/src/commands/webhook/create.js +8 -2
- package/src/lib/aliases.js +85 -5
- package/src/lib/changelog.js +85 -0
- package/src/lib/client.js +135 -117
package/src/lib/client.js
CHANGED
|
@@ -79,6 +79,87 @@ export function createClient({
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
return transport(method, url, {
|
|
83
|
+
path,
|
|
84
|
+
makeBody: body ? () => JSON.stringify(body) : undefined,
|
|
85
|
+
extraHeaders: { 'content-type': 'application/json' },
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* v2 cursor pagination: cursor/limit → additional_data.next_cursor.
|
|
91
|
+
* @param {string} path
|
|
92
|
+
* @param {object} [query]
|
|
93
|
+
* @returns {AsyncGenerator<object>}
|
|
94
|
+
*/
|
|
95
|
+
async function* pageV2(path, query = {}) {
|
|
96
|
+
let cursor
|
|
97
|
+
const baseQuery = clampLimit(query)
|
|
98
|
+
while (true) {
|
|
99
|
+
const envelope = await request('GET', path, {
|
|
100
|
+
query: cursor ? { ...baseQuery, cursor } : baseQuery,
|
|
101
|
+
})
|
|
102
|
+
yield* envelope?.data ?? []
|
|
103
|
+
cursor = envelope?.additional_data?.next_cursor
|
|
104
|
+
if (!cursor) break
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* v1 offset pagination: start/limit →
|
|
110
|
+
* additional_data.pagination.{more_items_in_collection,next_start}.
|
|
111
|
+
* @param {string} path
|
|
112
|
+
* @param {object} [query]
|
|
113
|
+
* @returns {AsyncGenerator<object>}
|
|
114
|
+
*/
|
|
115
|
+
async function* pageV1(path, query = {}) {
|
|
116
|
+
let start
|
|
117
|
+
const baseQuery = clampLimit(query)
|
|
118
|
+
while (true) {
|
|
119
|
+
const envelope = await request('GET', path, {
|
|
120
|
+
query: start != null ? { ...baseQuery, start } : baseQuery,
|
|
121
|
+
})
|
|
122
|
+
yield* envelope?.data ?? []
|
|
123
|
+
const pagination = envelope?.additional_data?.pagination
|
|
124
|
+
if (!pagination?.more_items_in_collection) break
|
|
125
|
+
start = pagination.next_start
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function lockedUrl(path) {
|
|
130
|
+
const url = new URL(path, baseOrigin)
|
|
131
|
+
if (url.origin !== baseOrigin) {
|
|
132
|
+
throw new CliError(
|
|
133
|
+
`Refusing to send request outside your Pipedrive company host ` +
|
|
134
|
+
`(${baseOrigin}): ${url.origin}`,
|
|
135
|
+
{ exitCode: 78 },
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
return url
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function authHeaders() {
|
|
142
|
+
return authMode === 'oauth'
|
|
143
|
+
? { authorization: `Bearer ${token}`, 'user-agent': userAgent }
|
|
144
|
+
: { 'x-api-token': token, 'user-agent': userAgent }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Shared transport: EVERY HTTP path (JSON, form, multipart, binary) goes
|
|
149
|
+
* through the same 429 backoff (x-ratelimit-reset → Retry-After → default),
|
|
150
|
+
* 429→403 escalation hard stop, 5xx retry, OAuth refresh-once, and error
|
|
151
|
+
* mapping. `makeBody` is invoked per attempt — FormData and friends are
|
|
152
|
+
* rebuilt rather than reused across retries.
|
|
153
|
+
* @param {string} method
|
|
154
|
+
* @param {URL} url
|
|
155
|
+
* @param {{ path: string, makeBody?: () => any,
|
|
156
|
+
* extraHeaders?: Record<string, string>, binary?: boolean }} options
|
|
157
|
+
*/
|
|
158
|
+
async function transport(
|
|
159
|
+
method,
|
|
160
|
+
url,
|
|
161
|
+
{ path, makeBody, extraHeaders = {}, binary = false },
|
|
162
|
+
) {
|
|
82
163
|
const maxAttempts = retry ? 3 : 1
|
|
83
164
|
let attempts = 0
|
|
84
165
|
let sawRateLimit = false
|
|
@@ -86,26 +167,31 @@ export function createClient({
|
|
|
86
167
|
while (attempts < maxAttempts) {
|
|
87
168
|
attempts++
|
|
88
169
|
|
|
89
|
-
const headers = {
|
|
90
|
-
'content-type': 'application/json',
|
|
91
|
-
'user-agent': userAgent,
|
|
92
|
-
}
|
|
93
|
-
if (authMode === 'oauth') {
|
|
94
|
-
headers.authorization = `Bearer ${token}`
|
|
95
|
-
} else {
|
|
96
|
-
headers['x-api-token'] = token
|
|
97
|
-
}
|
|
98
|
-
|
|
99
170
|
const res = await fetch(url, {
|
|
100
171
|
method,
|
|
101
|
-
headers,
|
|
102
|
-
body:
|
|
172
|
+
headers: { ...authHeaders(), ...extraHeaders },
|
|
173
|
+
body: makeBody ? makeBody() : undefined,
|
|
103
174
|
signal: AbortSignal.timeout(timeout),
|
|
104
175
|
})
|
|
105
176
|
|
|
106
177
|
debug('%s %s → %d', method, path, res.status)
|
|
107
178
|
|
|
108
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
|
+
}
|
|
109
195
|
const wait = Number(
|
|
110
196
|
res.headers.get('x-ratelimit-reset') ||
|
|
111
197
|
res.headers.get('retry-after') ||
|
|
@@ -118,6 +204,16 @@ export function createClient({
|
|
|
118
204
|
continue
|
|
119
205
|
}
|
|
120
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
|
+
|
|
121
217
|
// OAuth access tokens expire (~1h) — refresh once and retry.
|
|
122
218
|
if (res.status === 401 && onRefresh && attempts === 1) {
|
|
123
219
|
debug('401, attempting OAuth token refresh')
|
|
@@ -141,97 +237,38 @@ export function createClient({
|
|
|
141
237
|
continue
|
|
142
238
|
}
|
|
143
239
|
|
|
240
|
+
// Binary callers always get the {buffer, contentType} shape — a 204
|
|
241
|
+
// yields an empty buffer rather than null (pre-unification parity;
|
|
242
|
+
// file/download destructures the result).
|
|
243
|
+
if (binary) {
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
throw ApiError.fromResponse(res.status, await res.text(), path)
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
buffer: await res.arrayBuffer(),
|
|
249
|
+
contentType: res.headers.get('content-type'),
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
144
253
|
if (res.status === 204) return null
|
|
145
254
|
|
|
146
255
|
const text = await res.text()
|
|
147
|
-
|
|
148
256
|
if (!res.ok) {
|
|
149
257
|
throw ApiError.fromResponse(res.status, text, path)
|
|
150
258
|
}
|
|
151
|
-
|
|
152
259
|
return text ? JSON.parse(text) : null
|
|
153
260
|
}
|
|
154
261
|
|
|
155
262
|
throw new ServiceUnavailableError()
|
|
156
263
|
}
|
|
157
264
|
|
|
158
|
-
/**
|
|
159
|
-
* v2 cursor pagination: cursor/limit → additional_data.next_cursor.
|
|
160
|
-
* @param {string} path
|
|
161
|
-
* @param {object} [query]
|
|
162
|
-
* @returns {AsyncGenerator<object>}
|
|
163
|
-
*/
|
|
164
|
-
async function* pageV2(path, query = {}) {
|
|
165
|
-
let cursor
|
|
166
|
-
const baseQuery = clampLimit(query)
|
|
167
|
-
while (true) {
|
|
168
|
-
const envelope = await request('GET', path, {
|
|
169
|
-
query: cursor ? { ...baseQuery, cursor } : baseQuery,
|
|
170
|
-
})
|
|
171
|
-
yield* envelope?.data ?? []
|
|
172
|
-
cursor = envelope?.additional_data?.next_cursor
|
|
173
|
-
if (!cursor) break
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* v1 offset pagination: start/limit →
|
|
179
|
-
* additional_data.pagination.{more_items_in_collection,next_start}.
|
|
180
|
-
* @param {string} path
|
|
181
|
-
* @param {object} [query]
|
|
182
|
-
* @returns {AsyncGenerator<object>}
|
|
183
|
-
*/
|
|
184
|
-
async function* pageV1(path, query = {}) {
|
|
185
|
-
let start
|
|
186
|
-
const baseQuery = clampLimit(query)
|
|
187
|
-
while (true) {
|
|
188
|
-
const envelope = await request('GET', path, {
|
|
189
|
-
query: start != null ? { ...baseQuery, start } : baseQuery,
|
|
190
|
-
})
|
|
191
|
-
yield* envelope?.data ?? []
|
|
192
|
-
const pagination = envelope?.additional_data?.pagination
|
|
193
|
-
if (!pagination?.more_items_in_collection) break
|
|
194
|
-
start = pagination.next_start
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function lockedUrl(path) {
|
|
199
|
-
const url = new URL(path, baseOrigin)
|
|
200
|
-
if (url.origin !== baseOrigin) {
|
|
201
|
-
throw new CliError(
|
|
202
|
-
`Refusing to send request outside your Pipedrive company host ` +
|
|
203
|
-
`(${baseOrigin}): ${url.origin}`,
|
|
204
|
-
{ exitCode: 78 },
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
return url
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function authHeaders() {
|
|
211
|
-
return authMode === 'oauth'
|
|
212
|
-
? { authorization: `Bearer ${token}`, 'user-agent': userAgent }
|
|
213
|
-
: { 'x-api-token': token, 'user-agent': userAgent }
|
|
214
|
-
}
|
|
215
|
-
|
|
216
265
|
/**
|
|
217
266
|
* Download a binary resource (e.g. /api/v1/files/:id/download).
|
|
218
267
|
* @param {string} path
|
|
219
268
|
* @returns {Promise<{buffer: ArrayBuffer, contentType: string | null}>}
|
|
220
269
|
*/
|
|
221
270
|
async function download(path) {
|
|
222
|
-
|
|
223
|
-
const res = await fetch(url, {
|
|
224
|
-
headers: authHeaders(),
|
|
225
|
-
signal: AbortSignal.timeout(timeout),
|
|
226
|
-
})
|
|
227
|
-
debug('GET (binary) %s → %d', path, res.status)
|
|
228
|
-
if (!res.ok) {
|
|
229
|
-
throw ApiError.fromResponse(res.status, await res.text(), path)
|
|
230
|
-
}
|
|
231
|
-
return {
|
|
232
|
-
buffer: await res.arrayBuffer(),
|
|
233
|
-
contentType: res.headers.get('content-type'),
|
|
234
|
-
}
|
|
271
|
+
return transport('GET', lockedUrl(path), { path, binary: true })
|
|
235
272
|
}
|
|
236
273
|
|
|
237
274
|
/**
|
|
@@ -240,24 +277,16 @@ export function createClient({
|
|
|
240
277
|
* @param {{ file: { name: string, data: Buffer | Uint8Array }, fields?: Record<string, unknown> }} options
|
|
241
278
|
*/
|
|
242
279
|
async function postMultipart(path, { file, fields = {} }) {
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
headers: authHeaders(), // fetch sets the multipart boundary itself
|
|
252
|
-
body: form,
|
|
253
|
-
signal: AbortSignal.timeout(timeout),
|
|
254
|
-
})
|
|
255
|
-
debug('POST (multipart) %s → %d', path, res.status)
|
|
256
|
-
const text = await res.text()
|
|
257
|
-
if (!res.ok) {
|
|
258
|
-
throw ApiError.fromResponse(res.status, text, path)
|
|
280
|
+
// fetch sets the multipart boundary itself — no content-type override.
|
|
281
|
+
const makeBody = () => {
|
|
282
|
+
const form = new FormData()
|
|
283
|
+
form.set('file', new Blob([file.data]), file.name)
|
|
284
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
285
|
+
if (v != null) form.set(k, String(v))
|
|
286
|
+
}
|
|
287
|
+
return form
|
|
259
288
|
}
|
|
260
|
-
return
|
|
289
|
+
return transport('POST', lockedUrl(path), { path, makeBody })
|
|
261
290
|
}
|
|
262
291
|
|
|
263
292
|
/**
|
|
@@ -267,26 +296,15 @@ export function createClient({
|
|
|
267
296
|
* @param {Record<string, unknown>} fields Null/undefined values are omitted.
|
|
268
297
|
*/
|
|
269
298
|
async function postForm(path, fields = {}) {
|
|
270
|
-
const url = lockedUrl(path)
|
|
271
299
|
const params = new URLSearchParams()
|
|
272
300
|
for (const [k, v] of Object.entries(fields)) {
|
|
273
301
|
if (v != null) params.set(k, String(v))
|
|
274
302
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
'content-type': 'application/x-www-form-urlencoded',
|
|
280
|
-
},
|
|
281
|
-
body: params.toString(),
|
|
282
|
-
signal: AbortSignal.timeout(timeout),
|
|
303
|
+
return transport('POST', lockedUrl(path), {
|
|
304
|
+
path,
|
|
305
|
+
makeBody: () => params.toString(),
|
|
306
|
+
extraHeaders: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
283
307
|
})
|
|
284
|
-
debug('POST (form) %s → %d', path, res.status)
|
|
285
|
-
const text = await res.text()
|
|
286
|
-
if (!res.ok) {
|
|
287
|
-
throw ApiError.fromResponse(res.status, text, path)
|
|
288
|
-
}
|
|
289
|
-
return text ? JSON.parse(text) : null
|
|
290
308
|
}
|
|
291
309
|
|
|
292
310
|
return {
|