@wavyx/pdcli 0.1.0 → 0.2.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 +21 -1
- package/README.md +46 -11
- package/oclif.manifest.json +2523 -240
- package/package.json +2 -1
- package/src/base-command.js +35 -5
- package/src/commands/activity/create.js +63 -0
- package/src/commands/activity/delete.js +39 -0
- package/src/commands/activity/get.js +2 -17
- package/src/commands/activity/update.js +89 -0
- package/src/commands/auth/login.js +102 -7
- package/src/commands/auth/logout.js +2 -1
- package/src/commands/auth/status.js +61 -13
- package/src/commands/deal/create.js +65 -0
- package/src/commands/deal/delete.js +39 -0
- package/src/commands/deal/get.js +2 -19
- package/src/commands/deal/update.js +79 -0
- package/src/commands/org/create.js +42 -0
- package/src/commands/org/delete.js +39 -0
- package/src/commands/org/get.js +2 -17
- package/src/commands/org/update.js +57 -0
- package/src/commands/person/create.js +54 -0
- package/src/commands/person/delete.js +39 -0
- package/src/commands/person/get.js +2 -17
- package/src/commands/person/update.js +69 -0
- package/src/commands/product/create.js +62 -0
- package/src/commands/product/delete.js +39 -0
- package/src/commands/product/get.js +26 -0
- package/src/commands/product/list.js +45 -0
- package/src/commands/product/update.js +77 -0
- package/src/lib/auth.js +227 -3
- package/src/lib/client.js +29 -6
- package/src/lib/confirm.js +10 -0
- package/src/lib/entity-view.js +37 -0
- package/src/lib/input.js +110 -0
- package/src/lib/keychain.js +47 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core'
|
|
2
|
+
import BaseCommand from '../../base-command.js'
|
|
3
|
+
import { buildWriteBody } from '../../lib/input.js'
|
|
4
|
+
import { defsForFields, outputRecord } from '../../lib/entity-view.js'
|
|
5
|
+
import { CliError } from '../../lib/errors.js'
|
|
6
|
+
|
|
7
|
+
export default class ProductUpdateCommand extends BaseCommand {
|
|
8
|
+
static description =
|
|
9
|
+
'Update a product (v2 PATCH — only provided fields change)'
|
|
10
|
+
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> product update 7 --name "New name"',
|
|
13
|
+
'<%= config.bin %> product update 7 --price 12.50 --currency USD',
|
|
14
|
+
'<%= config.bin %> product update 7 --field "Material=Steel"',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
static args = {
|
|
18
|
+
id: Args.integer({ required: true, description: 'Product ID' }),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static flags = {
|
|
22
|
+
...BaseCommand.baseFlags,
|
|
23
|
+
name: Flags.string({ description: 'Product name' }),
|
|
24
|
+
code: Flags.string({ description: 'Product code (SKU)' }),
|
|
25
|
+
unit: Flags.string({ description: 'Unit of measure' }),
|
|
26
|
+
description: Flags.string({ description: 'Product description' }),
|
|
27
|
+
owner: Flags.integer({ description: 'Owner (user) ID' }),
|
|
28
|
+
price: Flags.string({
|
|
29
|
+
description: 'Unit price (requires --currency)',
|
|
30
|
+
dependsOn: ['currency'],
|
|
31
|
+
}),
|
|
32
|
+
currency: Flags.string({
|
|
33
|
+
description: 'Price currency (requires --price)',
|
|
34
|
+
dependsOn: ['price'],
|
|
35
|
+
}),
|
|
36
|
+
field: Flags.string({
|
|
37
|
+
multiple: true,
|
|
38
|
+
description: 'Custom/standard field as "Name=Value" (repeatable)',
|
|
39
|
+
}),
|
|
40
|
+
body: Flags.string({ description: 'Raw JSON body to merge (flags win)' }),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async run() {
|
|
44
|
+
const { args, flags } = await this.parse(ProductUpdateCommand)
|
|
45
|
+
|
|
46
|
+
const prices =
|
|
47
|
+
flags.price !== undefined && flags.currency !== undefined
|
|
48
|
+
? [{ price: Number(flags.price), currency: flags.currency }]
|
|
49
|
+
: undefined
|
|
50
|
+
|
|
51
|
+
const body = buildWriteBody({
|
|
52
|
+
typed: {
|
|
53
|
+
name: flags.name,
|
|
54
|
+
code: flags.code,
|
|
55
|
+
unit: flags.unit,
|
|
56
|
+
description: flags.description,
|
|
57
|
+
owner_id: flags.owner,
|
|
58
|
+
prices,
|
|
59
|
+
},
|
|
60
|
+
fields: flags.field,
|
|
61
|
+
rawBody: flags.body,
|
|
62
|
+
defs: await defsForFields(this, 'product', flags.field),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (Object.keys(body).length === 0) {
|
|
66
|
+
throw new CliError(
|
|
67
|
+
'Nothing to update — pass at least one field flag, --field, or --body',
|
|
68
|
+
{ exitCode: 64 },
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const res = await this.apiClient.patch(`/api/v2/products/${args.id}`, {
|
|
73
|
+
body,
|
|
74
|
+
})
|
|
75
|
+
await outputRecord(this, res.data, 'product')
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/lib/auth.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
1
3
|
import createDebug from 'debug'
|
|
2
|
-
import
|
|
4
|
+
import open from 'open'
|
|
5
|
+
import { getToken, getOAuthTokens, setOAuthTokens } from './keychain.js'
|
|
3
6
|
import { getProfileConfig } from './config.js'
|
|
4
|
-
import { AuthRequiredError, ConfigError } from './errors.js'
|
|
7
|
+
import { ApiError, AuthRequiredError, CliError, ConfigError } from './errors.js'
|
|
5
8
|
|
|
6
9
|
const debug = createDebug('pd:auth')
|
|
7
10
|
|
|
11
|
+
const AUTH_URL = 'https://oauth.pipedrive.com/oauth/authorize'
|
|
12
|
+
const TOKEN_URL = 'https://oauth.pipedrive.com/oauth/token'
|
|
13
|
+
const REFRESH_BUFFER_MS = 5 * 60 * 1000
|
|
14
|
+
const REDIRECT_PORT = 9999
|
|
15
|
+
|
|
8
16
|
/**
|
|
9
17
|
* Normalize user input ("acme", "acme.pipedrive.com",
|
|
10
18
|
* "https://acme.pipedrive.com/") to the bare company subdomain.
|
|
@@ -44,6 +52,26 @@ export function companyDomainToBaseOrigin(companyDomain) {
|
|
|
44
52
|
* @returns {Promise<ResolvedCredentials>}
|
|
45
53
|
*/
|
|
46
54
|
export async function resolveCredentials({ flags, profile } = {}) {
|
|
55
|
+
// Explicit token via flags/env always wins. Otherwise a profile in OAuth
|
|
56
|
+
// mode resolves to its (auto-refreshed) access token + api_domain.
|
|
57
|
+
const explicitToken = flags?.['api-token'] || process.env.PDCLI_API_TOKEN
|
|
58
|
+
if (
|
|
59
|
+
!explicitToken &&
|
|
60
|
+
profile &&
|
|
61
|
+
getProfileConfig(profile, 'auth_mode') === 'oauth'
|
|
62
|
+
) {
|
|
63
|
+
const access = await getValidOAuthAccess(profile)
|
|
64
|
+
if (!access) throw new AuthRequiredError()
|
|
65
|
+
debug('resolved OAuth credentials for %s', access.apiDomain)
|
|
66
|
+
return {
|
|
67
|
+
mode: 'oauth',
|
|
68
|
+
apiDomain: access.apiDomain,
|
|
69
|
+
token: access.accessToken,
|
|
70
|
+
oauth: access,
|
|
71
|
+
source: 'profile',
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
47
75
|
let companyDomain
|
|
48
76
|
if (flags?.company) {
|
|
49
77
|
companyDomain = flags.company
|
|
@@ -79,7 +107,203 @@ export async function resolveCredentials({ flags, profile } = {}) {
|
|
|
79
107
|
}
|
|
80
108
|
|
|
81
109
|
debug('resolved credentials for %s (token from %s)', companyDomain, source)
|
|
82
|
-
return { companyDomain, token, source }
|
|
110
|
+
return { mode: 'token', companyDomain, token, source }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Exchange an authorization code for tokens. Pipedrive authenticates the
|
|
115
|
+
* token endpoint with HTTP Basic (client_id:client_secret) and returns the
|
|
116
|
+
* account's api_domain, which becomes the API base URL.
|
|
117
|
+
* @param {{ code: string, clientId: string, clientSecret: string, redirectUri: string }} options
|
|
118
|
+
*/
|
|
119
|
+
export async function exchangeCode({
|
|
120
|
+
code,
|
|
121
|
+
clientId,
|
|
122
|
+
clientSecret,
|
|
123
|
+
redirectUri,
|
|
124
|
+
}) {
|
|
125
|
+
return tokenRequest(
|
|
126
|
+
{ grant_type: 'authorization_code', code, redirect_uri: redirectUri },
|
|
127
|
+
{ clientId, clientSecret },
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {{ refreshToken: string, clientId: string, clientSecret: string }} options
|
|
133
|
+
*/
|
|
134
|
+
export async function refreshAccessToken({
|
|
135
|
+
refreshToken,
|
|
136
|
+
clientId,
|
|
137
|
+
clientSecret,
|
|
138
|
+
}) {
|
|
139
|
+
debug('refreshing access token')
|
|
140
|
+
return tokenRequest(
|
|
141
|
+
{ grant_type: 'refresh_token', refresh_token: refreshToken },
|
|
142
|
+
{ clientId, clientSecret },
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function tokenRequest(params, { clientId, clientSecret }) {
|
|
147
|
+
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
|
|
148
|
+
const res = await fetch(TOKEN_URL, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: {
|
|
151
|
+
authorization: `Basic ${basic}`,
|
|
152
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
153
|
+
},
|
|
154
|
+
body: new URLSearchParams(params),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const body = await res.json()
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
throw ApiError.fromResponse(res.status, JSON.stringify(body), TOKEN_URL)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
accessToken: body.access_token,
|
|
164
|
+
refreshToken: body.refresh_token,
|
|
165
|
+
expiresIn: body.expires_in,
|
|
166
|
+
apiDomain: body.api_domain,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Browser-based OAuth authorization-code flow via a local loopback server.
|
|
172
|
+
* @param {object} options
|
|
173
|
+
* @param {string} options.clientId
|
|
174
|
+
* @param {string} options.clientSecret
|
|
175
|
+
* @param {number} [options.timeout]
|
|
176
|
+
* @param {number} [options.port] Loopback callback port (must match the
|
|
177
|
+
* callback URL registered in the Pipedrive Developer Hub app). Default 9999.
|
|
178
|
+
* @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number, apiDomain: string}>}
|
|
179
|
+
*/
|
|
180
|
+
export function authorizationCodeFlow({
|
|
181
|
+
clientId,
|
|
182
|
+
clientSecret,
|
|
183
|
+
timeout = 120_000,
|
|
184
|
+
port = REDIRECT_PORT,
|
|
185
|
+
}) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const state = randomBytes(16).toString('hex')
|
|
188
|
+
let timer
|
|
189
|
+
|
|
190
|
+
const server = createServer(async (req, res) => {
|
|
191
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
192
|
+
if (url.pathname !== '/callback') {
|
|
193
|
+
res.writeHead(404)
|
|
194
|
+
res.end()
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const code = url.searchParams.get('code')
|
|
199
|
+
const returnedState = url.searchParams.get('state')
|
|
200
|
+
|
|
201
|
+
if (returnedState !== state) {
|
|
202
|
+
res.writeHead(400, { 'content-type': 'text/html' })
|
|
203
|
+
res.end(
|
|
204
|
+
'<h2>Authentication failed: state mismatch (possible CSRF attack)</h2>',
|
|
205
|
+
)
|
|
206
|
+
clearTimeout(timer)
|
|
207
|
+
server.close()
|
|
208
|
+
reject(
|
|
209
|
+
new CliError('OAuth state mismatch — possible CSRF attack', {
|
|
210
|
+
exitCode: 77,
|
|
211
|
+
}),
|
|
212
|
+
)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!code) {
|
|
217
|
+
res.writeHead(400, { 'content-type': 'text/html' })
|
|
218
|
+
res.end(
|
|
219
|
+
'<h2>Authentication failed: no authorization code received</h2>',
|
|
220
|
+
)
|
|
221
|
+
clearTimeout(timer)
|
|
222
|
+
server.close()
|
|
223
|
+
reject(new CliError('No authorization code received', { exitCode: 77 }))
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const tokens = await exchangeCode({
|
|
229
|
+
code,
|
|
230
|
+
clientId,
|
|
231
|
+
clientSecret,
|
|
232
|
+
redirectUri: `http://127.0.0.1:${server.address().port}/callback`,
|
|
233
|
+
})
|
|
234
|
+
res.writeHead(200, { 'content-type': 'text/html' })
|
|
235
|
+
res.end('<h2>Authenticated! You can close this window.</h2>')
|
|
236
|
+
clearTimeout(timer)
|
|
237
|
+
server.close()
|
|
238
|
+
resolve(tokens)
|
|
239
|
+
} catch (err) {
|
|
240
|
+
res.writeHead(500, { 'content-type': 'text/html' })
|
|
241
|
+
res.end(`<h2>Authentication failed: ${err.message}</h2>`)
|
|
242
|
+
clearTimeout(timer)
|
|
243
|
+
server.close()
|
|
244
|
+
reject(err)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
server.on('error', (err) => {
|
|
249
|
+
clearTimeout(timer)
|
|
250
|
+
reject(
|
|
251
|
+
new CliError(
|
|
252
|
+
`Cannot start the local callback server on port ${port}: ${err.message}. ` +
|
|
253
|
+
'If the port is in use, close whatever is using it and run `pdcli auth login --oauth` again.',
|
|
254
|
+
{ exitCode: 78 },
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
server.listen(port, '127.0.0.1', () => {
|
|
260
|
+
const boundPort = server.address().port
|
|
261
|
+
const redirectUri = `http://127.0.0.1:${boundPort}/callback`
|
|
262
|
+
const authUrl = `${AUTH_URL}?client_id=${encodeURIComponent(clientId)}&state=${state}&redirect_uri=${encodeURIComponent(redirectUri)}`
|
|
263
|
+
debug('opening browser for auth: %s', authUrl)
|
|
264
|
+
open(authUrl)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
timer = setTimeout(() => {
|
|
268
|
+
server.close()
|
|
269
|
+
reject(
|
|
270
|
+
new CliError(
|
|
271
|
+
`Authentication timed out after ${timeout / 1000}s. Try again.`,
|
|
272
|
+
{ exitCode: 77 },
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
}, timeout)
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Return a valid OAuth access token for the profile, transparently
|
|
281
|
+
* refreshing (and persisting) when within the expiry buffer.
|
|
282
|
+
* @param {string} profile
|
|
283
|
+
* @returns {Promise<import('./keychain.js').OAuthTokens | null>}
|
|
284
|
+
*/
|
|
285
|
+
export async function getValidOAuthAccess(profile) {
|
|
286
|
+
const tokens = await getOAuthTokens(profile)
|
|
287
|
+
if (!tokens) return null
|
|
288
|
+
|
|
289
|
+
const now = Date.now()
|
|
290
|
+
if (tokens.expiresAt - now > REFRESH_BUFFER_MS) {
|
|
291
|
+
return tokens
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const refreshed = await refreshAccessToken({
|
|
295
|
+
refreshToken: tokens.refreshToken,
|
|
296
|
+
clientId: tokens.clientId,
|
|
297
|
+
clientSecret: tokens.clientSecret,
|
|
298
|
+
})
|
|
299
|
+
const updated = {
|
|
300
|
+
...tokens,
|
|
301
|
+
accessToken: refreshed.accessToken,
|
|
302
|
+
refreshToken: refreshed.refreshToken,
|
|
303
|
+
expiresAt: now + refreshed.expiresIn * 1000,
|
|
304
|
+
}
|
|
305
|
+
await setOAuthTokens(profile, updated)
|
|
306
|
+
return updated
|
|
83
307
|
}
|
|
84
308
|
|
|
85
309
|
/**
|
package/src/lib/client.js
CHANGED
|
@@ -31,21 +31,33 @@ function clampLimit(query) {
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
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.
|
|
34
|
+
* @param {string} [options.companyDomain] Bare subdomain ("acme") — forms and
|
|
35
|
+
* locks the base origin https://acme.pipedrive.com (token mode).
|
|
36
|
+
* @param {string} [options.apiDomain] Full origin from the OAuth token
|
|
37
|
+
* response (e.g. https://acme.pipedrive.com) — used and locked in OAuth mode.
|
|
38
|
+
* @param {string} options.token Personal API token (x-api-token header) or
|
|
39
|
+
* OAuth access token (Authorization: Bearer) depending on authMode.
|
|
40
|
+
* @param {'token' | 'oauth'} [options.authMode]
|
|
41
|
+
* @param {() => Promise<string>} [options.onRefresh] OAuth-mode callback
|
|
42
|
+
* invoked once on a 401; returns a fresh access token to retry with.
|
|
37
43
|
* @param {number} [options.timeout]
|
|
38
44
|
* @param {boolean} [options.retry]
|
|
39
45
|
* @param {string} [options.userAgent]
|
|
40
46
|
*/
|
|
41
47
|
export function createClient({
|
|
42
48
|
companyDomain,
|
|
43
|
-
|
|
49
|
+
apiDomain,
|
|
50
|
+
token: initialToken,
|
|
51
|
+
authMode = 'token',
|
|
52
|
+
onRefresh,
|
|
44
53
|
timeout = 30_000,
|
|
45
54
|
retry = true,
|
|
46
55
|
userAgent = 'pdcli',
|
|
47
56
|
}) {
|
|
48
|
-
const baseOrigin =
|
|
57
|
+
const baseOrigin = apiDomain
|
|
58
|
+
? new URL(apiDomain).origin
|
|
59
|
+
: companyDomainToBaseOrigin(companyDomain)
|
|
60
|
+
let token = initialToken
|
|
49
61
|
|
|
50
62
|
async function request(method, path, { body, query } = {}) {
|
|
51
63
|
const url = new URL(path, baseOrigin)
|
|
@@ -75,10 +87,14 @@ export function createClient({
|
|
|
75
87
|
attempts++
|
|
76
88
|
|
|
77
89
|
const headers = {
|
|
78
|
-
'x-api-token': token,
|
|
79
90
|
'content-type': 'application/json',
|
|
80
91
|
'user-agent': userAgent,
|
|
81
92
|
}
|
|
93
|
+
if (authMode === 'oauth') {
|
|
94
|
+
headers.authorization = `Bearer ${token}`
|
|
95
|
+
} else {
|
|
96
|
+
headers['x-api-token'] = token
|
|
97
|
+
}
|
|
82
98
|
|
|
83
99
|
const res = await fetch(url, {
|
|
84
100
|
method,
|
|
@@ -102,6 +118,13 @@ export function createClient({
|
|
|
102
118
|
continue
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
// OAuth access tokens expire (~1h) — refresh once and retry.
|
|
122
|
+
if (res.status === 401 && onRefresh && attempts === 1) {
|
|
123
|
+
debug('401, attempting OAuth token refresh')
|
|
124
|
+
token = await onRefresh()
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
// Pipedrive escalates persistent rate-limit abuse from 429 to 403 —
|
|
106
129
|
// treat that as a hard stop, never retry into it.
|
|
107
130
|
if (res.status === 403 && sawRateLimit) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} message
|
|
3
|
+
* @param {boolean} skipConfirm
|
|
4
|
+
* @returns {Promise<boolean>}
|
|
5
|
+
*/
|
|
6
|
+
export async function confirmAction(message, skipConfirm) {
|
|
7
|
+
if (skipConfirm) return true
|
|
8
|
+
const { confirm } = await import('@inquirer/prompts')
|
|
9
|
+
return confirm({ message })
|
|
10
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getFields, makeResolver } from './fields.js'
|
|
2
|
+
import { flattenRecord } from './output/record.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Output a single entity record: raw JSON for scripting, or a transposed
|
|
6
|
+
* field/value table with custom-field hash keys and option IDs resolved.
|
|
7
|
+
* @param {import('../base-command.js').default} cmd
|
|
8
|
+
* @param {object} record
|
|
9
|
+
* @param {string} entity deal | person | org | activity | product
|
|
10
|
+
*/
|
|
11
|
+
export async function outputRecord(cmd, record, entity) {
|
|
12
|
+
if (cmd.resolveFormat() === 'table') {
|
|
13
|
+
if (record.custom_fields && Object.keys(record.custom_fields).length) {
|
|
14
|
+
const defs = await getFields(cmd.apiClient, entity)
|
|
15
|
+
record = makeResolver(defs).resolveCustomFields(record)
|
|
16
|
+
}
|
|
17
|
+
await cmd.outputResults(flattenRecord(record), {
|
|
18
|
+
field: { header: 'Field' },
|
|
19
|
+
value: { header: 'Value' },
|
|
20
|
+
})
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await cmd.outputResults(record, {})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetch field definitions only when --field entries are present.
|
|
29
|
+
* @param {import('../base-command.js').default} cmd
|
|
30
|
+
* @param {string} entity
|
|
31
|
+
* @param {string[]} [fieldFlags]
|
|
32
|
+
* @returns {Promise<object[] | undefined>}
|
|
33
|
+
*/
|
|
34
|
+
export async function defsForFields(cmd, entity, fieldFlags) {
|
|
35
|
+
if (!fieldFlags?.length) return undefined
|
|
36
|
+
return getFields(cmd.apiClient, entity)
|
|
37
|
+
}
|
package/src/lib/input.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { CliError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
const NUMERIC_TYPES = new Set(['double', 'monetary', 'int'])
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a write-request body from typed flags, repeatable --field entries,
|
|
7
|
+
* and a raw --body JSON string. Precedence: raw body < typed flags/fields.
|
|
8
|
+
*
|
|
9
|
+
* --field entries are "Name=Value" where Name is a field's human name, its
|
|
10
|
+
* field_code, or a 40-char custom-field hash. Enum labels resolve to option
|
|
11
|
+
* IDs; set values are comma-separated labels; numeric field types coerce to
|
|
12
|
+
* Number. Custom-field values nest under custom_fields (v2 shape).
|
|
13
|
+
*
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {Record<string, unknown>} [options.typed] API-named values from typed flags
|
|
16
|
+
* @param {string[]} [options.fields] repeatable --field "Name=Value" entries
|
|
17
|
+
* @param {string} [options.rawBody] raw JSON string from --body
|
|
18
|
+
* @param {object[]} [options.defs] field definitions (required when fields given)
|
|
19
|
+
* @returns {object} request body
|
|
20
|
+
*/
|
|
21
|
+
export function buildWriteBody({
|
|
22
|
+
typed = {},
|
|
23
|
+
fields = [],
|
|
24
|
+
rawBody,
|
|
25
|
+
defs,
|
|
26
|
+
} = {}) {
|
|
27
|
+
let body = {}
|
|
28
|
+
|
|
29
|
+
if (rawBody) {
|
|
30
|
+
try {
|
|
31
|
+
body = JSON.parse(rawBody)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new CliError(`--body is not valid JSON: ${err.message}`, {
|
|
34
|
+
exitCode: 65,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const [key, value] of Object.entries(typed)) {
|
|
40
|
+
if (value !== undefined) body[key] = value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const entry of fields) {
|
|
44
|
+
const eq = entry.indexOf('=')
|
|
45
|
+
if (eq === -1) {
|
|
46
|
+
throw new CliError(`Invalid --field "${entry}". Expected Name=Value`, {
|
|
47
|
+
exitCode: 64,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
const name = entry.slice(0, eq).trim()
|
|
51
|
+
const rawValue = entry.slice(eq + 1)
|
|
52
|
+
|
|
53
|
+
const def = findField(defs ?? [], name)
|
|
54
|
+
if (!def) {
|
|
55
|
+
throw new CliError(
|
|
56
|
+
`Unknown field "${name}". Run: pdcli field list <entity>`,
|
|
57
|
+
{ exitCode: 65 },
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const value = coerceValue(def, rawValue)
|
|
62
|
+
|
|
63
|
+
if (def.is_custom_field ?? isHashKey(def.field_code)) {
|
|
64
|
+
body.custom_fields ??= {}
|
|
65
|
+
body.custom_fields[def.field_code] = value
|
|
66
|
+
} else {
|
|
67
|
+
body[def.field_code] = value
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return body
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isHashKey(s) {
|
|
75
|
+
return /^[a-f0-9]{40}$/.test(s)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findField(defs, name) {
|
|
79
|
+
const lower = name.toLowerCase()
|
|
80
|
+
return defs.find(
|
|
81
|
+
(d) => d.field_name.toLowerCase() === lower || d.field_code === name,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function coerceValue(def, rawValue) {
|
|
86
|
+
if (def.field_type === 'enum') {
|
|
87
|
+
return resolveOption(def, rawValue)
|
|
88
|
+
}
|
|
89
|
+
if (def.field_type === 'set') {
|
|
90
|
+
return rawValue.split(',').map((label) => resolveOption(def, label.trim()))
|
|
91
|
+
}
|
|
92
|
+
if (NUMERIC_TYPES.has(def.field_type)) {
|
|
93
|
+
return Number(rawValue)
|
|
94
|
+
}
|
|
95
|
+
return rawValue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveOption(def, label) {
|
|
99
|
+
const option = def.options?.find(
|
|
100
|
+
(o) => o.label.toLowerCase() === label.toLowerCase(),
|
|
101
|
+
)
|
|
102
|
+
if (!option) {
|
|
103
|
+
const valid = def.options?.map((o) => o.label).join(', ') ?? '(none)'
|
|
104
|
+
throw new CliError(
|
|
105
|
+
`Unknown option "${label}" for field "${def.field_name}". Valid: ${valid}`,
|
|
106
|
+
{ exitCode: 65 },
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return option.id
|
|
110
|
+
}
|
package/src/lib/keychain.js
CHANGED
|
@@ -64,6 +64,53 @@ export async function deleteToken(profile) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} OAuthTokens
|
|
69
|
+
* @property {string} accessToken
|
|
70
|
+
* @property {string} refreshToken
|
|
71
|
+
* @property {number} expiresAt epoch ms
|
|
72
|
+
* @property {string} apiDomain e.g. https://acme.pipedrive.com
|
|
73
|
+
* @property {string} clientId
|
|
74
|
+
* @property {string} clientSecret kept in the keychain, never in config
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} profile
|
|
79
|
+
* @returns {Promise<OAuthTokens | null>}
|
|
80
|
+
*/
|
|
81
|
+
export async function getOAuthTokens(profile) {
|
|
82
|
+
if (!Entry) return null
|
|
83
|
+
const account = `${profile}/oauth`
|
|
84
|
+
try {
|
|
85
|
+
const raw = getEntry(account).getPassword()
|
|
86
|
+
return raw ? JSON.parse(raw) : null
|
|
87
|
+
} catch (err) {
|
|
88
|
+
debug('getOAuthTokens error: %s', err.message)
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} profile
|
|
95
|
+
* @param {OAuthTokens} tokens
|
|
96
|
+
*/
|
|
97
|
+
export async function setOAuthTokens(profile, tokens) {
|
|
98
|
+
if (!Entry) keychainRequired()
|
|
99
|
+
const account = `${profile}/oauth`
|
|
100
|
+
getEntry(account).setPassword(JSON.stringify(tokens))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {string} profile */
|
|
104
|
+
export async function deleteOAuthTokens(profile) {
|
|
105
|
+
if (!Entry) return
|
|
106
|
+
const account = `${profile}/oauth`
|
|
107
|
+
try {
|
|
108
|
+
getEntry(account).deletePassword()
|
|
109
|
+
} catch (err) {
|
|
110
|
+
debug('deleteOAuthTokens error: %s', err.message)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
67
114
|
export function isKeychainAvailable() {
|
|
68
115
|
return Entry !== null
|
|
69
116
|
}
|