@wavyx/pdcli 0.2.0 → 0.4.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 (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +30 -2
  3. package/oclif.manifest.json +5579 -686
  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/deal/bulk-update.js +131 -0
  9. package/src/commands/file/download.js +35 -0
  10. package/src/commands/file/get.js +26 -0
  11. package/src/commands/file/list.js +40 -0
  12. package/src/commands/file/upload.js +42 -0
  13. package/src/commands/filter/get.js +26 -0
  14. package/src/commands/filter/list.js +43 -0
  15. package/src/commands/goal/list.js +37 -0
  16. package/src/commands/lead/create.js +58 -0
  17. package/src/commands/lead/delete.js +39 -0
  18. package/src/commands/lead/get.js +26 -0
  19. package/src/commands/lead/list.js +50 -0
  20. package/src/commands/lead/update.js +71 -0
  21. package/src/commands/note/create.js +42 -0
  22. package/src/commands/note/delete.js +39 -0
  23. package/src/commands/note/get.js +26 -0
  24. package/src/commands/note/list.js +49 -0
  25. package/src/commands/note/update.js +45 -0
  26. package/src/commands/org/import.js +109 -0
  27. package/src/commands/person/import.js +118 -0
  28. package/src/commands/pipeline/get.js +26 -0
  29. package/src/commands/pipeline/list.js +37 -0
  30. package/src/commands/project/create.js +48 -0
  31. package/src/commands/project/delete.js +39 -0
  32. package/src/commands/project/get.js +26 -0
  33. package/src/commands/project/list.js +39 -0
  34. package/src/commands/project/update.js +63 -0
  35. package/src/commands/stage/get.js +26 -0
  36. package/src/commands/stage/list.js +41 -0
  37. package/src/commands/webhook/create.js +75 -0
  38. package/src/commands/webhook/delete.js +39 -0
  39. package/src/commands/webhook/list.js +33 -0
  40. package/src/lib/backup.js +122 -0
  41. package/src/lib/bulk.js +106 -0
  42. package/src/lib/client.js +67 -0
  43. package/src/lib/csv-parse.js +88 -0
  44. package/src/lib/entity-view.js +7 -2
  45. package/src/lib/import.js +49 -0
  46. package/src/lib/output/csv.js +26 -0
  47. package/src/lib/output/index.js +9 -1
  48. package/src/lib/output/yaml.js +9 -0
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
  }
@@ -0,0 +1,88 @@
1
+ import { CliError } from './errors.js'
2
+
3
+ /**
4
+ * Minimal RFC 4180 CSV parser (quoted fields, escaped quotes, embedded
5
+ * commas/newlines, CRLF). First record is the header row.
6
+ * @param {string} text
7
+ * @returns {{ headers: string[], rows: string[][] }}
8
+ */
9
+ export function parseCsv(text) {
10
+ const records = []
11
+ let record = []
12
+ let field = ''
13
+ let inQuotes = false
14
+ let i = 0
15
+
16
+ while (i < text.length) {
17
+ const char = text[i]
18
+
19
+ if (inQuotes) {
20
+ if (char === '"') {
21
+ if (text[i + 1] === '"') {
22
+ field += '"'
23
+ i += 2
24
+ continue
25
+ }
26
+ inQuotes = false
27
+ i++
28
+ continue
29
+ }
30
+ field += char
31
+ i++
32
+ continue
33
+ }
34
+
35
+ if (char === '"') {
36
+ inQuotes = true
37
+ i++
38
+ continue
39
+ }
40
+ if (char === ',') {
41
+ record.push(field)
42
+ field = ''
43
+ i++
44
+ continue
45
+ }
46
+ if (char === '\n' || char === '\r') {
47
+ if (char === '\r' && text[i + 1] === '\n') i++
48
+ record.push(field)
49
+ field = ''
50
+ if (record.length > 1 || record[0] !== '') records.push(record)
51
+ record = []
52
+ i++
53
+ continue
54
+ }
55
+ field += char
56
+ i++
57
+ }
58
+
59
+ if (inQuotes) {
60
+ throw new CliError('Unterminated quoted field in CSV', { exitCode: 65 })
61
+ }
62
+ if (field !== '' || record.length > 0) {
63
+ // A tail record only reaches here when non-empty (a bare trailing
64
+ // newline never starts a record), so push unconditionally.
65
+ record.push(field)
66
+ records.push(record)
67
+ }
68
+
69
+ if (records.length === 0) {
70
+ throw new CliError('CSV file is empty', { exitCode: 65 })
71
+ }
72
+
73
+ const [headers, ...rows] = records
74
+
75
+ return {
76
+ headers,
77
+ rows: rows.map((row, index) => {
78
+ if (row.length > headers.length) {
79
+ throw new CliError(
80
+ `CSV row ${index + 2} has ${row.length} cells but the header has ${headers.length}`,
81
+ { exitCode: 65 },
82
+ )
83
+ }
84
+ while (row.length < headers.length) row.push('')
85
+ return row
86
+ }),
87
+ }
88
+ }
@@ -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,49 @@
1
+ import { buildWriteBody } from './input.js'
2
+ import { CliError } from './errors.js'
3
+
4
+ /**
5
+ * Turn parsed CSV rows into write bodies. Special columns (matched
6
+ * case-insensitively) build typed values; every other header resolves
7
+ * through the entity's field definitions — names, hash keys, and option
8
+ * labels included. Empty cells are skipped.
9
+ * @param {object} options
10
+ * @param {string[]} options.headers
11
+ * @param {string[][]} options.rows
12
+ * @param {Record<string, (typed: object, value: string) => void>} [options.specialColumns]
13
+ * @param {object[]} [options.defs] field definitions for non-special headers
14
+ * @returns {object[]} one request body per row
15
+ */
16
+ export function prepareImportBodies({
17
+ headers,
18
+ rows,
19
+ specialColumns = {},
20
+ defs,
21
+ }) {
22
+ const specials = Object.fromEntries(
23
+ Object.entries(specialColumns).map(([k, v]) => [k.toLowerCase(), v]),
24
+ )
25
+
26
+ return rows.map((row, index) => {
27
+ const typed = {}
28
+ const fields = []
29
+
30
+ headers.forEach((header, i) => {
31
+ const value = row[i]
32
+ if (value === '') return
33
+ const special = specials[header.toLowerCase()]
34
+ if (special) {
35
+ special(typed, value)
36
+ return
37
+ }
38
+ fields.push(`${header}=${value}`)
39
+ })
40
+
41
+ try {
42
+ return buildWriteBody({ typed, fields, defs })
43
+ } catch (err) {
44
+ throw new CliError(`CSV row ${index + 2}: ${err.message}`, {
45
+ exitCode: 65,
46
+ })
47
+ }
48
+ })
49
+ }
@@ -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
+ }