@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.
@@ -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 { getToken } from './keychain.js'
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.token Personal API token (sent as x-api-token).
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
- token,
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 = companyDomainToBaseOrigin(companyDomain)
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
+ }
@@ -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
+ }
@@ -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
  }