@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +16 -2
  3. package/oclif.manifest.json +5232 -768
  4. package/package.json +3 -1
  5. package/src/base-command.js +30 -2
  6. package/src/commands/api.js +6 -2
  7. package/src/commands/backup.js +53 -0
  8. package/src/commands/file/download.js +35 -0
  9. package/src/commands/file/get.js +26 -0
  10. package/src/commands/file/list.js +40 -0
  11. package/src/commands/file/upload.js +42 -0
  12. package/src/commands/filter/get.js +26 -0
  13. package/src/commands/filter/list.js +43 -0
  14. package/src/commands/goal/list.js +37 -0
  15. package/src/commands/lead/create.js +58 -0
  16. package/src/commands/lead/delete.js +39 -0
  17. package/src/commands/lead/get.js +26 -0
  18. package/src/commands/lead/list.js +50 -0
  19. package/src/commands/lead/update.js +71 -0
  20. package/src/commands/note/create.js +42 -0
  21. package/src/commands/note/delete.js +39 -0
  22. package/src/commands/note/get.js +26 -0
  23. package/src/commands/note/list.js +49 -0
  24. package/src/commands/note/update.js +45 -0
  25. package/src/commands/pipeline/get.js +26 -0
  26. package/src/commands/pipeline/list.js +37 -0
  27. package/src/commands/project/create.js +48 -0
  28. package/src/commands/project/delete.js +39 -0
  29. package/src/commands/project/get.js +26 -0
  30. package/src/commands/project/list.js +39 -0
  31. package/src/commands/project/update.js +63 -0
  32. package/src/commands/stage/get.js +26 -0
  33. package/src/commands/stage/list.js +41 -0
  34. package/src/commands/webhook/create.js +75 -0
  35. package/src/commands/webhook/delete.js +39 -0
  36. package/src/commands/webhook/list.js +33 -0
  37. package/src/lib/backup.js +122 -0
  38. package/src/lib/client.js +67 -0
  39. package/src/lib/entity-view.js +7 -2
  40. package/src/lib/output/csv.js +26 -0
  41. package/src/lib/output/index.js +9 -1
  42. 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
  }
@@ -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 (record.custom_fields && Object.keys(record.custom_fields).length) {
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
+ }
@@ -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))
@@ -0,0 +1,9 @@
1
+ import yaml from 'js-yaml'
2
+
3
+ /**
4
+ * @param {unknown} data
5
+ * @returns {string}
6
+ */
7
+ export function formatYaml(data) {
8
+ return yaml.dump(data, { lineWidth: -1 }).trimEnd()
9
+ }