@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.
- package/CHANGELOG.md +36 -1
- package/README.md +60 -11
- package/oclif.manifest.json +7101 -354
- package/package.json +4 -1
- package/src/base-command.js +65 -7
- package/src/commands/activity/create.js +63 -0
- package/src/commands/activity/delete.js +39 -0
- package/src/commands/activity/get.js +2 -17
- package/src/commands/activity/update.js +89 -0
- package/src/commands/api.js +6 -2
- package/src/commands/auth/login.js +102 -7
- package/src/commands/auth/logout.js +2 -1
- package/src/commands/auth/status.js +61 -13
- package/src/commands/backup.js +53 -0
- package/src/commands/deal/create.js +65 -0
- package/src/commands/deal/delete.js +39 -0
- package/src/commands/deal/get.js +2 -19
- package/src/commands/deal/update.js +79 -0
- package/src/commands/file/download.js +35 -0
- package/src/commands/file/get.js +26 -0
- package/src/commands/file/list.js +40 -0
- package/src/commands/file/upload.js +42 -0
- package/src/commands/filter/get.js +26 -0
- package/src/commands/filter/list.js +43 -0
- package/src/commands/goal/list.js +37 -0
- package/src/commands/lead/create.js +58 -0
- package/src/commands/lead/delete.js +39 -0
- package/src/commands/lead/get.js +26 -0
- package/src/commands/lead/list.js +50 -0
- package/src/commands/lead/update.js +71 -0
- package/src/commands/note/create.js +42 -0
- package/src/commands/note/delete.js +39 -0
- package/src/commands/note/get.js +26 -0
- package/src/commands/note/list.js +49 -0
- package/src/commands/note/update.js +45 -0
- package/src/commands/org/create.js +42 -0
- package/src/commands/org/delete.js +39 -0
- package/src/commands/org/get.js +2 -17
- package/src/commands/org/update.js +57 -0
- package/src/commands/person/create.js +54 -0
- package/src/commands/person/delete.js +39 -0
- package/src/commands/person/get.js +2 -17
- package/src/commands/person/update.js +69 -0
- package/src/commands/pipeline/get.js +26 -0
- package/src/commands/pipeline/list.js +37 -0
- package/src/commands/product/create.js +62 -0
- package/src/commands/product/delete.js +39 -0
- package/src/commands/product/get.js +26 -0
- package/src/commands/product/list.js +45 -0
- package/src/commands/product/update.js +77 -0
- package/src/commands/project/create.js +48 -0
- package/src/commands/project/delete.js +39 -0
- package/src/commands/project/get.js +26 -0
- package/src/commands/project/list.js +39 -0
- package/src/commands/project/update.js +63 -0
- package/src/commands/stage/get.js +26 -0
- package/src/commands/stage/list.js +41 -0
- package/src/commands/webhook/create.js +75 -0
- package/src/commands/webhook/delete.js +39 -0
- package/src/commands/webhook/list.js +33 -0
- package/src/lib/auth.js +227 -3
- package/src/lib/backup.js +122 -0
- package/src/lib/client.js +96 -6
- package/src/lib/confirm.js +10 -0
- package/src/lib/entity-view.js +42 -0
- package/src/lib/input.js +110 -0
- package/src/lib/keychain.js +47 -0
- package/src/lib/output/csv.js +26 -0
- package/src/lib/output/index.js +9 -1
- 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
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|