@wavyx/pdcli 0.2.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 +15 -0
- package/README.md +16 -2
- package/oclif.manifest.json +5232 -768
- package/package.json +3 -1
- package/src/base-command.js +30 -2
- package/src/commands/api.js +6 -2
- package/src/commands/backup.js +53 -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/pipeline/get.js +26 -0
- package/src/commands/pipeline/list.js +37 -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/backup.js +122 -0
- package/src/lib/client.js +67 -0
- package/src/lib/entity-view.js +7 -2
- 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,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
|
@@ -195,12 +195,79 @@ export function createClient({
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
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
|
+
|
|
198
263
|
return {
|
|
199
264
|
get: (path, opts) => request('GET', path, opts),
|
|
200
265
|
post: (path, opts) => request('POST', path, opts),
|
|
201
266
|
put: (path, opts) => request('PUT', path, opts),
|
|
202
267
|
patch: (path, opts) => request('PATCH', path, opts),
|
|
203
268
|
del: (path, opts) => request('DELETE', path, opts),
|
|
269
|
+
download,
|
|
270
|
+
postMultipart,
|
|
204
271
|
pageV1,
|
|
205
272
|
pageV2,
|
|
206
273
|
}
|
package/src/lib/entity-view.js
CHANGED
|
@@ -6,11 +6,16 @@ import { flattenRecord } from './output/record.js'
|
|
|
6
6
|
* field/value table with custom-field hash keys and option IDs resolved.
|
|
7
7
|
* @param {import('../base-command.js').default} cmd
|
|
8
8
|
* @param {object} record
|
|
9
|
-
* @param {string} entity deal | person | org | activity | product
|
|
9
|
+
* @param {string} [entity] deal | person | org | activity | product — omit
|
|
10
|
+
* for entities without resolvable custom fields (notes, files, webhooks, …)
|
|
10
11
|
*/
|
|
11
12
|
export async function outputRecord(cmd, record, entity) {
|
|
12
13
|
if (cmd.resolveFormat() === 'table') {
|
|
13
|
-
if (
|
|
14
|
+
if (
|
|
15
|
+
entity &&
|
|
16
|
+
record.custom_fields &&
|
|
17
|
+
Object.keys(record.custom_fields).length
|
|
18
|
+
) {
|
|
14
19
|
const defs = await getFields(cmd.apiClient, entity)
|
|
15
20
|
record = makeResolver(defs).resolveCustomFields(record)
|
|
16
21
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object[]} data
|
|
3
|
+
* @param {Record<string, import('./table.js').Column>} columns
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
export function formatCsv(data, columns) {
|
|
7
|
+
if (!data || data.length === 0) return ''
|
|
8
|
+
const entries = Object.entries(columns)
|
|
9
|
+
const header = entries.map(([, col]) => col.header).join(',')
|
|
10
|
+
const rows = data.map((row) =>
|
|
11
|
+
entries
|
|
12
|
+
.map(([key, col]) => {
|
|
13
|
+
const val = col.get ? col.get(row) : row[col.key ?? key]
|
|
14
|
+
return csvEscape(val != null ? String(val) : '')
|
|
15
|
+
})
|
|
16
|
+
.join(','),
|
|
17
|
+
)
|
|
18
|
+
return [header, ...rows].join('\n')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function csvEscape(val) {
|
|
22
|
+
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
|
23
|
+
return '"' + val.replace(/"/g, '""') + '"'
|
|
24
|
+
}
|
|
25
|
+
return val
|
|
26
|
+
}
|
package/src/lib/output/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { formatTable } from './table.js'
|
|
2
2
|
import { formatJson } from './json.js'
|
|
3
|
+
import { formatYaml } from './yaml.js'
|
|
4
|
+
import { formatCsv } from './csv.js'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* @param {object | object[]} data
|
|
6
8
|
* @param {Record<string, import('./table.js').Column>} columns
|
|
7
|
-
* @param {'table' | 'json'} format
|
|
9
|
+
* @param {'table' | 'json' | 'yaml' | 'csv'} format
|
|
8
10
|
* @param {import('@oclif/core').Command} cmd
|
|
9
11
|
*/
|
|
10
12
|
export function formatOutput(data, columns, format, cmd) {
|
|
@@ -14,6 +16,12 @@ export function formatOutput(data, columns, format, cmd) {
|
|
|
14
16
|
case 'json':
|
|
15
17
|
cmd.log(formatJson(data))
|
|
16
18
|
break
|
|
19
|
+
case 'yaml':
|
|
20
|
+
cmd.log(formatYaml(data))
|
|
21
|
+
break
|
|
22
|
+
case 'csv':
|
|
23
|
+
cmd.log(formatCsv(items, columns))
|
|
24
|
+
break
|
|
17
25
|
case 'table':
|
|
18
26
|
default:
|
|
19
27
|
cmd.log(formatTable(items, columns))
|