@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
@@ -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 ProjectDeleteCommand extends BaseCommand {
8
+ static description = 'Delete a project'
9
+
10
+ static examples = [
11
+ '<%= config.bin %> project delete 7',
12
+ '<%= config.bin %> project delete 7 --yes',
13
+ ]
14
+
15
+ static args = {
16
+ id: Args.integer({ required: true, description: 'Project 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(ProjectDeleteCommand)
30
+
31
+ const ok = await confirmAction(`Delete project ${args.id}?`, flags.yes)
32
+ if (!ok) {
33
+ throw new CliError('Aborted', { exitCode: 1 })
34
+ }
35
+
36
+ await this.apiClient.del(`/api/v2/projects/${args.id}`)
37
+ this.log(chalk.green(`Deleted project ${args.id}`))
38
+ }
39
+ }
@@ -0,0 +1,26 @@
1
+ import { Args } from '@oclif/core'
2
+ import BaseCommand from '../../base-command.js'
3
+ import { outputRecord } from '../../lib/entity-view.js'
4
+
5
+ export default class ProjectGetCommand extends BaseCommand {
6
+ static description = 'Get a project by ID'
7
+
8
+ static examples = [
9
+ '<%= config.bin %> project get 3',
10
+ '<%= config.bin %> project get 3 --output json',
11
+ ]
12
+
13
+ static flags = {
14
+ ...BaseCommand.baseFlags,
15
+ }
16
+
17
+ static args = {
18
+ id: Args.integer({ required: true, description: 'Project ID' }),
19
+ }
20
+
21
+ async run() {
22
+ const { args } = await this.parse(ProjectGetCommand)
23
+ const body = await this.apiClient.get(`/api/v2/projects/${args.id}`)
24
+ await outputRecord(this, body.data)
25
+ }
26
+ }
@@ -0,0 +1,39 @@
1
+ import BaseCommand from '../../base-command.js'
2
+ import { collectPages } from '../../lib/pagination.js'
3
+
4
+ const columns = {
5
+ id: { header: 'ID' },
6
+ title: { header: 'Title' },
7
+ status: { header: 'Status' },
8
+ owner_id: { header: 'Owner' },
9
+ start_date: { header: 'Start' },
10
+ end_date: { header: 'End' },
11
+ }
12
+
13
+ export default class ProjectListCommand extends BaseCommand {
14
+ static description = 'List projects'
15
+
16
+ static examples = [
17
+ '<%= config.bin %> project list',
18
+ '<%= config.bin %> project list --output json',
19
+ ]
20
+
21
+ static flags = {
22
+ ...BaseCommand.baseFlags,
23
+ }
24
+
25
+ async run() {
26
+ const { flags } = await this.parse(ProjectListCommand)
27
+ const limit = flags.limit ?? 100
28
+
29
+ const query = {
30
+ limit: Math.min(limit, 100),
31
+ }
32
+
33
+ const items = await collectPages(
34
+ this.apiClient.pageV2('/api/v2/projects', query),
35
+ limit,
36
+ )
37
+ await this.outputResults(items, columns)
38
+ }
39
+ }
@@ -0,0 +1,63 @@
1
+ import { Args, 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
+ import { CliError } from '../../lib/errors.js'
6
+
7
+ export default class ProjectUpdateCommand extends BaseCommand {
8
+ static description =
9
+ 'Update a project (v2 PATCH — only provided fields change)'
10
+
11
+ static examples = [
12
+ '<%= config.bin %> project update 7 --title "Relaunch"',
13
+ '<%= config.bin %> project update 7 --status closed',
14
+ '<%= config.bin %> project update 7 --owner 9',
15
+ ]
16
+
17
+ static args = {
18
+ id: Args.integer({ required: true, description: 'Project ID' }),
19
+ }
20
+
21
+ static flags = {
22
+ ...BaseCommand.baseFlags,
23
+ title: Flags.string({ description: 'Project title' }),
24
+ description: Flags.string({ description: 'Project description' }),
25
+ status: Flags.string({ description: 'Project status' }),
26
+ 'start-date': Flags.string({ description: 'Start date (YYYY-MM-DD)' }),
27
+ 'end-date': Flags.string({ description: 'End date (YYYY-MM-DD)' }),
28
+ owner: Flags.integer({ description: 'Owner (user) ID' }),
29
+ board: Flags.integer({ description: 'Board ID' }),
30
+ phase: Flags.integer({ description: 'Phase ID' }),
31
+ body: Flags.string({ description: 'Raw JSON body to merge (flags win)' }),
32
+ }
33
+
34
+ async run() {
35
+ const { args, flags } = await this.parse(ProjectUpdateCommand)
36
+
37
+ const body = buildWriteBody({
38
+ typed: {
39
+ title: flags.title,
40
+ description: flags.description,
41
+ status: flags.status,
42
+ start_date: flags['start-date'],
43
+ end_date: flags['end-date'],
44
+ owner_id: flags.owner,
45
+ board_id: flags.board,
46
+ phase_id: flags.phase,
47
+ },
48
+ rawBody: flags.body,
49
+ })
50
+
51
+ if (Object.keys(body).length === 0) {
52
+ throw new CliError(
53
+ 'Nothing to update — pass at least one field flag or --body',
54
+ { exitCode: 64 },
55
+ )
56
+ }
57
+
58
+ const res = await this.apiClient.patch(`/api/v2/projects/${args.id}`, {
59
+ body,
60
+ })
61
+ await outputRecord(this, res.data)
62
+ }
63
+ }
@@ -0,0 +1,26 @@
1
+ import { Args } from '@oclif/core'
2
+ import BaseCommand from '../../base-command.js'
3
+ import { outputRecord } from '../../lib/entity-view.js'
4
+
5
+ export default class StageGetCommand extends BaseCommand {
6
+ static description = 'Get a stage by ID'
7
+
8
+ static examples = [
9
+ '<%= config.bin %> stage get 5',
10
+ '<%= config.bin %> stage get 5 --output json',
11
+ ]
12
+
13
+ static flags = {
14
+ ...BaseCommand.baseFlags,
15
+ }
16
+
17
+ static args = {
18
+ id: Args.integer({ required: true, description: 'Stage ID' }),
19
+ }
20
+
21
+ async run() {
22
+ const { args } = await this.parse(StageGetCommand)
23
+ const body = await this.apiClient.get(`/api/v2/stages/${args.id}`)
24
+ await outputRecord(this, body.data)
25
+ }
26
+ }
@@ -0,0 +1,41 @@
1
+ import { Flags } from '@oclif/core'
2
+ import BaseCommand from '../../base-command.js'
3
+ import { collectPages } from '../../lib/pagination.js'
4
+
5
+ const columns = {
6
+ id: { header: 'ID' },
7
+ name: { header: 'Name' },
8
+ pipeline_id: { header: 'Pipeline' },
9
+ deal_probability: { header: 'Probability' },
10
+ order_nr: { header: 'Order' },
11
+ }
12
+
13
+ export default class StageListCommand extends BaseCommand {
14
+ static description = 'List stages'
15
+
16
+ static examples = [
17
+ '<%= config.bin %> stage list',
18
+ '<%= config.bin %> stage list --pipeline 1 --output json',
19
+ ]
20
+
21
+ static flags = {
22
+ ...BaseCommand.baseFlags,
23
+ pipeline: Flags.integer({ description: 'Filter by pipeline ID' }),
24
+ }
25
+
26
+ async run() {
27
+ const { flags } = await this.parse(StageListCommand)
28
+ const limit = flags.limit ?? 100
29
+
30
+ const query = {
31
+ pipeline_id: flags.pipeline,
32
+ limit: Math.min(limit, 100),
33
+ }
34
+
35
+ const items = await collectPages(
36
+ this.apiClient.pageV2('/api/v2/stages', query),
37
+ limit,
38
+ )
39
+ await this.outputResults(items, columns)
40
+ }
41
+ }
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,106 @@
1
+ import createDebug from 'debug'
2
+ import { CliError } from './errors.js'
3
+
4
+ const debug = createDebug('pd:bulk')
5
+
6
+ /**
7
+ * Resolve the target ids for a bulk operation from exactly one selector:
8
+ * --ids "1,2,3", a Pipedrive saved filter (--filter <id>), or piped stdin
9
+ * (newline-separated ids, a JSON array of ids, or JSON objects with an id).
10
+ * @param {object} selectors
11
+ * @param {string} [selectors.ids]
12
+ * @param {number} [selectors.filter]
13
+ * @param {NodeJS.ReadStream} [selectors.stdin] defaults to process.stdin
14
+ * @param {ReturnType<import('./client.js').createClient>} client
15
+ * @param {string} listPath v2 list endpoint supporting filter_id (e.g. /api/v2/deals)
16
+ * @returns {Promise<number[]>}
17
+ */
18
+ export async function resolveTargets(
19
+ { ids, filter, stdin = process.stdin },
20
+ client,
21
+ listPath,
22
+ ) {
23
+ if (ids) {
24
+ return ids.split(',').map(parseId)
25
+ }
26
+
27
+ if (filter != null) {
28
+ debug('resolving targets from filter %d', filter)
29
+ const targets = []
30
+ for await (const item of client.pageV2(listPath, {
31
+ filter_id: filter,
32
+ limit: 500,
33
+ })) {
34
+ targets.push(item.id)
35
+ }
36
+ return targets
37
+ }
38
+
39
+ if (!stdin.isTTY) {
40
+ const chunks = []
41
+ for await (const chunk of stdin) chunks.push(chunk)
42
+ const text = Buffer.concat(chunks).toString('utf8').trim()
43
+ if (text.startsWith('[')) {
44
+ const parsed = JSON.parse(text)
45
+ return parsed.map((entry) =>
46
+ typeof entry === 'object' ? entry.id : parseId(String(entry)),
47
+ )
48
+ }
49
+ return text.split('\n').map(parseId)
50
+ }
51
+
52
+ throw new CliError(
53
+ 'No targets — pass --ids, --filter, or pipe ids on stdin',
54
+ { exitCode: 64 },
55
+ )
56
+ }
57
+
58
+ function parseId(raw) {
59
+ const id = Number(raw.trim())
60
+ if (!Number.isInteger(id)) {
61
+ throw new CliError(`Invalid id "${raw.trim()}" — expected an integer`, {
62
+ exitCode: 64,
63
+ })
64
+ }
65
+ return id
66
+ }
67
+
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms))
70
+ }
71
+
72
+ /**
73
+ * Run an async operation per item, sequentially with a pacing gap so bulk
74
+ * writes stay inside Pipedrive's 2-second burst window (writes cost 10
75
+ * tokens each; the client's 429 backoff covers anything that slips through).
76
+ * Per-item failures are collected, never thrown.
77
+ * @template T
78
+ * @param {T[]} items
79
+ * @param {(item: T) => Promise<unknown>} operation
80
+ * @param {object} [options]
81
+ * @param {number} [options.gapMs] delay between requests (default 200)
82
+ * @param {(done: number, total: number) => void} [options.onProgress]
83
+ * @returns {Promise<{ succeeded: { item: T, result: unknown }[], failed: { item: T, error: string }[] }>}
84
+ */
85
+ export async function bulkRun(
86
+ items,
87
+ operation,
88
+ { gapMs = 200, onProgress } = {},
89
+ ) {
90
+ const succeeded = []
91
+ const failed = []
92
+
93
+ for (const [index, item] of items.entries()) {
94
+ if (index > 0 && gapMs > 0) await sleep(gapMs)
95
+ try {
96
+ const result = await operation(item)
97
+ succeeded.push({ item, result })
98
+ } catch (err) {
99
+ debug('bulk item %o failed: %s', item, err.message)
100
+ failed.push({ item, error: err.message })
101
+ }
102
+ onProgress?.(index + 1, items.length)
103
+ }
104
+
105
+ return { succeeded, failed }
106
+ }