@wavyx/pdcli 0.7.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.
@@ -96,6 +96,90 @@ export function computeFunnel(closedDeals, openDeals, stages, options = {}) {
96
96
  })
97
97
  }
98
98
 
99
+ /**
100
+ * EXACT stage funnel mined from per-deal changelog history, rather than the
101
+ * final-stage approximation in computeFunnel. A deal "enters" a stage when it
102
+ * is observed there: every `new_value` of a stage_id transition, plus the
103
+ * deal's starting stage (the first transition's `old_value`, or the deal's
104
+ * current stage when it has no stage transitions — i.e. it was created
105
+ * directly in that stage). Skipped stages are NOT counted (the whole point):
106
+ * a deal that jumps 1→3 enters 1 and 3 only, never 2. Entries are de-duped per
107
+ * deal, so re-entering a stage still counts once. `won` counts deals whose
108
+ * changelog has a status transition to "won".
109
+ *
110
+ * @param {{ dealId: number, stageId: number, rows: object[] }[]} transitionsByDeal
111
+ * one entry per mined deal: its current stage_id and raw changelog rows
112
+ * (each row: { field_key, old_value, new_value } — values stringified).
113
+ * @param {object[]} stages v2 stages (order_nr, pipeline_id)
114
+ * @param {{ pipelineId?: number }} [options]
115
+ */
116
+ export function computeExactFunnel(transitionsByDeal, stages, options = {}) {
117
+ const ordered = stages
118
+ .filter(
119
+ (s) => options.pipelineId == null || s.pipeline_id === options.pipelineId,
120
+ )
121
+ .sort((a, b) => a.order_nr - b.order_nr)
122
+
123
+ const validStageId = new Set(ordered.map((s) => s.id))
124
+ // distinct deals entered per stage id
125
+ const enteredByStage = new Map(ordered.map((s) => [s.id, new Set()]))
126
+ let won = 0
127
+
128
+ for (const { dealId, stageId, rows } of transitionsByDeal) {
129
+ const stageRows = rows.filter((r) => r.field_key === 'stage_id')
130
+ const entered = new Set()
131
+
132
+ // Starting stage: derived from the transition graph — the source stage
133
+ // that never appears as a destination. Order-independent, so it is
134
+ // immune to the changelog's newest-first ordering AND to two hops
135
+ // sharing the same one-second timestamp. Falls back to the oldest row's
136
+ // source (time sort) for degenerate cycles, else the current stage.
137
+ const destinations = new Set(stageRows.map((r) => String(r.new_value)))
138
+ const startSources = stageRows
139
+ .map((r) => String(r.old_value))
140
+ .filter((v) => !destinations.has(v))
141
+ let startStage
142
+ if (startSources.length > 0) {
143
+ startStage = Number(startSources[0])
144
+ } else if (stageRows.length > 0) {
145
+ const oldest = [...stageRows].sort((a, b) =>
146
+ String(a.time ?? '').localeCompare(String(b.time ?? '')),
147
+ )[0]
148
+ startStage = Number(oldest.old_value)
149
+ } else {
150
+ startStage = stageId
151
+ }
152
+ entered.add(startStage)
153
+ for (const r of stageRows) entered.add(Number(r.new_value))
154
+
155
+ for (const id of entered) {
156
+ if (validStageId.has(id)) enteredByStage.get(id).add(dealId)
157
+ }
158
+
159
+ if (rows.some((r) => r.field_key === 'status' && r.new_value === 'won')) {
160
+ won++
161
+ }
162
+ }
163
+
164
+ const resultRows = ordered.map((stage, index) => {
165
+ const entered = enteredByStage.get(stage.id).size
166
+ const prevEntered =
167
+ index > 0 ? enteredByStage.get(ordered[index - 1].id).size : null
168
+
169
+ return {
170
+ stage: stage.name,
171
+ stageId: stage.id,
172
+ entered,
173
+ conversionFromPrev:
174
+ index > 0 && prevEntered > 0 ? entered / prevEntered : null,
175
+ }
176
+ })
177
+
178
+ // `won` is a single account-wide total — returned once, not repeated on
179
+ // every row, so JSON consumers don't misread it as a per-stage figure.
180
+ return { rows: resultRows, won }
181
+ }
182
+
99
183
  const STALE_DAYS = 14
100
184
 
101
185
  /**
@@ -152,3 +236,61 @@ export function computeHealth(deals, stages, activities, { now }) {
152
236
  }
153
237
  })
154
238
  }
239
+
240
+ /** Rule-of-thumb pipeline-coverage thresholds (raw open ÷ remaining gap). */
241
+ const COVERAGE_HEALTHY = 3
242
+ const COVERAGE_BORDERLINE = 2
243
+
244
+ /**
245
+ * Pipeline coverage against a revenue quota. The classic 3x rule of thumb is
246
+ * defined on RAW pipeline value — weighting it by win probability first would
247
+ * double-discount risk — so `coverage` = openValue ÷ remaining and drives the
248
+ * verdict; `weightedCoverage` = weightedOpen ÷ remaining is reported as the
249
+ * risk-adjusted secondary view. remaining = max(target − progress, 0).
250
+ *
251
+ * When progress already meets/exceeds the target the gap clamps to 0 — there
252
+ * is nothing left to cover, so the ratios are `null` (not Infinity, which is
253
+ * not JSON-serializable) with verdict `'covered'`.
254
+ * @param {{ openValue: number, weightedOpen: number, goalTarget: number,
255
+ * progress?: number }} input
256
+ */
257
+ export function computeCoverage({
258
+ openValue,
259
+ weightedOpen,
260
+ goalTarget,
261
+ progress = 0,
262
+ }) {
263
+ const remaining = Math.max(goalTarget - progress, 0)
264
+
265
+ if (remaining === 0) {
266
+ return {
267
+ openValue,
268
+ weightedOpen,
269
+ goalTarget,
270
+ progress,
271
+ remaining,
272
+ coverage: null,
273
+ weightedCoverage: null,
274
+ verdict: 'covered',
275
+ }
276
+ }
277
+
278
+ const coverage = openValue / remaining
279
+ const verdict =
280
+ coverage >= COVERAGE_HEALTHY
281
+ ? 'healthy'
282
+ : coverage >= COVERAGE_BORDERLINE
283
+ ? 'borderline'
284
+ : 'low'
285
+
286
+ return {
287
+ openValue,
288
+ weightedOpen,
289
+ goalTarget,
290
+ progress,
291
+ remaining,
292
+ coverage,
293
+ weightedCoverage: weightedOpen / remaining,
294
+ verdict,
295
+ }
296
+ }
package/src/lib/audit.js CHANGED
@@ -3,6 +3,11 @@ const STALE_DAYS = 14
3
3
  const ANCIENT_FALLBACK_DAYS = 168 // ~2× the median B2B cycle
4
4
  const ANCIENT_CYCLE_MULTIPLIER = 2
5
5
 
6
+ /** Jaro-Winkler score at/above which two org names are treated as near-duplicates. */
7
+ export const FUZZY_ORG_THRESHOLD = 0.92
8
+ /** Skip the O(n²) fuzzy pass above this org count to bound the comparison cost. */
9
+ export const FUZZY_ORG_MAX = 2000
10
+
6
11
  function openDeals(data) {
7
12
  return data.deals.filter((d) => d.status === 'open')
8
13
  }
@@ -159,11 +164,48 @@ export const AUDIT_CHECKS = [
159
164
  for (const org of data.organizations) {
160
165
  const key = normalizeOrgName(org.name)
161
166
  if (!key) continue
162
- byName.set(key, [...(byName.get(key) ?? []), org.id])
167
+ const group = byName.get(key) ?? { ids: [], original: org.name }
168
+ group.ids.push(org.id)
169
+ byName.set(key, group)
170
+ }
171
+ const exact = [...byName.entries()]
172
+ .filter(([, group]) => group.ids.length > 1)
173
+ .map(([name, group]) => ({ kind: 'exact', name, ids: group.ids }))
174
+
175
+ // Normalized names that did NOT collide exactly are fuzzy candidates;
176
+ // exact-duplicate groups are already reported above, so skip them here.
177
+ const singles = [...byName.entries()].filter(
178
+ ([, group]) => group.ids.length === 1,
179
+ )
180
+
181
+ if (singles.length > FUZZY_ORG_MAX) {
182
+ return [
183
+ ...exact,
184
+ {
185
+ kind: 'note',
186
+ note: `Skipped fuzzy near-duplicate scan: ${singles.length} organizations exceed the ${FUZZY_ORG_MAX} cap.`,
187
+ },
188
+ ]
163
189
  }
164
- return [...byName.entries()]
165
- .filter(([, ids]) => ids.length > 1)
166
- .map(([name, ids]) => ({ name, ids }))
190
+
191
+ const fuzzy = []
192
+ for (let i = 0; i < singles.length; i++) {
193
+ for (let j = i + 1; j < singles.length; j++) {
194
+ const [nameA, groupA] = singles[i]
195
+ const [nameB, groupB] = singles[j]
196
+ const score = jaroWinkler(nameA, nameB)
197
+ if (score >= FUZZY_ORG_THRESHOLD) {
198
+ fuzzy.push({
199
+ kind: 'fuzzy',
200
+ // Report original spellings so the orgs are findable in Pipedrive.
201
+ names: [groupA.original, groupB.original],
202
+ ids: [groupA.ids[0], groupB.ids[0]].sort((a, b) => a - b),
203
+ score: Math.round(score * 10000) / 10000,
204
+ })
205
+ }
206
+ }
207
+ }
208
+ return [...exact, ...fuzzy]
167
209
  },
168
210
  },
169
211
  {
@@ -203,6 +245,62 @@ function normalizeOrgName(name) {
203
245
  .replace(/[^a-z0-9]/g, '')
204
246
  }
205
247
 
248
+ /**
249
+ * Jaro-Winkler string similarity in [0, 1] (1 = identical). Standard variant:
250
+ * Winkler prefix bonus with scale 0.1 over a max common prefix of 4.
251
+ * Pure and symmetric. Two empty strings score 1; one empty string scores 0.
252
+ * @param {string} a
253
+ * @param {string} b
254
+ * @returns {number}
255
+ */
256
+ export function jaroWinkler(a, b) {
257
+ if (a === b) return 1
258
+ const lenA = a.length
259
+ const lenB = b.length
260
+ if (lenA === 0 || lenB === 0) return 0
261
+
262
+ const matchWindow = Math.max(0, Math.floor(Math.max(lenA, lenB) / 2) - 1)
263
+ const matchedA = new Array(lenA).fill(false)
264
+ const matchedB = new Array(lenB).fill(false)
265
+
266
+ let matches = 0
267
+ for (let i = 0; i < lenA; i++) {
268
+ const start = Math.max(0, i - matchWindow)
269
+ const end = Math.min(i + matchWindow + 1, lenB)
270
+ for (let j = start; j < end; j++) {
271
+ if (matchedB[j] || a[i] !== b[j]) continue
272
+ matchedA[i] = true
273
+ matchedB[j] = true
274
+ matches++
275
+ break
276
+ }
277
+ }
278
+ if (matches === 0) return 0
279
+
280
+ // Count transpositions: matched chars out of order between the two strings.
281
+ let transpositions = 0
282
+ let k = 0
283
+ for (let i = 0; i < lenA; i++) {
284
+ if (!matchedA[i]) continue
285
+ while (!matchedB[k]) k++
286
+ if (a[i] !== b[k]) transpositions++
287
+ k++
288
+ }
289
+ transpositions /= 2
290
+
291
+ const jaro =
292
+ (matches / lenA + matches / lenB + (matches - transpositions) / matches) / 3
293
+
294
+ // Winkler prefix bonus, capped at 4 shared leading characters.
295
+ let prefix = 0
296
+ const maxPrefix = Math.min(4, lenA, lenB)
297
+ for (let i = 0; i < maxPrefix; i++) {
298
+ if (a[i] !== b[i]) break
299
+ prefix++
300
+ }
301
+ return jaro + prefix * 0.1 * (1 - jaro)
302
+ }
303
+
206
304
  /**
207
305
  * Run all (or a subset of) hygiene checks over pre-fetched account data.
208
306
  * @param {{ deals: object[], persons: object[], organizations: object[], activities: object[] }} data
@@ -214,13 +312,16 @@ export function runChecks(data, { now, only } = {}) {
214
312
  (check) => {
215
313
  const overdueTotal = check.name === 'overdue-activities'
216
314
  const items = check.run(data, { now })
315
+ // Informational rows (kind:'note') stay in items but are not findings,
316
+ // so they never contribute to the count consumers branch on.
317
+ const findings = items.filter((i) => i.kind !== 'note')
217
318
  return {
218
319
  name: check.name,
219
320
  severity: check.severity,
220
321
  title: check.title,
221
322
  count: overdueTotal
222
- ? items.reduce((sum, i) => sum + i.overdue, 0)
223
- : items.length,
323
+ ? findings.reduce((sum, i) => sum + i.overdue, 0)
324
+ : findings.length,
224
325
  items,
225
326
  }
226
327
  },
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,34 @@ 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)
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
263
+ }
264
+ return transport('POST', lockedUrl(path), { path, makeBody })
265
+ }
266
+
267
+ /**
268
+ * POST application/x-www-form-urlencoded (v1 form endpoints, e.g.
269
+ * /api/v1/files/remoteLink — JSON is not accepted there).
270
+ * @param {string} path
271
+ * @param {Record<string, unknown>} fields Null/undefined values are omitted.
272
+ */
273
+ async function postForm(path, fields = {}) {
274
+ const params = new URLSearchParams()
246
275
  for (const [k, v] of Object.entries(fields)) {
247
- if (v != null) form.set(k, String(v))
276
+ if (v != null) params.set(k, String(v))
248
277
  }
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),
278
+ return transport('POST', lockedUrl(path), {
279
+ path,
280
+ makeBody: () => params.toString(),
281
+ extraHeaders: { 'content-type': 'application/x-www-form-urlencoded' },
254
282
  })
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
283
  }
262
284
 
263
285
  return {
@@ -268,6 +290,7 @@ export function createClient({
268
290
  del: (path, opts) => request('DELETE', path, opts),
269
291
  download,
270
292
  postMultipart,
293
+ postForm,
271
294
  pageV1,
272
295
  pageV2,
273
296
  }
@@ -1,10 +1,23 @@
1
1
  /**
2
2
  * @param {string} message
3
3
  * @param {boolean} skipConfirm
4
+ * @param {{ default?: boolean }} [options] Forwarded to the inquirer prompt.
5
+ * Omit to preserve inquirer's native default (true).
4
6
  * @returns {Promise<boolean>}
5
7
  */
6
- export async function confirmAction(message, skipConfirm) {
8
+ export async function confirmAction(message, skipConfirm, options) {
7
9
  if (skipConfirm) return true
8
10
  const { confirm } = await import('@inquirer/prompts')
9
- return confirm({ message })
11
+ const promptOptions = { message }
12
+ if (options && Object.prototype.hasOwnProperty.call(options, 'default')) {
13
+ promptOptions.default = options.default
14
+ }
15
+ try {
16
+ return await confirm(promptOptions)
17
+ } catch (err) {
18
+ // Ctrl-C / closed stdin (CI, piping) force-closes the prompt — treat it
19
+ // as a "no" so callers abort cleanly instead of surfacing exit 70.
20
+ if (err?.name === 'ExitPromptError') return false
21
+ throw err
22
+ }
10
23
  }
@@ -10,15 +10,21 @@ import { flattenRecord } from './output/record.js'
10
10
  * for entities without resolvable custom fields (notes, files, webhooks, …)
11
11
  */
12
12
  export async function outputRecord(cmd, record, entity) {
13
- if (cmd.resolveFormat() === 'table') {
14
- if (
15
- entity &&
16
- record.custom_fields &&
17
- Object.keys(record.custom_fields).length
18
- ) {
19
- const defs = await getFields(cmd.apiClient, entity)
20
- record = makeResolver(defs).resolveCustomFields(record)
21
- }
13
+ const isTable = cmd.resolveFormat() === 'table'
14
+
15
+ // Table always resolves custom fields for readability; non-table formats
16
+ // stay raw for scriptability unless --resolve-fields opts in.
17
+ if (
18
+ (isTable || cmd.flags['resolve-fields']) &&
19
+ entity &&
20
+ record.custom_fields &&
21
+ Object.keys(record.custom_fields).length
22
+ ) {
23
+ const defs = await getFields(cmd.apiClient, entity)
24
+ record = makeResolver(defs).resolveCustomFields(record)
25
+ }
26
+
27
+ if (isTable) {
22
28
  await cmd.outputResults(flattenRecord(record), {
23
29
  field: { header: 'Field' },
24
30
  value: { header: 'Value' },
package/src/lib/fields.js CHANGED
@@ -105,7 +105,10 @@ export function makeResolver(defs) {
105
105
 
106
106
  const resolved = {}
107
107
  for (const [key, value] of Object.entries(record.custom_fields)) {
108
- const name = this.keyToName(key) ?? key
108
+ let name = this.keyToName(key) ?? key
109
+ // Duplicate field names exist in real accounts — disambiguate with
110
+ // a key fragment rather than silently clobbering the first value.
111
+ if (name in resolved) name = `${name} (${key.slice(0, 8)})`
109
112
  let displayValue = value
110
113
  if (Array.isArray(value)) {
111
114
  displayValue = value.map((v) => this.optionIdToLabel(key, v) ?? v)