@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +32 -0
  4. package/bin/run.js +20 -0
  5. package/oclif.manifest.json +2308 -0
  6. package/package.json +128 -0
  7. package/src/base-command.js +99 -0
  8. package/src/commands/activity/get.js +41 -0
  9. package/src/commands/activity/list.js +61 -0
  10. package/src/commands/api.js +60 -0
  11. package/src/commands/auth/login.js +79 -0
  12. package/src/commands/auth/logout.js +24 -0
  13. package/src/commands/auth/status.js +62 -0
  14. package/src/commands/config/get.js +30 -0
  15. package/src/commands/config/list.js +25 -0
  16. package/src/commands/config/set.js +29 -0
  17. package/src/commands/deal/get.js +43 -0
  18. package/src/commands/deal/list.js +62 -0
  19. package/src/commands/doctor.js +123 -0
  20. package/src/commands/field/get.js +58 -0
  21. package/src/commands/field/list.js +41 -0
  22. package/src/commands/org/get.js +41 -0
  23. package/src/commands/org/list.js +40 -0
  24. package/src/commands/person/get.js +41 -0
  25. package/src/commands/person/list.js +49 -0
  26. package/src/commands/profile/current.js +16 -0
  27. package/src/commands/profile/list.js +36 -0
  28. package/src/commands/profile/use.js +26 -0
  29. package/src/commands/search.js +67 -0
  30. package/src/commands/user/me.js +26 -0
  31. package/src/commands/version.js +24 -0
  32. package/src/hooks/command-not-found.js +15 -0
  33. package/src/hooks/init.js +7 -0
  34. package/src/hooks/prerun.js +7 -0
  35. package/src/lib/auth.js +95 -0
  36. package/src/lib/body.js +26 -0
  37. package/src/lib/client.js +184 -0
  38. package/src/lib/config.js +71 -0
  39. package/src/lib/errors.js +118 -0
  40. package/src/lib/fields.js +120 -0
  41. package/src/lib/keychain.js +69 -0
  42. package/src/lib/output/index.js +22 -0
  43. package/src/lib/output/json.js +7 -0
  44. package/src/lib/output/record.js +27 -0
  45. package/src/lib/output/table.js +40 -0
  46. package/src/lib/pagination.js +14 -0
@@ -0,0 +1,7 @@
1
+ import createDebug from 'debug'
2
+
3
+ const debug = createDebug('pd:prerun')
4
+
5
+ export default async function prerun(options) {
6
+ debug('prerun: %s', options.Command?.id)
7
+ }
@@ -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
+ }
@@ -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
+ }