@wavyx/pdcli 0.1.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 +24 -0
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/bin/run.js +20 -0
- package/oclif.manifest.json +2308 -0
- package/package.json +128 -0
- package/src/base-command.js +99 -0
- package/src/commands/activity/get.js +41 -0
- package/src/commands/activity/list.js +61 -0
- package/src/commands/api.js +60 -0
- package/src/commands/auth/login.js +79 -0
- package/src/commands/auth/logout.js +24 -0
- package/src/commands/auth/status.js +62 -0
- package/src/commands/config/get.js +30 -0
- package/src/commands/config/list.js +25 -0
- package/src/commands/config/set.js +29 -0
- package/src/commands/deal/get.js +43 -0
- package/src/commands/deal/list.js +62 -0
- package/src/commands/doctor.js +123 -0
- package/src/commands/field/get.js +58 -0
- package/src/commands/field/list.js +41 -0
- package/src/commands/org/get.js +41 -0
- package/src/commands/org/list.js +40 -0
- package/src/commands/person/get.js +41 -0
- package/src/commands/person/list.js +49 -0
- package/src/commands/profile/current.js +16 -0
- package/src/commands/profile/list.js +36 -0
- package/src/commands/profile/use.js +26 -0
- package/src/commands/search.js +67 -0
- package/src/commands/user/me.js +26 -0
- package/src/commands/version.js +24 -0
- package/src/hooks/command-not-found.js +15 -0
- package/src/hooks/init.js +7 -0
- package/src/hooks/prerun.js +7 -0
- package/src/lib/auth.js +95 -0
- package/src/lib/body.js +26 -0
- package/src/lib/client.js +184 -0
- package/src/lib/config.js +71 -0
- package/src/lib/errors.js +118 -0
- package/src/lib/fields.js +120 -0
- package/src/lib/keychain.js +69 -0
- package/src/lib/output/index.js +22 -0
- package/src/lib/output/json.js +7 -0
- package/src/lib/output/record.js +27 -0
- package/src/lib/output/table.js +40 -0
- package/src/lib/pagination.js +14 -0
package/src/lib/auth.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import createDebug from 'debug'
|
|
2
|
+
import { getToken } from './keychain.js'
|
|
3
|
+
import { getProfileConfig } from './config.js'
|
|
4
|
+
import { AuthRequiredError, ConfigError } from './errors.js'
|
|
5
|
+
|
|
6
|
+
const debug = createDebug('pd:auth')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize user input ("acme", "acme.pipedrive.com",
|
|
10
|
+
* "https://acme.pipedrive.com/") to the bare company subdomain.
|
|
11
|
+
* @param {string} input
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeCompanyDomain(input) {
|
|
15
|
+
return input
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^https?:\/\//, '')
|
|
18
|
+
.replace(/\.pipedrive\.com\/?.*$/, '')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} companyDomain
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function companyDomainToBaseOrigin(companyDomain) {
|
|
26
|
+
return `https://${companyDomain}.pipedrive.com`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} ResolvedCredentials
|
|
31
|
+
* @property {string} companyDomain
|
|
32
|
+
* @property {string} token
|
|
33
|
+
* @property {'flags' | 'env' | 'profile'} source Where the token came from.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve company domain and API token independently, each with
|
|
38
|
+
* flags → env → profile-config/keychain precedence.
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {object} [options.flags]
|
|
41
|
+
* @param {string} [options.flags.company]
|
|
42
|
+
* @param {string} [options.flags."api-token"]
|
|
43
|
+
* @param {string} [options.profile]
|
|
44
|
+
* @returns {Promise<ResolvedCredentials>}
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveCredentials({ flags, profile } = {}) {
|
|
47
|
+
let companyDomain
|
|
48
|
+
if (flags?.company) {
|
|
49
|
+
companyDomain = flags.company
|
|
50
|
+
} else if (process.env.PDCLI_COMPANY_DOMAIN) {
|
|
51
|
+
companyDomain = process.env.PDCLI_COMPANY_DOMAIN
|
|
52
|
+
} else if (profile) {
|
|
53
|
+
companyDomain = getProfileConfig(profile, 'company_domain')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!companyDomain) {
|
|
57
|
+
throw new ConfigError(
|
|
58
|
+
'No company domain configured. Run: pdcli auth login ' +
|
|
59
|
+
'(or set PDCLI_COMPANY_DOMAIN)',
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
companyDomain = normalizeCompanyDomain(companyDomain)
|
|
63
|
+
|
|
64
|
+
let token
|
|
65
|
+
let source
|
|
66
|
+
if (flags?.['api-token']) {
|
|
67
|
+
token = flags['api-token']
|
|
68
|
+
source = 'flags'
|
|
69
|
+
} else if (process.env.PDCLI_API_TOKEN) {
|
|
70
|
+
token = process.env.PDCLI_API_TOKEN
|
|
71
|
+
source = 'env'
|
|
72
|
+
} else if (profile) {
|
|
73
|
+
token = await getToken(profile)
|
|
74
|
+
source = 'profile'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!token) {
|
|
78
|
+
throw new AuthRequiredError()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
debug('resolved credentials for %s (token from %s)', companyDomain, source)
|
|
82
|
+
return { companyDomain, token, source }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate a token by fetching the authenticated user.
|
|
87
|
+
* Note: the Users API has no v2 equivalent (June 2026) — /api/v2/users/me
|
|
88
|
+
* 404s into the web app's HTML page, so this must stay on v1.
|
|
89
|
+
* @param {{ get: (path: string) => Promise<{data: object}> }} client
|
|
90
|
+
* @returns {Promise<object>} the Pipedrive user object
|
|
91
|
+
*/
|
|
92
|
+
export async function validateToken(client) {
|
|
93
|
+
const body = await client.get('/api/v1/users/me')
|
|
94
|
+
return body.data
|
|
95
|
+
}
|
package/src/lib/body.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { CliError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {object} flags
|
|
6
|
+
* @param {string} [flags.body]
|
|
7
|
+
* @returns {Promise<string>}
|
|
8
|
+
*/
|
|
9
|
+
export async function resolveBody(flags) {
|
|
10
|
+
if (flags.body) {
|
|
11
|
+
if (flags.body.startsWith('@')) {
|
|
12
|
+
return readFileSync(flags.body.slice(1), 'utf8')
|
|
13
|
+
}
|
|
14
|
+
return flags.body
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!process.stdin.isTTY) {
|
|
18
|
+
const chunks = []
|
|
19
|
+
for await (const chunk of process.stdin) {
|
|
20
|
+
chunks.push(chunk)
|
|
21
|
+
}
|
|
22
|
+
return Buffer.concat(chunks).toString('utf8').trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
throw new CliError('--body is required', { exitCode: 2 })
|
|
26
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import createDebug from 'debug'
|
|
2
|
+
import {
|
|
3
|
+
ApiError,
|
|
4
|
+
CliError,
|
|
5
|
+
RateLimitError,
|
|
6
|
+
ServiceUnavailableError,
|
|
7
|
+
} from './errors.js'
|
|
8
|
+
import { companyDomainToBaseOrigin } from './auth.js'
|
|
9
|
+
|
|
10
|
+
const debug = createDebug('pd:client')
|
|
11
|
+
|
|
12
|
+
/** Pipedrive caps list/search page sizes at 500. */
|
|
13
|
+
const MAX_PAGE_LIMIT = 500
|
|
14
|
+
/** Default seconds to wait on a 429 without rate-limit headers (2s burst window). */
|
|
15
|
+
const DEFAULT_RETRY_AFTER_S = 2
|
|
16
|
+
|
|
17
|
+
function jitter() {
|
|
18
|
+
return Math.floor(Math.random() * 1000)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clampLimit(query) {
|
|
26
|
+
if (query?.limit && Number(query.limit) > MAX_PAGE_LIMIT) {
|
|
27
|
+
return { ...query, limit: MAX_PAGE_LIMIT }
|
|
28
|
+
}
|
|
29
|
+
return query
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} options
|
|
34
|
+
* @param {string} options.companyDomain Bare subdomain ("acme") — forms and
|
|
35
|
+
* locks the base origin https://acme.pipedrive.com.
|
|
36
|
+
* @param {string} options.token Personal API token (sent as x-api-token).
|
|
37
|
+
* @param {number} [options.timeout]
|
|
38
|
+
* @param {boolean} [options.retry]
|
|
39
|
+
* @param {string} [options.userAgent]
|
|
40
|
+
*/
|
|
41
|
+
export function createClient({
|
|
42
|
+
companyDomain,
|
|
43
|
+
token,
|
|
44
|
+
timeout = 30_000,
|
|
45
|
+
retry = true,
|
|
46
|
+
userAgent = 'pdcli',
|
|
47
|
+
}) {
|
|
48
|
+
const baseOrigin = companyDomainToBaseOrigin(companyDomain)
|
|
49
|
+
|
|
50
|
+
async function request(method, path, { body, query } = {}) {
|
|
51
|
+
const url = new URL(path, baseOrigin)
|
|
52
|
+
if (url.origin !== baseOrigin) {
|
|
53
|
+
throw new CliError(
|
|
54
|
+
`Refusing to send request outside your Pipedrive company host ` +
|
|
55
|
+
`(${baseOrigin}): ${url.origin}`,
|
|
56
|
+
{ exitCode: 78 },
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
if (query) {
|
|
60
|
+
for (const [k, v] of Object.entries(query)) {
|
|
61
|
+
if (v == null) continue
|
|
62
|
+
if (Array.isArray(v)) {
|
|
63
|
+
url.searchParams.set(k, v.join(','))
|
|
64
|
+
} else {
|
|
65
|
+
url.searchParams.set(k, String(v))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const maxAttempts = retry ? 3 : 1
|
|
71
|
+
let attempts = 0
|
|
72
|
+
let sawRateLimit = false
|
|
73
|
+
|
|
74
|
+
while (attempts < maxAttempts) {
|
|
75
|
+
attempts++
|
|
76
|
+
|
|
77
|
+
const headers = {
|
|
78
|
+
'x-api-token': token,
|
|
79
|
+
'content-type': 'application/json',
|
|
80
|
+
'user-agent': userAgent,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
method,
|
|
85
|
+
headers,
|
|
86
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
87
|
+
signal: AbortSignal.timeout(timeout),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
debug('%s %s → %d', method, path, res.status)
|
|
91
|
+
|
|
92
|
+
if (res.status === 429) {
|
|
93
|
+
const wait = Number(
|
|
94
|
+
res.headers.get('x-ratelimit-reset') ||
|
|
95
|
+
res.headers.get('retry-after') ||
|
|
96
|
+
DEFAULT_RETRY_AFTER_S,
|
|
97
|
+
)
|
|
98
|
+
if (!retry) throw new RateLimitError(wait)
|
|
99
|
+
sawRateLimit = true
|
|
100
|
+
debug('rate limited, waiting %ds', wait)
|
|
101
|
+
await sleep(wait * 1000)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pipedrive escalates persistent rate-limit abuse from 429 to 403 —
|
|
106
|
+
// treat that as a hard stop, never retry into it.
|
|
107
|
+
if (res.status === 403 && sawRateLimit) {
|
|
108
|
+
const text = await res.text()
|
|
109
|
+
const err = ApiError.fromResponse(res.status, text, path)
|
|
110
|
+
err.message += ' (403 after 429: rate-limit escalation — stopping)'
|
|
111
|
+
throw err
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (res.status >= 500 && attempts < maxAttempts) {
|
|
115
|
+
const delay = Math.min(1000 * 2 ** attempts, 30_000) + jitter()
|
|
116
|
+
debug('server error %d, retrying in %dms', res.status, delay)
|
|
117
|
+
await sleep(delay)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (res.status === 204) return null
|
|
122
|
+
|
|
123
|
+
const text = await res.text()
|
|
124
|
+
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
throw ApiError.fromResponse(res.status, text, path)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return text ? JSON.parse(text) : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new ServiceUnavailableError()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* v2 cursor pagination: cursor/limit → additional_data.next_cursor.
|
|
137
|
+
* @param {string} path
|
|
138
|
+
* @param {object} [query]
|
|
139
|
+
* @returns {AsyncGenerator<object>}
|
|
140
|
+
*/
|
|
141
|
+
async function* pageV2(path, query = {}) {
|
|
142
|
+
let cursor
|
|
143
|
+
const baseQuery = clampLimit(query)
|
|
144
|
+
while (true) {
|
|
145
|
+
const envelope = await request('GET', path, {
|
|
146
|
+
query: cursor ? { ...baseQuery, cursor } : baseQuery,
|
|
147
|
+
})
|
|
148
|
+
yield* envelope?.data ?? []
|
|
149
|
+
cursor = envelope?.additional_data?.next_cursor
|
|
150
|
+
if (!cursor) break
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* v1 offset pagination: start/limit →
|
|
156
|
+
* additional_data.pagination.{more_items_in_collection,next_start}.
|
|
157
|
+
* @param {string} path
|
|
158
|
+
* @param {object} [query]
|
|
159
|
+
* @returns {AsyncGenerator<object>}
|
|
160
|
+
*/
|
|
161
|
+
async function* pageV1(path, query = {}) {
|
|
162
|
+
let start
|
|
163
|
+
const baseQuery = clampLimit(query)
|
|
164
|
+
while (true) {
|
|
165
|
+
const envelope = await request('GET', path, {
|
|
166
|
+
query: start != null ? { ...baseQuery, start } : baseQuery,
|
|
167
|
+
})
|
|
168
|
+
yield* envelope?.data ?? []
|
|
169
|
+
const pagination = envelope?.additional_data?.pagination
|
|
170
|
+
if (!pagination?.more_items_in_collection) break
|
|
171
|
+
start = pagination.next_start
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
get: (path, opts) => request('GET', path, opts),
|
|
177
|
+
post: (path, opts) => request('POST', path, opts),
|
|
178
|
+
put: (path, opts) => request('PUT', path, opts),
|
|
179
|
+
patch: (path, opts) => request('PATCH', path, opts),
|
|
180
|
+
del: (path, opts) => request('DELETE', path, opts),
|
|
181
|
+
pageV1,
|
|
182
|
+
pageV2,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import Conf from 'conf'
|
|
2
|
+
|
|
3
|
+
const schema = {
|
|
4
|
+
activeProfile: { type: 'string', default: 'default' },
|
|
5
|
+
profiles: {
|
|
6
|
+
type: 'object',
|
|
7
|
+
default: {},
|
|
8
|
+
},
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @type {Conf | undefined} */
|
|
12
|
+
let _conf
|
|
13
|
+
|
|
14
|
+
export function getConf() {
|
|
15
|
+
_conf ??= new Conf({ projectName: 'pdcli', schema })
|
|
16
|
+
return _conf
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} [profileFlag]
|
|
21
|
+
* @returns {{ activeProfile: string, [key: string]: unknown }}
|
|
22
|
+
*/
|
|
23
|
+
export function loadConfig(profileFlag) {
|
|
24
|
+
const conf = getConf()
|
|
25
|
+
const activeProfile = profileFlag ?? conf.get('activeProfile')
|
|
26
|
+
const profileData = conf.get(`profiles.${activeProfile}`) ?? {}
|
|
27
|
+
return { activeProfile, ...profileData }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getActiveProfile() {
|
|
31
|
+
return getConf().get('activeProfile')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {string} name */
|
|
35
|
+
export function setActiveProfile(name) {
|
|
36
|
+
getConf().set('activeProfile', name)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} profile
|
|
41
|
+
* @param {string} key
|
|
42
|
+
*/
|
|
43
|
+
export function getProfileConfig(profile, key) {
|
|
44
|
+
return getConf().get(`profiles.${profile}.${key}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} profile
|
|
49
|
+
* @param {string} key
|
|
50
|
+
* @param {unknown} value
|
|
51
|
+
*/
|
|
52
|
+
export function setProfileConfig(profile, key, value) {
|
|
53
|
+
getConf().set(`profiles.${profile}.${key}`, value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string} profile */
|
|
57
|
+
export function getProfileData(profile) {
|
|
58
|
+
return getConf().get(`profiles.${profile}`) ?? {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getAllProfiles() {
|
|
62
|
+
return getConf().get('profiles')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} profile
|
|
67
|
+
* @param {string} key
|
|
68
|
+
*/
|
|
69
|
+
export function deleteProfileConfig(profile, key) {
|
|
70
|
+
getConf().delete(`profiles.${profile}.${key}`)
|
|
71
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
/** @param {string} message @param {{exitCode?: number, cause?: Error}} [options] */
|
|
3
|
+
constructor(message, { exitCode = 1, cause } = {}) {
|
|
4
|
+
super(message, { cause })
|
|
5
|
+
this.exitCode = exitCode
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class AuthRequiredError extends CliError {
|
|
10
|
+
constructor() {
|
|
11
|
+
super('Not authenticated. Run: pdcli auth login', { exitCode: 77 })
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ConfigError extends CliError {
|
|
16
|
+
/** @param {string} message */
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message, { exitCode: 78 })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class RateLimitError extends CliError {
|
|
23
|
+
/** @param {number} retryAfter */
|
|
24
|
+
constructor(retryAfter) {
|
|
25
|
+
super(`Rate limited. Retry after ${retryAfter}s`, { exitCode: 75 })
|
|
26
|
+
this.retryAfter = retryAfter
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ServiceUnavailableError extends CliError {
|
|
31
|
+
constructor() {
|
|
32
|
+
super('Pipedrive API is unavailable', { exitCode: 69 })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Exit-code ladder per spec §9 (sysexits). */
|
|
37
|
+
function exitCodeForStatus(statusCode) {
|
|
38
|
+
if (statusCode === 400 || statusCode === 422) return 65
|
|
39
|
+
if (statusCode === 401 || statusCode === 403) return 77
|
|
40
|
+
if (statusCode === 402) return 78
|
|
41
|
+
if (statusCode === 429) return 75
|
|
42
|
+
if (statusCode >= 500) return 69
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ApiError extends CliError {
|
|
47
|
+
/**
|
|
48
|
+
* @param {number} statusCode
|
|
49
|
+
* @param {object} body Pipedrive error envelope { success, error, error_info }
|
|
50
|
+
* @param {string} path
|
|
51
|
+
*/
|
|
52
|
+
constructor(statusCode, body, path) {
|
|
53
|
+
const message = body?.error || body?.message || `API error ${statusCode}`
|
|
54
|
+
|
|
55
|
+
super(`Pipedrive API ${statusCode}: ${message}`, {
|
|
56
|
+
exitCode: exitCodeForStatus(statusCode),
|
|
57
|
+
})
|
|
58
|
+
this.statusCode = statusCode
|
|
59
|
+
this.path = path
|
|
60
|
+
this.body = body
|
|
61
|
+
this.errorInfo = body?.error_info
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {number} statusCode
|
|
66
|
+
* @param {string} text
|
|
67
|
+
* @param {string} path
|
|
68
|
+
*/
|
|
69
|
+
static fromResponse(statusCode, text, path) {
|
|
70
|
+
let body
|
|
71
|
+
try {
|
|
72
|
+
body = JSON.parse(text)
|
|
73
|
+
} catch {
|
|
74
|
+
// Non-JSON bodies (e.g. HTML error pages) can be huge — truncate.
|
|
75
|
+
const truncated = text.length > 200 ? `${text.slice(0, 200)}…` : text
|
|
76
|
+
body = { message: truncated }
|
|
77
|
+
}
|
|
78
|
+
return new ApiError(statusCode, body, path)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Error} err
|
|
84
|
+
* @param {import('@oclif/core').Command} cmd
|
|
85
|
+
*/
|
|
86
|
+
export function handleError(err, cmd) {
|
|
87
|
+
const exitCode = err.exitCode ?? 70
|
|
88
|
+
const flags = cmd.flags ?? {}
|
|
89
|
+
|
|
90
|
+
if (flags.output === 'json') {
|
|
91
|
+
const payload = {
|
|
92
|
+
error: err.constructor.name,
|
|
93
|
+
message: err.message,
|
|
94
|
+
exitCode,
|
|
95
|
+
}
|
|
96
|
+
if (err instanceof ApiError) {
|
|
97
|
+
payload.statusCode = err.statusCode
|
|
98
|
+
payload.path = err.path
|
|
99
|
+
if (err.errorInfo) payload.errorInfo = err.errorInfo
|
|
100
|
+
if (flags.verbose) payload.body = err.body
|
|
101
|
+
}
|
|
102
|
+
process.stderr.write(JSON.stringify(payload, null, 2) + '\n')
|
|
103
|
+
cmd.exit(exitCode)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (flags.verbose && err instanceof ApiError) {
|
|
107
|
+
process.stderr.write(`\nRequest path: ${err.path}\n`)
|
|
108
|
+
process.stderr.write(`Status code: ${err.statusCode}\n`)
|
|
109
|
+
if (err.errorInfo) process.stderr.write(`Error info: ${err.errorInfo}\n`)
|
|
110
|
+
if (err.body) {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
`Response body:\n${JSON.stringify(err.body, null, 2)}\n`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
cmd.error(err.message, { exit: exitCode })
|
|
118
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import createDebug from 'debug'
|
|
2
|
+
import { CliError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
const debug = createDebug('pd:fields')
|
|
5
|
+
|
|
6
|
+
/** Entity (and aliases) → v2 fields endpoint. */
|
|
7
|
+
const ENTITY_FIELDS = {
|
|
8
|
+
deal: 'dealFields',
|
|
9
|
+
person: 'personFields',
|
|
10
|
+
org: 'organizationFields',
|
|
11
|
+
organization: 'organizationFields',
|
|
12
|
+
product: 'productFields',
|
|
13
|
+
activity: 'activityFields',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} entity deal | person | org(anization) | product | activity
|
|
18
|
+
* @returns {string} v2 fields endpoint path
|
|
19
|
+
*/
|
|
20
|
+
export function entityToFieldsPath(entity) {
|
|
21
|
+
const resource = ENTITY_FIELDS[entity]
|
|
22
|
+
if (!resource) {
|
|
23
|
+
throw new CliError(
|
|
24
|
+
`Unknown entity "${entity}". Use one of: ${Object.keys(ENTITY_FIELDS).join(', ')}`,
|
|
25
|
+
{ exitCode: 64 },
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
return `/api/v2/${resource}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, object[]>} per-run field-definition cache */
|
|
32
|
+
const cache = new Map()
|
|
33
|
+
|
|
34
|
+
export function clearFieldsCache() {
|
|
35
|
+
cache.clear()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch (and memoize for this run) all field definitions for an entity.
|
|
40
|
+
* @param {{ pageV2: (path: string) => AsyncGenerator<object> }} client
|
|
41
|
+
* @param {string} entity
|
|
42
|
+
* @returns {Promise<object[]>}
|
|
43
|
+
*/
|
|
44
|
+
export async function getFields(client, entity) {
|
|
45
|
+
const path = entityToFieldsPath(entity)
|
|
46
|
+
if (cache.has(path)) return cache.get(path)
|
|
47
|
+
|
|
48
|
+
debug('fetching field definitions: %s', path)
|
|
49
|
+
const defs = []
|
|
50
|
+
for await (const def of client.pageV2(path)) {
|
|
51
|
+
defs.push(def)
|
|
52
|
+
}
|
|
53
|
+
cache.set(path, defs)
|
|
54
|
+
return defs
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a resolver for name⇄key and option label⇄ID lookups.
|
|
59
|
+
* @param {object[]} defs v2 field definitions
|
|
60
|
+
* ({ field_code, field_name, field_type, options })
|
|
61
|
+
*/
|
|
62
|
+
export function makeResolver(defs) {
|
|
63
|
+
const byName = new Map()
|
|
64
|
+
const byKey = new Map()
|
|
65
|
+
for (const def of defs) {
|
|
66
|
+
byName.set(def.field_name.toLowerCase(), def)
|
|
67
|
+
byKey.set(def.field_code, def)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function optionsOf(fieldKey) {
|
|
71
|
+
return byKey.get(fieldKey)?.options
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
/** @param {string} name @returns {string | undefined} hashed key */
|
|
76
|
+
nameToKey(name) {
|
|
77
|
+
return byName.get(name.toLowerCase())?.field_code
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/** @param {string} key @returns {string | undefined} human name */
|
|
81
|
+
keyToName(key) {
|
|
82
|
+
return byKey.get(key)?.field_name
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** @param {string} fieldKey @param {string} label @returns {number | undefined} */
|
|
86
|
+
labelToOptionId(fieldKey, label) {
|
|
87
|
+
return optionsOf(fieldKey)?.find(
|
|
88
|
+
(o) => o.label.toLowerCase() === label.toLowerCase(),
|
|
89
|
+
)?.id
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/** @param {string} fieldKey @param {number} id @returns {string | undefined} */
|
|
93
|
+
optionIdToLabel(fieldKey, id) {
|
|
94
|
+
return optionsOf(fieldKey)?.find((o) => o.id === id)?.label
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Return a copy of the record with custom_fields hash keys replaced by
|
|
99
|
+
* human names and option IDs by labels (for table display; JSON output
|
|
100
|
+
* stays raw).
|
|
101
|
+
* @param {object} record
|
|
102
|
+
*/
|
|
103
|
+
resolveCustomFields(record) {
|
|
104
|
+
if (!record?.custom_fields) return record
|
|
105
|
+
|
|
106
|
+
const resolved = {}
|
|
107
|
+
for (const [key, value] of Object.entries(record.custom_fields)) {
|
|
108
|
+
const name = this.keyToName(key) ?? key
|
|
109
|
+
let displayValue = value
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
displayValue = value.map((v) => this.optionIdToLabel(key, v) ?? v)
|
|
112
|
+
} else if (value != null) {
|
|
113
|
+
displayValue = this.optionIdToLabel(key, value) ?? value
|
|
114
|
+
}
|
|
115
|
+
resolved[name] = displayValue
|
|
116
|
+
}
|
|
117
|
+
return { ...record, custom_fields: resolved }
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import createDebug from 'debug'
|
|
2
|
+
import { CliError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
const debug = createDebug('pd:keychain')
|
|
5
|
+
const SERVICE = 'pdcli'
|
|
6
|
+
|
|
7
|
+
/** @type {typeof import('@napi-rs/keyring').Entry | null} */
|
|
8
|
+
let Entry = null
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const mod = await import('@napi-rs/keyring')
|
|
12
|
+
Entry = mod.Entry
|
|
13
|
+
debug('using OS keychain via @napi-rs/keyring')
|
|
14
|
+
} catch {
|
|
15
|
+
debug('OS keychain unavailable')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getEntry(account) {
|
|
19
|
+
return new Entry(SERVICE, account)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function keychainRequired() {
|
|
23
|
+
throw new CliError(
|
|
24
|
+
'OS keychain unavailable. pdcli stores credentials in your operating system ' +
|
|
25
|
+
'keychain (macOS Keychain, Windows Credential Manager, or libsecret on Linux) ' +
|
|
26
|
+
'and refuses to write them to disk in plaintext. Enable a system keychain and retry.',
|
|
27
|
+
{ exitCode: 78 },
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} profile
|
|
33
|
+
* @returns {Promise<string | null>}
|
|
34
|
+
*/
|
|
35
|
+
export async function getToken(profile) {
|
|
36
|
+
if (!Entry) return null
|
|
37
|
+
const account = `${profile}/token`
|
|
38
|
+
try {
|
|
39
|
+
return getEntry(account).getPassword() || null
|
|
40
|
+
} catch (err) {
|
|
41
|
+
debug('getToken error: %s', err.message)
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} profile
|
|
48
|
+
* @param {string} token
|
|
49
|
+
*/
|
|
50
|
+
export async function setToken(profile, token) {
|
|
51
|
+
if (!Entry) keychainRequired()
|
|
52
|
+
const account = `${profile}/token`
|
|
53
|
+
getEntry(account).setPassword(token)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string} profile */
|
|
57
|
+
export async function deleteToken(profile) {
|
|
58
|
+
if (!Entry) return
|
|
59
|
+
const account = `${profile}/token`
|
|
60
|
+
try {
|
|
61
|
+
getEntry(account).deletePassword()
|
|
62
|
+
} catch (err) {
|
|
63
|
+
debug('deleteToken error: %s', err.message)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isKeychainAvailable() {
|
|
68
|
+
return Entry !== null
|
|
69
|
+
}
|