@wavyx/pdcli 0.8.0 → 0.9.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 +28 -0
- package/oclif.manifest.json +729 -729
- package/package.json +1 -1
- package/src/base-command.js +22 -3
- package/src/commands/activity/list.js +1 -1
- package/src/commands/deal/list.js +1 -1
- package/src/commands/org/list.js +1 -1
- package/src/commands/person/list.js +1 -1
- package/src/commands/product/list.js +1 -1
- package/src/lib/aliases.js +85 -5
- package/src/lib/client.js +110 -117
package/package.json
CHANGED
package/src/base-command.js
CHANGED
|
@@ -24,7 +24,7 @@ export default class BaseCommand extends Command {
|
|
|
24
24
|
}),
|
|
25
25
|
'resolve-fields': Flags.boolean({
|
|
26
26
|
description:
|
|
27
|
-
'Resolve custom-field hash keys to names (and option ids to labels) in json/yaml/csv output of
|
|
27
|
+
'Resolve custom-field hash keys to names (and option ids to labels) in json/yaml/csv output of get and core list commands',
|
|
28
28
|
helpGroup: 'GLOBAL',
|
|
29
29
|
default: false,
|
|
30
30
|
}),
|
|
@@ -151,12 +151,31 @@ export default class BaseCommand extends Command {
|
|
|
151
151
|
/**
|
|
152
152
|
* @param {object | object[]} data
|
|
153
153
|
* @param {Record<string, import('./lib/output/table.js').Column>} columns
|
|
154
|
+
* @param {{ entity?: string }} [options] entity context enables
|
|
155
|
+
* --resolve-fields custom-field resolution on machine-format lists
|
|
154
156
|
*/
|
|
155
|
-
async outputResults(data, columns) {
|
|
157
|
+
async outputResults(data, columns, { entity } = {}) {
|
|
158
|
+
if (
|
|
159
|
+
entity &&
|
|
160
|
+
this.flags['resolve-fields'] &&
|
|
161
|
+
this.resolveFormat() !== 'table' &&
|
|
162
|
+
Array.isArray(data) &&
|
|
163
|
+
data.some((row) => row?.custom_fields)
|
|
164
|
+
) {
|
|
165
|
+
const { getFields, makeResolver } = await import('./lib/fields.js')
|
|
166
|
+
// getFields is memoized per run — one defs fetch covers the whole list.
|
|
167
|
+
const resolver = makeResolver(await getFields(this.apiClient, entity))
|
|
168
|
+
data = data.map((row) =>
|
|
169
|
+
row?.custom_fields ? resolver.resolveCustomFields(row) : row,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
if (this.flags.jq) {
|
|
157
174
|
// node-jq ships a native binary — load it only when actually used.
|
|
175
|
+
// Single records pass UNWRAPPED: `--jq .id` works on a get without
|
|
176
|
+
// the historical `.[0]` indirection (changed in 0.9.0).
|
|
158
177
|
const { run } = await import('node-jq')
|
|
159
|
-
const input = JSON.stringify(
|
|
178
|
+
const input = JSON.stringify(data)
|
|
160
179
|
const result = await run(this.flags.jq, input, {
|
|
161
180
|
input: 'string',
|
|
162
181
|
output: 'pretty',
|
package/src/commands/org/list.js
CHANGED
package/src/lib/aliases.js
CHANGED
|
@@ -1,4 +1,78 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
1
2
|
import { getConf } from './config.js'
|
|
3
|
+
import { CliError } from './errors.js'
|
|
4
|
+
|
|
5
|
+
const LOCK_STALE_MS = 5000
|
|
6
|
+
const LOCK_RETRIES = 8
|
|
7
|
+
const LOCK_RETRY_MS = 50
|
|
8
|
+
|
|
9
|
+
/** Synchronous sleep for the short lock-retry loop (no event-loop yield needed). */
|
|
10
|
+
function sleepSync(ms) {
|
|
11
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Release the lock. Never throws: the mutation already persisted, so a
|
|
16
|
+
* failed release must not convert success into a reported failure (a
|
|
17
|
+
* leftover dir goes stale in 5s and is reaped by the next writer).
|
|
18
|
+
*/
|
|
19
|
+
function releaseLock(lockDir) {
|
|
20
|
+
try {
|
|
21
|
+
fs.rmdirSync(lockDir)
|
|
22
|
+
} catch {
|
|
23
|
+
/* benign: stale-broken concurrently, or unremovable — reaped later */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Advisory lock around alias mutations: a lock DIRECTORY next to the conf
|
|
29
|
+
* file (mkdir is atomic on every platform we support). Protects concurrent
|
|
30
|
+
* pdcli processes from clobbering each other's read-modify-write of the
|
|
31
|
+
* aliases object — advisory only; other writers aren't covered. A lock left
|
|
32
|
+
* behind by a crashed process goes stale after 5s and is broken.
|
|
33
|
+
* @param {() => void} fn the mutation to run while holding the lock
|
|
34
|
+
*/
|
|
35
|
+
function withAliasLock(fn) {
|
|
36
|
+
const lockDir = `${getConf().path}.aliases.lock`
|
|
37
|
+
|
|
38
|
+
for (let attempt = 0; attempt <= LOCK_RETRIES; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(lockDir)
|
|
41
|
+
try {
|
|
42
|
+
fn()
|
|
43
|
+
return
|
|
44
|
+
} finally {
|
|
45
|
+
releaseLock(lockDir)
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err?.code !== 'EEXIST') {
|
|
49
|
+
// Not contention — the config dir itself is unusable. Name the real
|
|
50
|
+
// problem instead of leaking the lock implementation detail.
|
|
51
|
+
throw new CliError(
|
|
52
|
+
`Cannot update aliases: the config directory is not writable (${err?.code ?? err?.message})`,
|
|
53
|
+
{ exitCode: 78 },
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
// Held by someone else — break it if stale, otherwise wait and retry.
|
|
57
|
+
let stale
|
|
58
|
+
try {
|
|
59
|
+
stale = Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS
|
|
60
|
+
} catch {
|
|
61
|
+
continue // lock vanished between mkdir and stat — retry immediately
|
|
62
|
+
}
|
|
63
|
+
if (stale) {
|
|
64
|
+
fs.rmdirSync(lockDir) // a real failure here must surface, not retry
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (attempt < LOCK_RETRIES) sleepSync(LOCK_RETRY_MS)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new CliError(
|
|
72
|
+
'another pdcli process is updating aliases — retry in a moment',
|
|
73
|
+
{ exitCode: 75 },
|
|
74
|
+
)
|
|
75
|
+
}
|
|
2
76
|
|
|
3
77
|
export function getAliases() {
|
|
4
78
|
return getConf().get('aliases') ?? {}
|
|
@@ -17,19 +91,25 @@ export function getAlias(name) {
|
|
|
17
91
|
* dotted-path write. conf splits `set('aliases.<name>', …)` on every '.', so a
|
|
18
92
|
* dotted-path write would corrupt any name containing a dot (store/read
|
|
19
93
|
* mismatch). Mutating the object and writing it whole keeps odd names flat.
|
|
94
|
+
* The read-modify-write runs under an advisory lock so concurrent pdcli
|
|
95
|
+
* processes can't clobber each other.
|
|
20
96
|
* @param {string} name
|
|
21
97
|
* @param {string} command
|
|
22
98
|
*/
|
|
23
99
|
export function setAlias(name, command) {
|
|
24
|
-
|
|
25
|
-
|
|
100
|
+
withAliasLock(() => {
|
|
101
|
+
const aliases = { ...getAliases(), [name]: command }
|
|
102
|
+
getConf().set('aliases', aliases)
|
|
103
|
+
})
|
|
26
104
|
}
|
|
27
105
|
|
|
28
106
|
/**
|
|
29
107
|
* @param {string} name
|
|
30
108
|
*/
|
|
31
109
|
export function unsetAlias(name) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
110
|
+
withAliasLock(() => {
|
|
111
|
+
const aliases = { ...getAliases() }
|
|
112
|
+
delete aliases[name]
|
|
113
|
+
getConf().set('aliases', aliases)
|
|
114
|
+
})
|
|
35
115
|
}
|
package/src/lib/client.js
CHANGED
|
@@ -79,6 +79,87 @@ export function createClient({
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
return transport(method, url, {
|
|
83
|
+
path,
|
|
84
|
+
makeBody: body ? () => JSON.stringify(body) : undefined,
|
|
85
|
+
extraHeaders: { 'content-type': 'application/json' },
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* v2 cursor pagination: cursor/limit → additional_data.next_cursor.
|
|
91
|
+
* @param {string} path
|
|
92
|
+
* @param {object} [query]
|
|
93
|
+
* @returns {AsyncGenerator<object>}
|
|
94
|
+
*/
|
|
95
|
+
async function* pageV2(path, query = {}) {
|
|
96
|
+
let cursor
|
|
97
|
+
const baseQuery = clampLimit(query)
|
|
98
|
+
while (true) {
|
|
99
|
+
const envelope = await request('GET', path, {
|
|
100
|
+
query: cursor ? { ...baseQuery, cursor } : baseQuery,
|
|
101
|
+
})
|
|
102
|
+
yield* envelope?.data ?? []
|
|
103
|
+
cursor = envelope?.additional_data?.next_cursor
|
|
104
|
+
if (!cursor) break
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* v1 offset pagination: start/limit →
|
|
110
|
+
* additional_data.pagination.{more_items_in_collection,next_start}.
|
|
111
|
+
* @param {string} path
|
|
112
|
+
* @param {object} [query]
|
|
113
|
+
* @returns {AsyncGenerator<object>}
|
|
114
|
+
*/
|
|
115
|
+
async function* pageV1(path, query = {}) {
|
|
116
|
+
let start
|
|
117
|
+
const baseQuery = clampLimit(query)
|
|
118
|
+
while (true) {
|
|
119
|
+
const envelope = await request('GET', path, {
|
|
120
|
+
query: start != null ? { ...baseQuery, start } : baseQuery,
|
|
121
|
+
})
|
|
122
|
+
yield* envelope?.data ?? []
|
|
123
|
+
const pagination = envelope?.additional_data?.pagination
|
|
124
|
+
if (!pagination?.more_items_in_collection) break
|
|
125
|
+
start = pagination.next_start
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function lockedUrl(path) {
|
|
130
|
+
const url = new URL(path, baseOrigin)
|
|
131
|
+
if (url.origin !== baseOrigin) {
|
|
132
|
+
throw new CliError(
|
|
133
|
+
`Refusing to send request outside your Pipedrive company host ` +
|
|
134
|
+
`(${baseOrigin}): ${url.origin}`,
|
|
135
|
+
{ exitCode: 78 },
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
return url
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function authHeaders() {
|
|
142
|
+
return authMode === 'oauth'
|
|
143
|
+
? { authorization: `Bearer ${token}`, 'user-agent': userAgent }
|
|
144
|
+
: { 'x-api-token': token, 'user-agent': userAgent }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Shared transport: EVERY HTTP path (JSON, form, multipart, binary) goes
|
|
149
|
+
* through the same 429 backoff (x-ratelimit-reset → Retry-After → default),
|
|
150
|
+
* 429→403 escalation hard stop, 5xx retry, OAuth refresh-once, and error
|
|
151
|
+
* mapping. `makeBody` is invoked per attempt — FormData and friends are
|
|
152
|
+
* rebuilt rather than reused across retries.
|
|
153
|
+
* @param {string} method
|
|
154
|
+
* @param {URL} url
|
|
155
|
+
* @param {{ path: string, makeBody?: () => any,
|
|
156
|
+
* extraHeaders?: Record<string, string>, binary?: boolean }} options
|
|
157
|
+
*/
|
|
158
|
+
async function transport(
|
|
159
|
+
method,
|
|
160
|
+
url,
|
|
161
|
+
{ path, makeBody, extraHeaders = {}, binary = false },
|
|
162
|
+
) {
|
|
82
163
|
const maxAttempts = retry ? 3 : 1
|
|
83
164
|
let attempts = 0
|
|
84
165
|
let sawRateLimit = false
|
|
@@ -86,20 +167,10 @@ export function createClient({
|
|
|
86
167
|
while (attempts < maxAttempts) {
|
|
87
168
|
attempts++
|
|
88
169
|
|
|
89
|
-
const headers = {
|
|
90
|
-
'content-type': 'application/json',
|
|
91
|
-
'user-agent': userAgent,
|
|
92
|
-
}
|
|
93
|
-
if (authMode === 'oauth') {
|
|
94
|
-
headers.authorization = `Bearer ${token}`
|
|
95
|
-
} else {
|
|
96
|
-
headers['x-api-token'] = token
|
|
97
|
-
}
|
|
98
|
-
|
|
99
170
|
const res = await fetch(url, {
|
|
100
171
|
method,
|
|
101
|
-
headers,
|
|
102
|
-
body:
|
|
172
|
+
headers: { ...authHeaders(), ...extraHeaders },
|
|
173
|
+
body: makeBody ? makeBody() : undefined,
|
|
103
174
|
signal: AbortSignal.timeout(timeout),
|
|
104
175
|
})
|
|
105
176
|
|
|
@@ -141,97 +212,38 @@ export function createClient({
|
|
|
141
212
|
continue
|
|
142
213
|
}
|
|
143
214
|
|
|
215
|
+
// Binary callers always get the {buffer, contentType} shape — a 204
|
|
216
|
+
// yields an empty buffer rather than null (pre-unification parity;
|
|
217
|
+
// file/download destructures the result).
|
|
218
|
+
if (binary) {
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
throw ApiError.fromResponse(res.status, await res.text(), path)
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
buffer: await res.arrayBuffer(),
|
|
224
|
+
contentType: res.headers.get('content-type'),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
144
228
|
if (res.status === 204) return null
|
|
145
229
|
|
|
146
230
|
const text = await res.text()
|
|
147
|
-
|
|
148
231
|
if (!res.ok) {
|
|
149
232
|
throw ApiError.fromResponse(res.status, text, path)
|
|
150
233
|
}
|
|
151
|
-
|
|
152
234
|
return text ? JSON.parse(text) : null
|
|
153
235
|
}
|
|
154
236
|
|
|
155
237
|
throw new ServiceUnavailableError()
|
|
156
238
|
}
|
|
157
239
|
|
|
158
|
-
/**
|
|
159
|
-
* v2 cursor pagination: cursor/limit → additional_data.next_cursor.
|
|
160
|
-
* @param {string} path
|
|
161
|
-
* @param {object} [query]
|
|
162
|
-
* @returns {AsyncGenerator<object>}
|
|
163
|
-
*/
|
|
164
|
-
async function* pageV2(path, query = {}) {
|
|
165
|
-
let cursor
|
|
166
|
-
const baseQuery = clampLimit(query)
|
|
167
|
-
while (true) {
|
|
168
|
-
const envelope = await request('GET', path, {
|
|
169
|
-
query: cursor ? { ...baseQuery, cursor } : baseQuery,
|
|
170
|
-
})
|
|
171
|
-
yield* envelope?.data ?? []
|
|
172
|
-
cursor = envelope?.additional_data?.next_cursor
|
|
173
|
-
if (!cursor) break
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* v1 offset pagination: start/limit →
|
|
179
|
-
* additional_data.pagination.{more_items_in_collection,next_start}.
|
|
180
|
-
* @param {string} path
|
|
181
|
-
* @param {object} [query]
|
|
182
|
-
* @returns {AsyncGenerator<object>}
|
|
183
|
-
*/
|
|
184
|
-
async function* pageV1(path, query = {}) {
|
|
185
|
-
let start
|
|
186
|
-
const baseQuery = clampLimit(query)
|
|
187
|
-
while (true) {
|
|
188
|
-
const envelope = await request('GET', path, {
|
|
189
|
-
query: start != null ? { ...baseQuery, start } : baseQuery,
|
|
190
|
-
})
|
|
191
|
-
yield* envelope?.data ?? []
|
|
192
|
-
const pagination = envelope?.additional_data?.pagination
|
|
193
|
-
if (!pagination?.more_items_in_collection) break
|
|
194
|
-
start = pagination.next_start
|
|
195
|
-
}
|
|
196
|
-
}
|
|
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
240
|
/**
|
|
217
241
|
* Download a binary resource (e.g. /api/v1/files/:id/download).
|
|
218
242
|
* @param {string} path
|
|
219
243
|
* @returns {Promise<{buffer: ArrayBuffer, contentType: string | null}>}
|
|
220
244
|
*/
|
|
221
245
|
async function download(path) {
|
|
222
|
-
|
|
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
|
-
}
|
|
246
|
+
return transport('GET', lockedUrl(path), { path, binary: true })
|
|
235
247
|
}
|
|
236
248
|
|
|
237
249
|
/**
|
|
@@ -240,24 +252,16 @@ export function createClient({
|
|
|
240
252
|
* @param {{ file: { name: string, data: Buffer | Uint8Array }, fields?: Record<string, unknown> }} options
|
|
241
253
|
*/
|
|
242
254
|
async function postMultipart(path, { file, fields = {} }) {
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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)
|
|
255
|
+
// fetch sets the multipart boundary itself — no content-type override.
|
|
256
|
+
const makeBody = () => {
|
|
257
|
+
const form = new FormData()
|
|
258
|
+
form.set('file', new Blob([file.data]), file.name)
|
|
259
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
260
|
+
if (v != null) form.set(k, String(v))
|
|
261
|
+
}
|
|
262
|
+
return form
|
|
259
263
|
}
|
|
260
|
-
return
|
|
264
|
+
return transport('POST', lockedUrl(path), { path, makeBody })
|
|
261
265
|
}
|
|
262
266
|
|
|
263
267
|
/**
|
|
@@ -267,26 +271,15 @@ export function createClient({
|
|
|
267
271
|
* @param {Record<string, unknown>} fields Null/undefined values are omitted.
|
|
268
272
|
*/
|
|
269
273
|
async function postForm(path, fields = {}) {
|
|
270
|
-
const url = lockedUrl(path)
|
|
271
274
|
const params = new URLSearchParams()
|
|
272
275
|
for (const [k, v] of Object.entries(fields)) {
|
|
273
276
|
if (v != null) params.set(k, String(v))
|
|
274
277
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
'content-type': 'application/x-www-form-urlencoded',
|
|
280
|
-
},
|
|
281
|
-
body: params.toString(),
|
|
282
|
-
signal: AbortSignal.timeout(timeout),
|
|
278
|
+
return transport('POST', lockedUrl(path), {
|
|
279
|
+
path,
|
|
280
|
+
makeBody: () => params.toString(),
|
|
281
|
+
extraHeaders: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
283
282
|
})
|
|
284
|
-
debug('POST (form) %s → %d', path, res.status)
|
|
285
|
-
const text = await res.text()
|
|
286
|
-
if (!res.ok) {
|
|
287
|
-
throw ApiError.fromResponse(res.status, text, path)
|
|
288
|
-
}
|
|
289
|
-
return text ? JSON.parse(text) : null
|
|
290
283
|
}
|
|
291
284
|
|
|
292
285
|
return {
|