@wavyx/pdcli 0.1.0 → 0.3.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +60 -11
  3. package/oclif.manifest.json +7101 -354
  4. package/package.json +4 -1
  5. package/src/base-command.js +65 -7
  6. package/src/commands/activity/create.js +63 -0
  7. package/src/commands/activity/delete.js +39 -0
  8. package/src/commands/activity/get.js +2 -17
  9. package/src/commands/activity/update.js +89 -0
  10. package/src/commands/api.js +6 -2
  11. package/src/commands/auth/login.js +102 -7
  12. package/src/commands/auth/logout.js +2 -1
  13. package/src/commands/auth/status.js +61 -13
  14. package/src/commands/backup.js +53 -0
  15. package/src/commands/deal/create.js +65 -0
  16. package/src/commands/deal/delete.js +39 -0
  17. package/src/commands/deal/get.js +2 -19
  18. package/src/commands/deal/update.js +79 -0
  19. package/src/commands/file/download.js +35 -0
  20. package/src/commands/file/get.js +26 -0
  21. package/src/commands/file/list.js +40 -0
  22. package/src/commands/file/upload.js +42 -0
  23. package/src/commands/filter/get.js +26 -0
  24. package/src/commands/filter/list.js +43 -0
  25. package/src/commands/goal/list.js +37 -0
  26. package/src/commands/lead/create.js +58 -0
  27. package/src/commands/lead/delete.js +39 -0
  28. package/src/commands/lead/get.js +26 -0
  29. package/src/commands/lead/list.js +50 -0
  30. package/src/commands/lead/update.js +71 -0
  31. package/src/commands/note/create.js +42 -0
  32. package/src/commands/note/delete.js +39 -0
  33. package/src/commands/note/get.js +26 -0
  34. package/src/commands/note/list.js +49 -0
  35. package/src/commands/note/update.js +45 -0
  36. package/src/commands/org/create.js +42 -0
  37. package/src/commands/org/delete.js +39 -0
  38. package/src/commands/org/get.js +2 -17
  39. package/src/commands/org/update.js +57 -0
  40. package/src/commands/person/create.js +54 -0
  41. package/src/commands/person/delete.js +39 -0
  42. package/src/commands/person/get.js +2 -17
  43. package/src/commands/person/update.js +69 -0
  44. package/src/commands/pipeline/get.js +26 -0
  45. package/src/commands/pipeline/list.js +37 -0
  46. package/src/commands/product/create.js +62 -0
  47. package/src/commands/product/delete.js +39 -0
  48. package/src/commands/product/get.js +26 -0
  49. package/src/commands/product/list.js +45 -0
  50. package/src/commands/product/update.js +77 -0
  51. package/src/commands/project/create.js +48 -0
  52. package/src/commands/project/delete.js +39 -0
  53. package/src/commands/project/get.js +26 -0
  54. package/src/commands/project/list.js +39 -0
  55. package/src/commands/project/update.js +63 -0
  56. package/src/commands/stage/get.js +26 -0
  57. package/src/commands/stage/list.js +41 -0
  58. package/src/commands/webhook/create.js +75 -0
  59. package/src/commands/webhook/delete.js +39 -0
  60. package/src/commands/webhook/list.js +33 -0
  61. package/src/lib/auth.js +227 -3
  62. package/src/lib/backup.js +122 -0
  63. package/src/lib/client.js +96 -6
  64. package/src/lib/confirm.js +10 -0
  65. package/src/lib/entity-view.js +42 -0
  66. package/src/lib/input.js +110 -0
  67. package/src/lib/keychain.js +47 -0
  68. package/src/lib/output/csv.js +26 -0
  69. package/src/lib/output/index.js +9 -1
  70. package/src/lib/output/yaml.js +9 -0
@@ -0,0 +1,75 @@
1
+ import { Flags } from '@oclif/core'
2
+ import BaseCommand from '../../base-command.js'
3
+ import { buildWriteBody } from '../../lib/input.js'
4
+ import { outputRecord } from '../../lib/entity-view.js'
5
+
6
+ export default class WebhookCreateCommand extends BaseCommand {
7
+ static description = 'Create a webhook'
8
+
9
+ static examples = [
10
+ '<%= config.bin %> webhook create --url https://example.com/hook --event-action change --event-object deal',
11
+ '<%= config.bin %> webhook create --url https://example.com/hook --event-action "*" --event-object "*"',
12
+ ]
13
+
14
+ static flags = {
15
+ ...BaseCommand.baseFlags,
16
+ url: Flags.string({
17
+ required: true,
18
+ description: 'Webhook subscription URL',
19
+ }),
20
+ 'event-action': Flags.string({
21
+ required: true,
22
+ description: 'Event action to subscribe to',
23
+ options: ['create', 'change', 'delete', '*'],
24
+ }),
25
+ 'event-object': Flags.string({
26
+ required: true,
27
+ description: 'Event object to subscribe to',
28
+ options: [
29
+ 'activity',
30
+ 'deal',
31
+ 'lead',
32
+ 'note',
33
+ 'organization',
34
+ 'person',
35
+ 'product',
36
+ 'user',
37
+ 'pipeline',
38
+ 'stage',
39
+ '*',
40
+ ],
41
+ }),
42
+ name: Flags.string({ description: 'Webhook name' }),
43
+ version: Flags.string({
44
+ description: 'Webhook payload version',
45
+ default: '2.0',
46
+ }),
47
+ 'http-auth-user': Flags.string({
48
+ description: 'HTTP basic auth username for the endpoint',
49
+ dependsOn: ['http-auth-password'],
50
+ }),
51
+ 'http-auth-password': Flags.string({
52
+ description: 'HTTP basic auth password for the endpoint',
53
+ dependsOn: ['http-auth-user'],
54
+ }),
55
+ }
56
+
57
+ async run() {
58
+ const { flags } = await this.parse(WebhookCreateCommand)
59
+
60
+ const body = buildWriteBody({
61
+ typed: {
62
+ subscription_url: flags.url,
63
+ event_action: flags['event-action'],
64
+ event_object: flags['event-object'],
65
+ version: flags.version,
66
+ name: flags.name,
67
+ http_auth_user: flags['http-auth-user'],
68
+ http_auth_password: flags['http-auth-password'],
69
+ },
70
+ })
71
+
72
+ const res = await this.apiClient.post('/api/v1/webhooks', { body })
73
+ await outputRecord(this, res.data)
74
+ }
75
+ }
@@ -0,0 +1,39 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import chalk from 'chalk'
3
+ import BaseCommand from '../../base-command.js'
4
+ import { confirmAction } from '../../lib/confirm.js'
5
+ import { CliError } from '../../lib/errors.js'
6
+
7
+ export default class WebhookDeleteCommand extends BaseCommand {
8
+ static description = 'Delete a webhook'
9
+
10
+ static examples = [
11
+ '<%= config.bin %> webhook delete 3',
12
+ '<%= config.bin %> webhook delete 3 --yes',
13
+ ]
14
+
15
+ static args = {
16
+ id: Args.integer({ required: true, description: 'Webhook ID' }),
17
+ }
18
+
19
+ static flags = {
20
+ ...BaseCommand.baseFlags,
21
+ yes: Flags.boolean({
22
+ char: 'y',
23
+ description: 'Skip the confirmation prompt',
24
+ default: false,
25
+ }),
26
+ }
27
+
28
+ async run() {
29
+ const { args, flags } = await this.parse(WebhookDeleteCommand)
30
+
31
+ const ok = await confirmAction(`Delete webhook ${args.id}?`, flags.yes)
32
+ if (!ok) {
33
+ throw new CliError('Aborted', { exitCode: 1 })
34
+ }
35
+
36
+ await this.apiClient.del(`/api/v1/webhooks/${args.id}`)
37
+ this.log(chalk.green(`Deleted webhook ${args.id}`))
38
+ }
39
+ }
@@ -0,0 +1,33 @@
1
+ import BaseCommand from '../../base-command.js'
2
+
3
+ const columns = {
4
+ id: { header: 'ID' },
5
+ subscription_url: { header: 'URL' },
6
+ event_action: { header: 'Action' },
7
+ event_object: { header: 'Object' },
8
+ version: { header: 'Version' },
9
+ is_active: {
10
+ header: 'Active',
11
+ get: (row) => row.is_active ?? row.active_flag ?? '',
12
+ },
13
+ }
14
+
15
+ export default class WebhookListCommand extends BaseCommand {
16
+ static description = 'List webhooks'
17
+
18
+ static examples = [
19
+ '<%= config.bin %> webhook list',
20
+ '<%= config.bin %> webhook list --output json',
21
+ ]
22
+
23
+ static flags = {
24
+ ...BaseCommand.baseFlags,
25
+ }
26
+
27
+ async run() {
28
+ await this.parse(WebhookListCommand)
29
+
30
+ const body = await this.apiClient.get('/api/v1/webhooks')
31
+ await this.outputResults(body.data, columns)
32
+ }
33
+ }
package/src/lib/auth.js CHANGED
@@ -1,10 +1,18 @@
1
+ import { createServer } from 'node:http'
2
+ import { randomBytes } from 'node:crypto'
1
3
  import createDebug from 'debug'
2
- import { getToken } from './keychain.js'
4
+ import open from 'open'
5
+ import { getToken, getOAuthTokens, setOAuthTokens } from './keychain.js'
3
6
  import { getProfileConfig } from './config.js'
4
- import { AuthRequiredError, ConfigError } from './errors.js'
7
+ import { ApiError, AuthRequiredError, CliError, ConfigError } from './errors.js'
5
8
 
6
9
  const debug = createDebug('pd:auth')
7
10
 
11
+ const AUTH_URL = 'https://oauth.pipedrive.com/oauth/authorize'
12
+ const TOKEN_URL = 'https://oauth.pipedrive.com/oauth/token'
13
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000
14
+ const REDIRECT_PORT = 9999
15
+
8
16
  /**
9
17
  * Normalize user input ("acme", "acme.pipedrive.com",
10
18
  * "https://acme.pipedrive.com/") to the bare company subdomain.
@@ -44,6 +52,26 @@ export function companyDomainToBaseOrigin(companyDomain) {
44
52
  * @returns {Promise<ResolvedCredentials>}
45
53
  */
46
54
  export async function resolveCredentials({ flags, profile } = {}) {
55
+ // Explicit token via flags/env always wins. Otherwise a profile in OAuth
56
+ // mode resolves to its (auto-refreshed) access token + api_domain.
57
+ const explicitToken = flags?.['api-token'] || process.env.PDCLI_API_TOKEN
58
+ if (
59
+ !explicitToken &&
60
+ profile &&
61
+ getProfileConfig(profile, 'auth_mode') === 'oauth'
62
+ ) {
63
+ const access = await getValidOAuthAccess(profile)
64
+ if (!access) throw new AuthRequiredError()
65
+ debug('resolved OAuth credentials for %s', access.apiDomain)
66
+ return {
67
+ mode: 'oauth',
68
+ apiDomain: access.apiDomain,
69
+ token: access.accessToken,
70
+ oauth: access,
71
+ source: 'profile',
72
+ }
73
+ }
74
+
47
75
  let companyDomain
48
76
  if (flags?.company) {
49
77
  companyDomain = flags.company
@@ -79,7 +107,203 @@ export async function resolveCredentials({ flags, profile } = {}) {
79
107
  }
80
108
 
81
109
  debug('resolved credentials for %s (token from %s)', companyDomain, source)
82
- return { companyDomain, token, source }
110
+ return { mode: 'token', companyDomain, token, source }
111
+ }
112
+
113
+ /**
114
+ * Exchange an authorization code for tokens. Pipedrive authenticates the
115
+ * token endpoint with HTTP Basic (client_id:client_secret) and returns the
116
+ * account's api_domain, which becomes the API base URL.
117
+ * @param {{ code: string, clientId: string, clientSecret: string, redirectUri: string }} options
118
+ */
119
+ export async function exchangeCode({
120
+ code,
121
+ clientId,
122
+ clientSecret,
123
+ redirectUri,
124
+ }) {
125
+ return tokenRequest(
126
+ { grant_type: 'authorization_code', code, redirect_uri: redirectUri },
127
+ { clientId, clientSecret },
128
+ )
129
+ }
130
+
131
+ /**
132
+ * @param {{ refreshToken: string, clientId: string, clientSecret: string }} options
133
+ */
134
+ export async function refreshAccessToken({
135
+ refreshToken,
136
+ clientId,
137
+ clientSecret,
138
+ }) {
139
+ debug('refreshing access token')
140
+ return tokenRequest(
141
+ { grant_type: 'refresh_token', refresh_token: refreshToken },
142
+ { clientId, clientSecret },
143
+ )
144
+ }
145
+
146
+ async function tokenRequest(params, { clientId, clientSecret }) {
147
+ const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
148
+ const res = await fetch(TOKEN_URL, {
149
+ method: 'POST',
150
+ headers: {
151
+ authorization: `Basic ${basic}`,
152
+ 'content-type': 'application/x-www-form-urlencoded',
153
+ },
154
+ body: new URLSearchParams(params),
155
+ })
156
+
157
+ const body = await res.json()
158
+ if (!res.ok) {
159
+ throw ApiError.fromResponse(res.status, JSON.stringify(body), TOKEN_URL)
160
+ }
161
+
162
+ return {
163
+ accessToken: body.access_token,
164
+ refreshToken: body.refresh_token,
165
+ expiresIn: body.expires_in,
166
+ apiDomain: body.api_domain,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Browser-based OAuth authorization-code flow via a local loopback server.
172
+ * @param {object} options
173
+ * @param {string} options.clientId
174
+ * @param {string} options.clientSecret
175
+ * @param {number} [options.timeout]
176
+ * @param {number} [options.port] Loopback callback port (must match the
177
+ * callback URL registered in the Pipedrive Developer Hub app). Default 9999.
178
+ * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number, apiDomain: string}>}
179
+ */
180
+ export function authorizationCodeFlow({
181
+ clientId,
182
+ clientSecret,
183
+ timeout = 120_000,
184
+ port = REDIRECT_PORT,
185
+ }) {
186
+ return new Promise((resolve, reject) => {
187
+ const state = randomBytes(16).toString('hex')
188
+ let timer
189
+
190
+ const server = createServer(async (req, res) => {
191
+ const url = new URL(req.url, `http://${req.headers.host}`)
192
+ if (url.pathname !== '/callback') {
193
+ res.writeHead(404)
194
+ res.end()
195
+ return
196
+ }
197
+
198
+ const code = url.searchParams.get('code')
199
+ const returnedState = url.searchParams.get('state')
200
+
201
+ if (returnedState !== state) {
202
+ res.writeHead(400, { 'content-type': 'text/html' })
203
+ res.end(
204
+ '<h2>Authentication failed: state mismatch (possible CSRF attack)</h2>',
205
+ )
206
+ clearTimeout(timer)
207
+ server.close()
208
+ reject(
209
+ new CliError('OAuth state mismatch — possible CSRF attack', {
210
+ exitCode: 77,
211
+ }),
212
+ )
213
+ return
214
+ }
215
+
216
+ if (!code) {
217
+ res.writeHead(400, { 'content-type': 'text/html' })
218
+ res.end(
219
+ '<h2>Authentication failed: no authorization code received</h2>',
220
+ )
221
+ clearTimeout(timer)
222
+ server.close()
223
+ reject(new CliError('No authorization code received', { exitCode: 77 }))
224
+ return
225
+ }
226
+
227
+ try {
228
+ const tokens = await exchangeCode({
229
+ code,
230
+ clientId,
231
+ clientSecret,
232
+ redirectUri: `http://127.0.0.1:${server.address().port}/callback`,
233
+ })
234
+ res.writeHead(200, { 'content-type': 'text/html' })
235
+ res.end('<h2>Authenticated! You can close this window.</h2>')
236
+ clearTimeout(timer)
237
+ server.close()
238
+ resolve(tokens)
239
+ } catch (err) {
240
+ res.writeHead(500, { 'content-type': 'text/html' })
241
+ res.end(`<h2>Authentication failed: ${err.message}</h2>`)
242
+ clearTimeout(timer)
243
+ server.close()
244
+ reject(err)
245
+ }
246
+ })
247
+
248
+ server.on('error', (err) => {
249
+ clearTimeout(timer)
250
+ reject(
251
+ new CliError(
252
+ `Cannot start the local callback server on port ${port}: ${err.message}. ` +
253
+ 'If the port is in use, close whatever is using it and run `pdcli auth login --oauth` again.',
254
+ { exitCode: 78 },
255
+ ),
256
+ )
257
+ })
258
+
259
+ server.listen(port, '127.0.0.1', () => {
260
+ const boundPort = server.address().port
261
+ const redirectUri = `http://127.0.0.1:${boundPort}/callback`
262
+ const authUrl = `${AUTH_URL}?client_id=${encodeURIComponent(clientId)}&state=${state}&redirect_uri=${encodeURIComponent(redirectUri)}`
263
+ debug('opening browser for auth: %s', authUrl)
264
+ open(authUrl)
265
+ })
266
+
267
+ timer = setTimeout(() => {
268
+ server.close()
269
+ reject(
270
+ new CliError(
271
+ `Authentication timed out after ${timeout / 1000}s. Try again.`,
272
+ { exitCode: 77 },
273
+ ),
274
+ )
275
+ }, timeout)
276
+ })
277
+ }
278
+
279
+ /**
280
+ * Return a valid OAuth access token for the profile, transparently
281
+ * refreshing (and persisting) when within the expiry buffer.
282
+ * @param {string} profile
283
+ * @returns {Promise<import('./keychain.js').OAuthTokens | null>}
284
+ */
285
+ export async function getValidOAuthAccess(profile) {
286
+ const tokens = await getOAuthTokens(profile)
287
+ if (!tokens) return null
288
+
289
+ const now = Date.now()
290
+ if (tokens.expiresAt - now > REFRESH_BUFFER_MS) {
291
+ return tokens
292
+ }
293
+
294
+ const refreshed = await refreshAccessToken({
295
+ refreshToken: tokens.refreshToken,
296
+ clientId: tokens.clientId,
297
+ clientSecret: tokens.clientSecret,
298
+ })
299
+ const updated = {
300
+ ...tokens,
301
+ accessToken: refreshed.accessToken,
302
+ refreshToken: refreshed.refreshToken,
303
+ expiresAt: now + refreshed.expiresIn * 1000,
304
+ }
305
+ await setOAuthTokens(profile, updated)
306
+ return updated
83
307
  }
84
308
 
85
309
  /**
@@ -0,0 +1,122 @@
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import createDebug from 'debug'
4
+
5
+ const debug = createDebug('pd:backup')
6
+
7
+ /**
8
+ * Everything a full-account export covers. Sequential fetching keeps the
9
+ * token budget predictable (each list page costs 20 tokens); the client's
10
+ * 429 backoff handles bursts.
11
+ * @type {{ name: string, path: string, pager: 'v1' | 'v2' | 'plain' }[]}
12
+ */
13
+ export const BACKUP_RESOURCES = [
14
+ { name: 'deals', path: '/api/v2/deals', pager: 'v2' },
15
+ { name: 'persons', path: '/api/v2/persons', pager: 'v2' },
16
+ { name: 'organizations', path: '/api/v2/organizations', pager: 'v2' },
17
+ { name: 'activities', path: '/api/v2/activities', pager: 'v2' },
18
+ { name: 'products', path: '/api/v2/products', pager: 'v2' },
19
+ { name: 'pipelines', path: '/api/v2/pipelines', pager: 'v2' },
20
+ { name: 'stages', path: '/api/v2/stages', pager: 'v2' },
21
+ { name: 'dealFields', path: '/api/v2/dealFields', pager: 'v2' },
22
+ { name: 'personFields', path: '/api/v2/personFields', pager: 'v2' },
23
+ {
24
+ name: 'organizationFields',
25
+ path: '/api/v2/organizationFields',
26
+ pager: 'v2',
27
+ },
28
+ { name: 'productFields', path: '/api/v2/productFields', pager: 'v2' },
29
+ { name: 'activityFields', path: '/api/v2/activityFields', pager: 'v2' },
30
+ { name: 'leads', path: '/api/v1/leads', pager: 'v1' },
31
+ { name: 'notes', path: '/api/v1/notes', pager: 'v1' },
32
+ { name: 'users', path: '/api/v1/users', pager: 'plain' },
33
+ { name: 'filters', path: '/api/v1/filters', pager: 'plain' },
34
+ { name: 'webhooks', path: '/api/v1/webhooks', pager: 'plain' },
35
+ { name: 'currencies', path: '/api/v1/currencies', pager: 'plain' },
36
+ ]
37
+
38
+ const MANIFEST = 'manifest.json'
39
+
40
+ function readManifest(dir) {
41
+ const file = join(dir, MANIFEST)
42
+ if (!existsSync(file)) return { completed: [], counts: {} }
43
+ try {
44
+ return JSON.parse(readFileSync(file, 'utf8'))
45
+ } catch {
46
+ return { completed: [], counts: {} }
47
+ }
48
+ }
49
+
50
+ function writeManifest(dir, manifest) {
51
+ writeFileSync(join(dir, MANIFEST), JSON.stringify(manifest, null, 2))
52
+ }
53
+
54
+ async function fetchResource(client, resource) {
55
+ if (resource.pager === 'v2') {
56
+ const items = []
57
+ for await (const item of client.pageV2(resource.path, { limit: 500 })) {
58
+ items.push(item)
59
+ }
60
+ return items
61
+ }
62
+ if (resource.pager === 'v1') {
63
+ const items = []
64
+ for await (const item of client.pageV1(resource.path, { limit: 500 })) {
65
+ items.push(item)
66
+ }
67
+ return items
68
+ }
69
+ const body = await client.get(resource.path)
70
+ return body?.data ?? []
71
+ }
72
+
73
+ /**
74
+ * Export the whole account to a JSON tree, one file per resource, with a
75
+ * manifest checkpoint after each resource so interrupted runs can --resume.
76
+ * @param {ReturnType<import('./client.js').createClient>} client
77
+ * @param {string} dir target directory (created if missing)
78
+ * @param {object} [options]
79
+ * @param {boolean} [options.resume] skip resources already in the manifest
80
+ * @param {(resource: string, count: number) => void} [options.onProgress]
81
+ * @returns {Promise<{ total: number, exported: number, skipped: number, counts: Record<string, number> }>}
82
+ */
83
+ export async function runBackup(client, dir, { resume, onProgress } = {}) {
84
+ mkdirSync(dir, { recursive: true })
85
+
86
+ const manifest = resume
87
+ ? readManifest(dir)
88
+ : { started_at: new Date().toISOString(), completed: [], counts: {} }
89
+
90
+ let exported = 0
91
+ let skipped = 0
92
+
93
+ for (const resource of BACKUP_RESOURCES) {
94
+ if (resume && manifest.completed.includes(resource.name)) {
95
+ debug('skip %s (already in manifest)', resource.name)
96
+ skipped++
97
+ continue
98
+ }
99
+
100
+ debug('exporting %s', resource.name)
101
+ const items = await fetchResource(client, resource)
102
+ writeFileSync(
103
+ join(dir, `${resource.name}.json`),
104
+ JSON.stringify(items, null, 2),
105
+ )
106
+
107
+ manifest.completed.push(resource.name)
108
+ manifest.counts[resource.name] = items.length
109
+ manifest.updated_at = new Date().toISOString()
110
+ writeManifest(dir, manifest)
111
+
112
+ exported++
113
+ onProgress?.(resource.name, items.length)
114
+ }
115
+
116
+ return {
117
+ total: BACKUP_RESOURCES.length,
118
+ exported,
119
+ skipped,
120
+ counts: manifest.counts,
121
+ }
122
+ }
package/src/lib/client.js CHANGED
@@ -31,21 +31,33 @@ function clampLimit(query) {
31
31
 
32
32
  /**
33
33
  * @param {object} options
34
- * @param {string} options.companyDomain Bare subdomain ("acme") — forms and
35
- * locks the base origin https://acme.pipedrive.com.
36
- * @param {string} options.token Personal API token (sent as x-api-token).
34
+ * @param {string} [options.companyDomain] Bare subdomain ("acme") — forms and
35
+ * locks the base origin https://acme.pipedrive.com (token mode).
36
+ * @param {string} [options.apiDomain] Full origin from the OAuth token
37
+ * response (e.g. https://acme.pipedrive.com) — used and locked in OAuth mode.
38
+ * @param {string} options.token Personal API token (x-api-token header) or
39
+ * OAuth access token (Authorization: Bearer) depending on authMode.
40
+ * @param {'token' | 'oauth'} [options.authMode]
41
+ * @param {() => Promise<string>} [options.onRefresh] OAuth-mode callback
42
+ * invoked once on a 401; returns a fresh access token to retry with.
37
43
  * @param {number} [options.timeout]
38
44
  * @param {boolean} [options.retry]
39
45
  * @param {string} [options.userAgent]
40
46
  */
41
47
  export function createClient({
42
48
  companyDomain,
43
- token,
49
+ apiDomain,
50
+ token: initialToken,
51
+ authMode = 'token',
52
+ onRefresh,
44
53
  timeout = 30_000,
45
54
  retry = true,
46
55
  userAgent = 'pdcli',
47
56
  }) {
48
- const baseOrigin = companyDomainToBaseOrigin(companyDomain)
57
+ const baseOrigin = apiDomain
58
+ ? new URL(apiDomain).origin
59
+ : companyDomainToBaseOrigin(companyDomain)
60
+ let token = initialToken
49
61
 
50
62
  async function request(method, path, { body, query } = {}) {
51
63
  const url = new URL(path, baseOrigin)
@@ -75,10 +87,14 @@ export function createClient({
75
87
  attempts++
76
88
 
77
89
  const headers = {
78
- 'x-api-token': token,
79
90
  'content-type': 'application/json',
80
91
  'user-agent': userAgent,
81
92
  }
93
+ if (authMode === 'oauth') {
94
+ headers.authorization = `Bearer ${token}`
95
+ } else {
96
+ headers['x-api-token'] = token
97
+ }
82
98
 
83
99
  const res = await fetch(url, {
84
100
  method,
@@ -102,6 +118,13 @@ export function createClient({
102
118
  continue
103
119
  }
104
120
 
121
+ // OAuth access tokens expire (~1h) — refresh once and retry.
122
+ if (res.status === 401 && onRefresh && attempts === 1) {
123
+ debug('401, attempting OAuth token refresh')
124
+ token = await onRefresh()
125
+ continue
126
+ }
127
+
105
128
  // Pipedrive escalates persistent rate-limit abuse from 429 to 403 —
106
129
  // treat that as a hard stop, never retry into it.
107
130
  if (res.status === 403 && sawRateLimit) {
@@ -172,12 +195,79 @@ export function createClient({
172
195
  }
173
196
  }
174
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
+ /**
217
+ * Download a binary resource (e.g. /api/v1/files/:id/download).
218
+ * @param {string} path
219
+ * @returns {Promise<{buffer: ArrayBuffer, contentType: string | null}>}
220
+ */
221
+ async function download(path) {
222
+ const url = lockedUrl(path)
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
+ }
235
+ }
236
+
237
+ /**
238
+ * POST multipart/form-data (file uploads — v1 files API).
239
+ * @param {string} path
240
+ * @param {{ file: { name: string, data: Buffer | Uint8Array }, fields?: Record<string, unknown> }} options
241
+ */
242
+ async function postMultipart(path, { file, fields = {} }) {
243
+ const url = lockedUrl(path)
244
+ const form = new FormData()
245
+ form.set('file', new Blob([file.data]), file.name)
246
+ for (const [k, v] of Object.entries(fields)) {
247
+ if (v != null) form.set(k, String(v))
248
+ }
249
+ const res = await fetch(url, {
250
+ method: 'POST',
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)
259
+ }
260
+ return text ? JSON.parse(text) : null
261
+ }
262
+
175
263
  return {
176
264
  get: (path, opts) => request('GET', path, opts),
177
265
  post: (path, opts) => request('POST', path, opts),
178
266
  put: (path, opts) => request('PUT', path, opts),
179
267
  patch: (path, opts) => request('PATCH', path, opts),
180
268
  del: (path, opts) => request('DELETE', path, opts),
269
+ download,
270
+ postMultipart,
181
271
  pageV1,
182
272
  pageV2,
183
273
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @param {string} message
3
+ * @param {boolean} skipConfirm
4
+ * @returns {Promise<boolean>}
5
+ */
6
+ export async function confirmAction(message, skipConfirm) {
7
+ if (skipConfirm) return true
8
+ const { confirm } = await import('@inquirer/prompts')
9
+ return confirm({ message })
10
+ }