@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavyx/pdcli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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 single-record get commands',
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(Array.isArray(data) ? data : [data])
178
+ const input = JSON.stringify(data)
160
179
  const result = await run(this.flags.jq, input, {
161
180
  input: 'string',
162
181
  output: 'pretty',
@@ -56,6 +56,6 @@ export default class ActivityListCommand extends BaseCommand {
56
56
  this.apiClient.pageV2('/api/v2/activities', query),
57
57
  limit,
58
58
  )
59
- await this.outputResults(items, columns)
59
+ await this.outputResults(items, columns, { entity: 'activity' })
60
60
  }
61
61
  }
@@ -57,6 +57,6 @@ export default class DealListCommand extends BaseCommand {
57
57
  this.apiClient.pageV2('/api/v2/deals', query),
58
58
  limit,
59
59
  )
60
- await this.outputResults(items, columns)
60
+ await this.outputResults(items, columns, { entity: 'deal' })
61
61
  }
62
62
  }
@@ -35,6 +35,6 @@ export default class OrgListCommand extends BaseCommand {
35
35
  this.apiClient.pageV2('/api/v2/organizations', query),
36
36
  limit,
37
37
  )
38
- await this.outputResults(items, columns)
38
+ await this.outputResults(items, columns, { entity: 'org' })
39
39
  }
40
40
  }
@@ -44,6 +44,6 @@ export default class PersonListCommand extends BaseCommand {
44
44
  this.apiClient.pageV2('/api/v2/persons', query),
45
45
  limit,
46
46
  )
47
- await this.outputResults(items, columns)
47
+ await this.outputResults(items, columns, { entity: 'person' })
48
48
  }
49
49
  }
@@ -40,6 +40,6 @@ export default class ProductListCommand extends BaseCommand {
40
40
  this.apiClient.pageV2('/api/v2/products', query),
41
41
  limit,
42
42
  )
43
- await this.outputResults(items, columns)
43
+ await this.outputResults(items, columns, { entity: 'product' })
44
44
  }
45
45
  }
@@ -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
- const aliases = { ...getAliases(), [name]: command }
25
- getConf().set('aliases', aliases)
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
- const aliases = { ...getAliases() }
33
- delete aliases[name]
34
- getConf().set('aliases', aliases)
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: body ? JSON.stringify(body) : undefined,
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
- 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
- }
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
- 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)
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 text ? JSON.parse(text) : null
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
- const res = await fetch(url, {
276
- method: 'POST',
277
- headers: {
278
- ...authHeaders(),
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 {