From e07d0f18e2772e3fdcbc1274dabe9831b3207f9c Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 9 Jul 2026 13:37:29 +0200 Subject: [PATCH 1/3] feat(dev-loop): quota, lookup, and a localhost mock endpoint - pdcli quota (alias ratelimit): reads the daily token-budget headers (x-daily-ratelimit-token-remaining/-limit) via one cheap GET; --min / --threshold gate CI with exit 75; absent headers print n/a, exit 0. The budget is company-wide, so treat the reading as a hint. - pdcli lookup --field --value : exact-match id lookup via GET /api/v2/itemSearch/field (name to hash resolved from the fields cache). --first yields one row for shell composition (--jq .id); no match exits 3 so scripts branch create-vs-update. Case-sensitive and eventually consistent. - Localhost mock: PDCLI_BASE_URL is honored only when it resolves to localhost/127.0.0.1/::1 (anything else exits 78, reaffirming the host-lock); a loud stderr banner prints; the real keychain token is never sent to the override host (PDCLI_API_TOKEN required). Prism recipe documented. - MCP: lookup and quota classified read; lookup added to the curated set. Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9 --- src/commands/lookup.js | 109 ++++++++++++ src/commands/quota.js | 100 ++++++++++++ src/lib/client.js | 78 ++++++++- src/lib/mcp/catalog.js | 3 + test/commands/lookup.test.js | 200 +++++++++++++++++++++++ test/commands/quota.test.js | 132 +++++++++++++++ test/lib/client.test.js | 154 +++++++++++++++++ test/lib/mcp/catalog.test.js | 8 +- website/astro.config.mjs | 1 + website/src/content/docs/guides/mock.mdx | 61 +++++++ 10 files changed, 840 insertions(+), 6 deletions(-) create mode 100644 src/commands/lookup.js create mode 100644 src/commands/quota.js create mode 100644 test/commands/lookup.test.js create mode 100644 test/commands/quota.test.js create mode 100644 website/src/content/docs/guides/mock.mdx diff --git a/src/commands/lookup.js b/src/commands/lookup.js new file mode 100644 index 0000000..d4c4167 --- /dev/null +++ b/src/commands/lookup.js @@ -0,0 +1,109 @@ +import { Args, Flags } from '@oclif/core' +import BaseCommand from '../base-command.js' +import { CliError } from '../lib/errors.js' +import { getFields, makeResolver } from '../lib/fields.js' + +// User-facing entity -> itemSearch/field entity_type. Only entities that have +// both a fields endpoint (for name->key resolution) and a searchable type are +// supported; `project` is intentionally omitted (no fields endpoint). +const ENTITY_TYPES = { + deal: 'deal', + person: 'person', + org: 'organization', + product: 'product', + lead: 'lead', +} + +const columns = { + id: { header: 'ID' }, + name: { header: 'Name / Title', get: (row) => row.title ?? row.name ?? '' }, +} + +export default class LookupCommand extends BaseCommand { + static description = + 'Find item IDs by an exact field value — the create-vs-update primitive.\n' + + 'Matching is CASE-SENSITIVE, and the search index is eventually consistent ' + + '(a just-created record is not immediately findable), so a lookup-then-create ' + + 'loop can double-create in fast pipelines. Exit 3 means no match (branch to ' + + 'create); exit 0 with rows means it exists.' + + static examples = [ + '<%= config.bin %> lookup deal --field "PO Number" --value PO-1234', + '<%= config.bin %> lookup person --field Email --value a@b.com --first --jq .id', + '<%= config.bin %> lookup org --field Name --value Acme --match beginning', + ] + + static args = { + entity: Args.string({ + required: true, + description: 'Item type to search', + options: Object.keys(ENTITY_TYPES), + }), + } + + static flags = { + ...BaseCommand.baseFlags, + field: Flags.string({ + required: true, + description: 'Field name (built-in or custom) to search on', + }), + value: Flags.string({ + required: true, + description: 'Value to match against the field', + }), + match: Flags.string({ + description: 'Match mode (case-sensitive)', + options: ['exact', 'beginning', 'middle'], + default: 'exact', + }), + first: Flags.boolean({ + description: 'Return only the first match (a single object)', + default: false, + }), + } + + async run() { + const { args, flags } = await this.parse(LookupCommand) + const entityType = ENTITY_TYPES[args.entity] + + const resolver = makeResolver(await getFields(this.apiClient, args.entity)) + const field = resolver.nameToKey(flags.field) + if (!field) { + throw new CliError(`Unknown field "${flags.field}" for ${args.entity}`, { + exitCode: 64, + }) + } + + // exact match allows a 1-char term; the other modes need at least 2. + const minLength = flags.match === 'exact' ? 1 : 2 + if (flags.value.length < minLength) { + throw new CliError( + `--value must be at least ${minLength} character(s) for --match ${flags.match}`, + { exitCode: 64 }, + ) + } + + // Search costs 20 rate-limit tokens; the field-search endpoint caps limit + // at 100 (single request, no auto-paging). + const body = await this.apiClient.get('/api/v2/itemSearch/field', { + query: { + term: flags.value, + entity_type: entityType, + field, + match: flags.match, + limit: flags.limit != null ? Math.min(flags.limit, 100) : undefined, + }, + }) + + const rows = (body.data ?? []).map((entry) => entry.item) + + if (rows.length === 0) { + throw new CliError( + `No ${args.entity} found where ${flags.field} = ${flags.value}`, + { exitCode: 3 }, + ) + } + + await this.outputResults(flags.first ? rows[0] : rows, columns) + } +} diff --git a/src/commands/quota.js b/src/commands/quota.js new file mode 100644 index 0000000..802de4f --- /dev/null +++ b/src/commands/quota.js @@ -0,0 +1,100 @@ +import { Flags } from '@oclif/core' +import BaseCommand from '../base-command.js' +import { CliError } from '../lib/errors.js' + +const columns = { + remaining: { + header: 'Remaining', + get: (r) => (r.daily.remaining == null ? 'n/a' : r.daily.remaining), + }, + limit: { + header: 'Limit', + get: (r) => (r.daily.limit == null ? 'n/a' : r.daily.limit), + }, + pct: { + header: '% Left', + get: (r) => (r.daily.pct == null ? 'n/a' : `${r.daily.pct}%`), + }, + reset: { + header: 'Burst reset (s)', + get: (r) => (r.reset == null ? 'n/a' : r.reset), + }, +} + +export default class QuotaCommand extends BaseCommand { + static aliases = ['ratelimit'] + + static description = + 'Show the remaining Pipedrive daily API token budget. Makes one cheap ' + + 'probe request purely to read the rate-limit headers. The budget is ' + + 'COMPANY-WIDE — shared across every integration and user on the account — ' + + 'so treat the reading as a hint, not a guarantee. --min / --threshold gate ' + + 'CI: they exit 75 (rate-limited) when the budget is too low.' + + static examples = [ + '<%= config.bin %> quota', + '<%= config.bin %> quota --output json', + '<%= config.bin %> quota --min 5000', + '<%= config.bin %> quota --threshold 10', + ] + + static flags = { + ...BaseCommand.baseFlags, + min: Flags.integer({ + description: + 'Exit 75 if the remaining daily token budget is below this many tokens', + min: 0, + }), + threshold: Flags.integer({ + description: + 'Exit 75 if the remaining daily token budget is below this percentage', + min: 0, + max: 100, + }), + } + + async run() { + const { flags } = await this.parse(QuotaCommand) + + // A GET-single-entity probe (~2 tokens) purely to populate the rate-limit + // headers. Users is v1-only (no v2 equivalent) and always exists. + await this.apiClient.get('/api/v1/users/me') + + // transport() populates lastRateLimit off every response, so a resolved + // probe GET above guarantees it is set (headers may still be absent). + const rl = this.apiClient.lastRateLimit + const remaining = rl.dailyRemaining + const limit = rl.dailyLimit + const hasData = remaining != null + const pct = + hasData && limit ? Math.round((remaining / limit) * 100) : undefined + + const data = { + daily: { + remaining: hasData ? remaining : null, + limit: limit ?? null, + pct: pct ?? null, + }, + reset: rl.reset ?? null, + } + + await this.outputResults(data, columns) + + // No headers → nothing to gate on. Never fabricate a pass/fail. + if (!hasData) return + + if (flags.min != null && remaining < flags.min) { + throw new CliError( + `Daily token budget ${remaining} is below --min ${flags.min}`, + { exitCode: 75 }, + ) + } + if (flags.threshold != null && pct != null && pct < flags.threshold) { + throw new CliError( + `Daily token budget ${pct}% remaining is below --threshold ` + + `${flags.threshold}%`, + { exitCode: 75 }, + ) + } + } +} diff --git a/src/lib/client.js b/src/lib/client.js index 37a3432..4e55f58 100644 --- a/src/lib/client.js +++ b/src/lib/client.js @@ -2,6 +2,7 @@ import createDebug from 'debug' import { ApiError, CliError, + ConfigError, RateLimitError, ServiceUnavailableError, } from './errors.js' @@ -29,6 +30,55 @@ function clampLimit(query) { return query } +/** Number-or-undefined: absent/NaN headers must never become NaN downstream. */ +function numHeader(value) { + if (value == null) return undefined + const n = Number(value) + return Number.isNaN(n) ? undefined : n +} + +/** + * Resolve the `PDCLI_BASE_URL` mock-endpoint override, a guarded escape hatch + * for pointing pdcli at a local Prism/WireMock server. It ONLY accepts a + * localhost origin — anything else hard-fails (78) to reaffirm the host-lock, + * never silently trusting an arbitrary host with your token. And because a + * mock server has no business receiving your real Pipedrive credential, an + * explicit env token (PDCLI_API_TOKEN, which may be a throwaway dummy) is + * required: a keychain/profile token is refused rather than leaked. + * @param {string} rawUrl + * @returns {string} the localhost origin to use as the base + */ +function resolveMockOrigin(rawUrl) { + let url + try { + url = new URL(rawUrl) + } catch { + throw new ConfigError(`PDCLI_BASE_URL is not a valid URL: ${rawUrl}`) + } + // URL.hostname wraps IPv6 in brackets, so ::1 arrives as "[::1]". + const host = url.hostname + const isLocal = + host === 'localhost' || host === '127.0.0.1' || host === '[::1]' + if (!isLocal) { + throw new ConfigError( + `PDCLI_BASE_URL is host-locked to localhost — refusing ${url.origin}. ` + + `Only localhost / 127.0.0.1 / ::1 are accepted so a stray host can ` + + `never receive your Pipedrive token.`, + ) + } + if (!process.env.PDCLI_API_TOKEN) { + throw new ConfigError( + `PDCLI_BASE_URL points at a mock endpoint (${url.origin}) but no ` + + `PDCLI_API_TOKEN is set. Refusing to send your stored keychain token ` + + `to the override host — set PDCLI_API_TOKEN= to use the mock.`, + ) + } + process.stderr.write( + `[pdcli] MOCK ENDPOINT — using ${url.origin} (host-lock bypassed)\n`, + ) + return url.origin +} + /** * @param {object} options * @param {string} [options.companyDomain] Bare subdomain ("acme") — forms and @@ -54,9 +104,11 @@ export function createClient({ retry = true, userAgent = 'pdcli', }) { - const baseOrigin = apiDomain - ? new URL(apiDomain).origin - : companyDomainToBaseOrigin(companyDomain) + const baseOrigin = process.env.PDCLI_BASE_URL + ? resolveMockOrigin(process.env.PDCLI_BASE_URL) + : apiDomain + ? new URL(apiDomain).origin + : companyDomainToBaseOrigin(companyDomain) let token = initialToken async function request(method, path, { body, query } = {}) { @@ -213,6 +265,17 @@ export function createClient({ debug('%s %s → %d', method, path, res.status) + // Snapshot the daily token-budget headers off EVERY response (including + // 429s) so `pdcli quota` can read the last-seen budget without a fresh + // request. Missing/NaN headers stay undefined — never fabricated. + api.lastRateLimit = { + dailyRemaining: numHeader( + res.headers.get('x-daily-ratelimit-token-remaining'), + ), + dailyLimit: numHeader(res.headers.get('x-daily-ratelimit-token-limit')), + reset: numHeader(res.headers.get('x-ratelimit-reset')), + } + if (res.status === 429) { // Daily-budget exhaustion has no useful reset window — backoff would // stall until the daily reset. Fail fast with an actionable message. @@ -372,7 +435,7 @@ export function createClient({ }) } - return { + const api = { get: (path, opts) => request('GET', path, opts), post: (path, opts) => request('POST', path, opts), put: (path, opts) => request('PUT', path, opts), @@ -384,5 +447,12 @@ export function createClient({ putForm, pageV1, pageV2, + /** + * Daily token-budget + burst-reset from the most recent response, or + * undefined until the first request. Populated by transport(). + * @type {{ dailyRemaining?: number, dailyLimit?: number, reset?: number } | undefined} + */ + lastRateLimit: undefined, } + return api } diff --git a/src/lib/mcp/catalog.js b/src/lib/mcp/catalog.js index 98b72c2..968ae9e 100644 --- a/src/lib/mcp/catalog.js +++ b/src/lib/mcp/catalog.js @@ -75,6 +75,8 @@ export const READ_IDS = new Set([ 'audit:stage-skips', 'digest', 'funnel', + 'lookup', + 'quota', ]) // Leaf verbs that mutate (documented for the audit test; classifyKind treats @@ -152,6 +154,7 @@ export const CURATED = new Set([ 'user:list', // reads — search + deal intelligence 'search', + 'lookup', 'deal:context', 'deal:history', 'deal:summary', diff --git a/test/commands/lookup.test.js b/test/commands/lookup.test.js new file mode 100644 index 0000000..0d019d4 --- /dev/null +++ b/test/commands/lookup.test.js @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import nock from 'nock' + +const mockResolveCredentials = vi.fn() +vi.mock('../../src/lib/auth.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, resolveCredentials: mockResolveCredentials } +}) +vi.mock('../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), +})) + +const { default: LookupCommand } = await import('../../src/commands/lookup.js') +import { runCmd, mockApi } from '../helpers.js' +import { clearFieldsCache } from '../../src/lib/fields.js' + +const HASH = '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b' + +/** Mock the dealFields lookup so `po_number` resolves to HASH. */ +function mockDealFields() { + mockApi() + .get('/api/v2/dealFields') + .query(true) + .reply(200, { + success: true, + data: [{ field_code: HASH, field_name: 'PO Number' }], + }) +} + +describe('lookup', () => { + beforeEach(() => { + clearFieldsCache() + mockResolveCredentials.mockResolvedValue({ + mode: 'token', + companyDomain: 'acme', + token: 'test-token', + }) + }) + + afterEach(() => { + nock.cleanAll() + vi.clearAllMocks() + }) + + it('resolves a custom field name to its hash and queries itemSearch/field', async () => { + mockDealFields() + const scope = mockApi() + .get('/api/v2/itemSearch/field') + .query({ + term: 'PO-1234', + entity_type: 'deal', + field: HASH, + match: 'exact', + }) + .reply(200, { + success: true, + data: [{ result_score: 1, item: { id: 42 } }], + }) + + const stdout = await runCmd(LookupCommand, [ + 'deal', + '--field', + 'PO Number', + '--value', + 'PO-1234', + '--output', + 'json', + ]) + + expect(scope.isDone()).toBe(true) + expect(JSON.parse(stdout)).toEqual([{ id: 42 }]) + }) + + it('maps org to the organization entity_type', async () => { + mockApi() + .get('/api/v2/organizationFields') + .query(true) + .reply(200, { + success: true, + data: [{ field_code: 'name', field_name: 'Name' }], + }) + const scope = mockApi() + .get('/api/v2/itemSearch/field') + .query((q) => q.entity_type === 'organization' && q.field === 'name') + .reply(200, { success: true, data: [{ item: { id: 7 } }] }) + + await runCmd(LookupCommand, [ + 'org', + '--field', + 'Name', + '--value', + 'Acme', + '--output', + 'json', + ]) + expect(scope.isDone()).toBe(true) + }) + + it('--first returns only the first match as a single object', async () => { + mockDealFields() + mockApi() + .get('/api/v2/itemSearch/field') + .query(true) + .reply(200, { + success: true, + data: [{ item: { id: 42 } }, { item: { id: 43 } }], + }) + + const stdout = await runCmd(LookupCommand, [ + 'deal', + '--field', + 'PO Number', + '--value', + 'PO-1234', + '--first', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual({ id: 42 }) + }) + + it('exits 3 when there is no match (branch create-vs-update)', async () => { + mockDealFields() + mockApi() + .get('/api/v2/itemSearch/field') + .query(true) + .reply(200, { success: true, data: [] }) + + const err = await LookupCommand.run([ + 'deal', + '--field', + 'PO Number', + '--value', + 'nope', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(3) + }) + + it('treats a response with no data array as no match (exit 3)', async () => { + mockDealFields() + mockApi() + .get('/api/v2/itemSearch/field') + .query(true) + .reply(200, { success: true }) + + const err = await LookupCommand.run([ + 'deal', + '--field', + 'PO Number', + '--value', + 'nope', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(3) + }) + + it('exits 64 for an unknown field name', async () => { + mockDealFields() + const err = await LookupCommand.run([ + 'deal', + '--field', + 'No Such Field', + '--value', + 'x', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('passes match=beginning through and enforces the 2-char minimum', async () => { + mockDealFields() + const err = await LookupCommand.run([ + 'deal', + '--field', + 'PO Number', + '--value', + 'x', + '--match', + 'beginning', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('caps --limit at the 100 the field-search endpoint allows', async () => { + mockDealFields() + const scope = mockApi() + .get('/api/v2/itemSearch/field') + .query((q) => q.limit === '100') + .reply(200, { success: true, data: [{ item: { id: 42 } }] }) + + await runCmd(LookupCommand, [ + 'deal', + '--field', + 'PO Number', + '--value', + 'PO-1234', + '--limit', + '500', + ]) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/test/commands/quota.test.js b/test/commands/quota.test.js new file mode 100644 index 0000000..d935baf --- /dev/null +++ b/test/commands/quota.test.js @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import nock from 'nock' + +const mockResolveCredentials = vi.fn() +vi.mock('../../src/lib/auth.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, resolveCredentials: mockResolveCredentials } +}) + +vi.mock('../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), +})) + +const { default: QuotaCommand } = await import('../../src/commands/quota.js') +import { runCmd, mockApi } from '../helpers.js' + +/** The probe endpoint + the daily-budget headers it comes back with. */ +function mockProbe(headers) { + return mockApi() + .get('/api/v1/users/me') + .reply(200, { success: true, data: { id: 1 } }, headers) +} + +describe('quota', () => { + beforeEach(() => { + nock.cleanAll() + delete process.env.PDCLI_BASE_URL + mockResolveCredentials.mockResolvedValue({ + companyDomain: 'acme', + token: 'tok', + source: 'profile', + }) + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('prints the daily budget as a table', async () => { + mockProbe({ + 'x-daily-ratelimit-token-remaining': '75000', + 'x-daily-ratelimit-token-limit': '150000', + 'x-ratelimit-reset': '2', + }) + + const stdout = await runCmd(QuotaCommand, ['--output', 'table']) + + expect(stdout).toContain('75000') + expect(stdout).toContain('150000') + expect(stdout).toContain('50%') + }) + + it('emits the nested { daily, reset } shape as JSON', async () => { + mockProbe({ + 'x-daily-ratelimit-token-remaining': '120000', + 'x-daily-ratelimit-token-limit': '150000', + 'x-ratelimit-reset': '2', + }) + + const stdout = await runCmd(QuotaCommand, ['--output', 'json']) + const out = JSON.parse(stdout) + + expect(out).toEqual({ + daily: { remaining: 120000, limit: 150000, pct: 80 }, + reset: 2, + }) + }) + + it('prints n/a and exits 0 when the daily headers are absent', async () => { + mockProbe({}) + + const stdout = await runCmd(QuotaCommand, ['--output', 'json']) + const out = JSON.parse(stdout) + + expect(out).toEqual({ + daily: { remaining: null, limit: null, pct: null }, + reset: null, + }) + }) + + it('fails with exit 75 when remaining is below --min', async () => { + mockProbe({ + 'x-daily-ratelimit-token-remaining': '900', + 'x-daily-ratelimit-token-limit': '150000', + }) + + const err = await runCmd(QuotaCommand, ['--min', '1000']).catch((e) => e) + + expect(err.oclif.exit).toBe(75) + expect(err.message).toMatch(/min/i) + }) + + it('passes when remaining is at or above --min', async () => { + mockProbe({ + 'x-daily-ratelimit-token-remaining': '2000', + 'x-daily-ratelimit-token-limit': '150000', + }) + + const stdout = await runCmd(QuotaCommand, ['--min', '1000']) + expect(stdout).toContain('2000') + }) + + it('fails with exit 75 when the remaining pct is below --threshold', async () => { + mockProbe({ + 'x-daily-ratelimit-token-remaining': '15000', + 'x-daily-ratelimit-token-limit': '150000', + }) + + const err = await runCmd(QuotaCommand, ['--threshold', '25']).catch( + (e) => e, + ) + + expect(err.oclif.exit).toBe(75) + expect(err.message).toMatch(/threshold/i) + }) + + it('does not gate when the daily headers are absent (exit 0)', async () => { + mockProbe({}) + + const stdout = await runCmd(QuotaCommand, ['--min', '1000']) + expect(stdout).toContain('n/a') + }) + + it('reports pct as n/a when remaining is known but limit is not', async () => { + mockProbe({ 'x-daily-ratelimit-token-remaining': '5000' }) + + // --min still gates on the absolute remaining; --threshold can't (no pct). + const stdout = await runCmd(QuotaCommand, ['--threshold', '10']) + expect(stdout).toContain('5000') + expect(stdout).toContain('n/a') + }) +}) diff --git a/test/lib/client.test.js b/test/lib/client.test.js index 9beeadb..1ddcba7 100644 --- a/test/lib/client.test.js +++ b/test/lib/client.test.js @@ -1414,3 +1414,157 @@ describe('daily budget exhaustion', () => { expect(res.success).toBe(true) }) }) + +describe('lastRateLimit capture', () => { + beforeEach(() => nock.cleanAll()) + const BASE3 = 'https://acme.pipedrive.com' + + it('captures the daily budget + reset headers onto the client', async () => { + nock(BASE3).get('/api/v2/deals').reply( + 200, + { success: true, data: [] }, + { + 'x-daily-ratelimit-token-remaining': '127320', + 'x-daily-ratelimit-token-limit': '150000', + 'x-ratelimit-reset': '2', + }, + ) + + const client = createClient({ + companyDomain: 'acme', + token: 'test-token', + retry: false, + timeout: 5000, + }) + await client.get('/api/v2/deals') + expect(client.lastRateLimit).toEqual({ + dailyRemaining: 127320, + dailyLimit: 150000, + reset: 2, + }) + }) + + it('leaves fields undefined (never NaN) when headers are absent', async () => { + nock(BASE3).get('/api/v2/deals').reply(200, { success: true, data: [] }) + + const client = createClient({ + companyDomain: 'acme', + token: 'test-token', + retry: false, + timeout: 5000, + }) + await client.get('/api/v2/deals') + expect(client.lastRateLimit).toEqual({ + dailyRemaining: undefined, + dailyLimit: undefined, + reset: undefined, + }) + }) + + it('coerces a non-numeric header to undefined, never NaN', async () => { + nock(BASE3) + .get('/api/v2/deals') + .reply(200, { success: true, data: [] }, { 'x-ratelimit-reset': 'soon' }) + + const client = createClient({ + companyDomain: 'acme', + token: 'test-token', + retry: false, + timeout: 5000, + }) + await client.get('/api/v2/deals') + expect(client.lastRateLimit.reset).toBeUndefined() + }) +}) + +describe('localhost mock endpoint (PDCLI_BASE_URL)', () => { + beforeEach(() => nock.cleanAll()) + afterEach(() => { + delete process.env.PDCLI_BASE_URL + delete process.env.PDCLI_API_TOKEN + nock.cleanAll() + }) + + it('honors a localhost override and targets it (env token present)', async () => { + process.env.PDCLI_BASE_URL = 'http://127.0.0.1:4010' + process.env.PDCLI_API_TOKEN = 'dummy' + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + const client = createClient({ + companyDomain: 'acme', + token: 'keychain-secret', + retry: false, + timeout: 5000, + }) + + // The loud banner fires once at client construction. + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('MOCK ENDPOINT'), + ) + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('http://127.0.0.1:4010'), + ) + + const scope = nock('http://127.0.0.1:4010') + .get('/api/v2/deals/1') + .reply(200, { success: true, data: { id: 1 } }) + + const res = await client.get('/api/v2/deals/1') + expect(res.data).toEqual({ id: 1 }) + expect(scope.isDone()).toBe(true) + stderr.mockRestore() + }) + + it('hard-exits 78 when PDCLI_BASE_URL is not localhost', () => { + process.env.PDCLI_BASE_URL = 'https://evil.example.com' + process.env.PDCLI_API_TOKEN = 'dummy' + + try { + createClient({ companyDomain: 'acme', token: 'keychain-secret' }) + expect.unreachable('should have thrown') + } catch (err) { + expect(err.exitCode).toBe(78) + expect(err.message).toMatch(/localhost|host-lock/i) + } + }) + + it('refuses a keychain token on a localhost override with no env token', () => { + process.env.PDCLI_BASE_URL = 'http://localhost:4010' + delete process.env.PDCLI_API_TOKEN + + try { + createClient({ companyDomain: 'acme', token: 'keychain-secret' }) + expect.unreachable('should have thrown') + } catch (err) { + expect(err.exitCode).toBe(78) + expect(err.message).toMatch(/PDCLI_API_TOKEN/) + } + }) + + it('accepts ::1 as a localhost override', async () => { + process.env.PDCLI_BASE_URL = 'http://[::1]:4010' + process.env.PDCLI_API_TOKEN = 'dummy' + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + const client = createClient({ companyDomain: 'acme', token: 'x' }) + const scope = nock('http://[::1]:4010') + .get('/api/v2/deals') + .reply(200, { success: true, data: [] }) + + await client.get('/api/v2/deals') + expect(scope.isDone()).toBe(true) + stderr.mockRestore() + }) + + it('rejects a malformed PDCLI_BASE_URL as a config error (78)', () => { + process.env.PDCLI_BASE_URL = 'not a url' + process.env.PDCLI_API_TOKEN = 'dummy' + + try { + createClient({ companyDomain: 'acme', token: 'x' }) + expect.unreachable('should have thrown') + } catch (err) { + expect(err.exitCode).toBe(78) + } + }) +}) diff --git a/test/lib/mcp/catalog.test.js b/test/lib/mcp/catalog.test.js index 970dd94..b7a723f 100644 --- a/test/lib/mcp/catalog.test.js +++ b/test/lib/mcp/catalog.test.js @@ -56,6 +56,8 @@ describe('classifyKind', () => { ['funnel', 'read'], ['digest', 'read'], ['audit', 'read'], + ['lookup', 'read'], + ['quota', 'read'], ['audit:stage-skips', 'read'], ['version', 'read'], ['some:future-verb', 'write'], // unknown verbs default to gated @@ -225,6 +227,7 @@ describe('real-config classification audit', () => { 'lead:label:list': 'read', 'lead:list': 'read', 'lead:update': 'write', + lookup: 'read', 'mcp:serve': 'excluded', 'metrics:aging': 'read', 'metrics:conversion-matrix': 'read', @@ -282,6 +285,7 @@ describe('real-config classification audit', () => { 'project:get': 'read', 'project:list': 'read', 'project:update': 'write', + quota: 'read', 'rep:scorecard': 'read', search: 'read', 'stage:get': 'read', @@ -358,9 +362,9 @@ describe('real-config classification audit', () => { } }) - it('locks the curated split: 45 reads + 14 writes, nothing destructive', () => { + it('locks the curated split: 46 reads + 14 writes, nothing destructive', () => { const kinds = [...CURATED].map(classifyKind) - expect(kinds.filter((k) => k === 'read')).toHaveLength(45) + expect(kinds.filter((k) => k === 'read')).toHaveLength(46) expect(kinds.filter((k) => k === 'write')).toHaveLength(14) expect(kinds).not.toContain('destructive') }) diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 68e8213..b385118 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -107,6 +107,7 @@ export default defineConfig({ { label: 'Sales analytics', slug: 'guides/analytics' }, { label: 'Data-hygiene audit', slug: 'guides/audit' }, { label: 'The raw api escape hatch', slug: 'guides/api' }, + { label: 'Local mock endpoint', slug: 'guides/mock' }, ], }, { diff --git a/website/src/content/docs/guides/mock.mdx b/website/src/content/docs/guides/mock.mdx new file mode 100644 index 0000000..73735de --- /dev/null +++ b/website/src/content/docs/guides/mock.mdx @@ -0,0 +1,61 @@ +--- +title: Local mock endpoint +description: Point pdcli at a localhost mock server (Prism, WireMock) for offline development and CI, without ever leaking your real Pipedrive token off-host. +--- + +`pdcli` is host-locked: every request goes to your own company domain +(`{company}.pipedrive.com`) or your OAuth `api_domain`, and nothing else. That is +what stops a mistyped or hallucinated URL from leaking your API token. + +For local development you sometimes want to aim pdcli at a **mock server** instead +of the live API: to iterate offline, to run deterministic tests, or to exercise a +new command against a canned response. `PDCLI_BASE_URL` is the guarded escape hatch +for exactly that. + +## The rules + +`PDCLI_BASE_URL`, when set, overrides the base origin for every request. It is +deliberately narrow: + +- It is honored **only** when it resolves to `localhost`, `127.0.0.1`, or `::1`. + Any other host is a hard config error (exit `78`) that reaffirms the host-lock. + pdcli never silently trusts an arbitrary host with your credentials. +- Because a mock server has no business receiving your real Pipedrive token, + `PDCLI_API_TOKEN` **must** be set when the override is active. Set it to a + throwaway dummy value. If pdcli would otherwise send a stored keychain token to + the override host, it refuses with a clear error rather than leaking it. +- Every invocation prints a loud banner to stderr so the override is never silent: + + ``` + [pdcli] MOCK ENDPOINT — using http://127.0.0.1:4010 (host-lock bypassed) + ``` + +## Recipe: Prism against the Pipedrive OpenAPI spec + +[Prism](https://github.com/stoplightio/prism) turns an OpenAPI document into a +local mock server. Point it at the Pipedrive v2 spec: + +```bash +prism mock ~/dev/wavyx/pddoc/output/openapi-v2.yaml +# → Prism is listening on http://127.0.0.1:4010 +``` + +Then run pdcli against it. A dummy token satisfies the safety check; a company +domain (env or profile) is still required so credential resolution succeeds: + +```bash +PDCLI_BASE_URL=http://127.0.0.1:4010 \ +PDCLI_API_TOKEN=dummy \ +PDCLI_COMPANY_DOMAIN=mock \ + pdcli deal list +``` + +pdcli prints the banner and the mock's canned deals, using the same pagers, +rate-limit handling, and output formatting as against the live API. Unset +`PDCLI_BASE_URL` to return to your real, host-locked company domain. + +:::caution +The mock endpoint is a development aid. Requests never reach Pipedrive, so +responses are only as accurate as the mock. Never point production automation at +a mock server. +::: From 532ecaebd197ed89a6d233a7c5ba50076e04353a Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 9 Jul 2026 14:00:52 +0200 Subject: [PATCH 2/3] fix(dev-loop): address v0.21 review findings Security: - The localhost mock now forces the credential to PDCLI_API_TOKEN and token auth (and disables OAuth refresh) at the client layer, so a skipAuth command (auth status/login) can no longer send the real keychain/OAuth token to the override host. Withholding was previously presence-only. quota: - --threshold fails closed: if the daily-limit header is absent it errors (exit 69) instead of silently exiting 0. - The probe catches a RateLimitError and still prints the reading from the captured headers, so an exhausted budget shows numbers (and gates) instead of erroring out with exit 75 and no output. lookup: - Tolerates both the wrapped ({item}) and flat ({id}) result shapes, so a flat response no longer yields undefined rows and a false "found". - Exit 3 (no match) is now documented in the exit-code contract. - Held out of the MCP curated default: its exit 3 reads as a tool error to agents (still reachable via --all-tools). docs: the mock recipe points at a downloadable spec, not a private path. Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9 --- src/commands/lookup.js | 5 +- src/commands/quota.js | 53 ++++++--- src/lib/client.js | 16 ++- src/lib/mcp/catalog.js | 1 - test/commands/lookup.test.js | 19 +++ test/commands/quota.test.js | 111 +++++++++++++++++- test/lib/client.test.js | 58 +++++++++ test/lib/mcp/catalog.test.js | 9 +- .../content/docs/automation/exit-codes.mdx | 2 + website/src/content/docs/guides/mock.mdx | 6 +- 10 files changed, 253 insertions(+), 27 deletions(-) diff --git a/src/commands/lookup.js b/src/commands/lookup.js index d4c4167..259fbcc 100644 --- a/src/commands/lookup.js +++ b/src/commands/lookup.js @@ -95,7 +95,10 @@ export default class LookupCommand extends BaseCommand { }, }) - const rows = (body.data ?? []).map((entry) => entry.item) + // Each result wraps the matched record under `item`; tolerate a flat shape + // (`{ id, ... }` directly) so the no-match check and output stay correct + // whichever the live endpoint returns. + const rows = (body.data ?? []).map((entry) => entry.item ?? entry) if (rows.length === 0) { throw new CliError( diff --git a/src/commands/quota.js b/src/commands/quota.js index 802de4f..decadeb 100644 --- a/src/commands/quota.js +++ b/src/commands/quota.js @@ -1,6 +1,6 @@ import { Flags } from '@oclif/core' import BaseCommand from '../base-command.js' -import { CliError } from '../lib/errors.js' +import { CliError, RateLimitError } from '../lib/errors.js' const columns = { remaining: { @@ -57,11 +57,21 @@ export default class QuotaCommand extends BaseCommand { const { flags } = await this.parse(QuotaCommand) // A GET-single-entity probe (~2 tokens) purely to populate the rate-limit - // headers. Users is v1-only (no v2 equivalent) and always exists. - await this.apiClient.get('/api/v1/users/me') + // headers. Users is v1-only (no v2 equivalent) and always exists. The + // probe goes through the shared transport, which THROWS a RateLimitError + // (exit 75) on a 429 or an exhausted daily budget — but that is precisely + // the reading a user runs `quota` to see. transport snapshots lastRateLimit + // off the 429 response BEFORE throwing, so swallow the rate-limit throw and + // fall through to print the reading; re-throw anything else (auth, 5xx, + // network) unchanged. + try { + await this.apiClient.get('/api/v1/users/me') + } catch (err) { + if (!(err instanceof RateLimitError)) throw err + } - // transport() populates lastRateLimit off every response, so a resolved - // probe GET above guarantees it is set (headers may still be absent). + // transport() populates lastRateLimit off every response (including the 429 + // above), so it is set whether the probe resolved or was rate-limited. const rl = this.apiClient.lastRateLimit const remaining = rl.dailyRemaining const limit = rl.dailyLimit @@ -80,21 +90,34 @@ export default class QuotaCommand extends BaseCommand { await this.outputResults(data, columns) - // No headers → nothing to gate on. Never fabricate a pass/fail. - if (!hasData) return - - if (flags.min != null && remaining < flags.min) { + // --min gates on the absolute remaining; skip silently when there is no + // remaining reading to compare (nothing to gate on — never fabricate one). + if (flags.min != null && hasData && remaining < flags.min) { throw new CliError( `Daily token budget ${remaining} is below --min ${flags.min}`, { exitCode: 75 }, ) } - if (flags.threshold != null && pct != null && pct < flags.threshold) { - throw new CliError( - `Daily token budget ${pct}% remaining is below --threshold ` + - `${flags.threshold}%`, - { exitCode: 75 }, - ) + + // --threshold gates on the percentage. If the caller asked for it but no + // pct could be computed (the daily-limit header was absent, or there was no + // reading at all), do NOT silently pass — a false green would let a CI job + // gating on `pdcli quota --threshold N` proceed blind. Fail closed. + if (flags.threshold != null) { + if (pct == null) { + throw new CliError( + 'cannot evaluate --threshold: no daily-limit header in the API ' + + 'response (x-daily-ratelimit-token-limit absent)', + { exitCode: 69 }, + ) + } + if (pct < flags.threshold) { + throw new CliError( + `Daily token budget ${pct}% remaining is below --threshold ` + + `${flags.threshold}%`, + { exitCode: 75 }, + ) + } } } } diff --git a/src/lib/client.js b/src/lib/client.js index 4e55f58..ffbaa90 100644 --- a/src/lib/client.js +++ b/src/lib/client.js @@ -104,12 +104,26 @@ export function createClient({ retry = true, userAgent = 'pdcli', }) { - const baseOrigin = process.env.PDCLI_BASE_URL + const usingMock = Boolean(process.env.PDCLI_BASE_URL) + const baseOrigin = usingMock ? resolveMockOrigin(process.env.PDCLI_BASE_URL) : apiDomain ? new URL(apiDomain).origin : companyDomainToBaseOrigin(companyDomain) let token = initialToken + if (usingMock) { + // The mock override is host-locked to localhost, but the token guarantee + // must hold at the CLIENT layer too: a skipAuth command (e.g. `auth + // status`) constructs the client directly with the REAL keychain/OAuth + // credential. Force the credential to the env token under token auth — and + // drop the OAuth refresh callback so a 401 can't mint a real access token + // and re-send it — so NO stored credential reaches the override host, + // regardless of which command built the client. resolveMockOrigin has + // already guaranteed PDCLI_API_TOKEN is set. + token = process.env.PDCLI_API_TOKEN + authMode = 'token' + onRefresh = undefined + } async function request(method, path, { body, query } = {}) { const url = new URL(path, baseOrigin) diff --git a/src/lib/mcp/catalog.js b/src/lib/mcp/catalog.js index 968ae9e..b1c3c6d 100644 --- a/src/lib/mcp/catalog.js +++ b/src/lib/mcp/catalog.js @@ -154,7 +154,6 @@ export const CURATED = new Set([ 'user:list', // reads — search + deal intelligence 'search', - 'lookup', 'deal:context', 'deal:history', 'deal:summary', diff --git a/test/commands/lookup.test.js b/test/commands/lookup.test.js index 0d019d4..cde6517 100644 --- a/test/commands/lookup.test.js +++ b/test/commands/lookup.test.js @@ -136,6 +136,25 @@ describe('lookup', () => { expect(err.exitCode ?? err.oclif?.exit).toBe(3) }) + it('tolerates a flat result element (no item wrapper)', async () => { + mockDealFields() + mockApi() + .get('/api/v2/itemSearch/field') + .query(true) + .reply(200, { success: true, data: [{ id: 42, title: 'Acme' }] }) + + const stdout = await runCmd(LookupCommand, [ + 'deal', + '--field', + 'PO Number', + '--value', + 'PO-1234', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual([{ id: 42, title: 'Acme' }]) + }) + it('treats a response with no data array as no match (exit 3)', async () => { mockDealFields() mockApi() diff --git a/test/commands/quota.test.js b/test/commands/quota.test.js index d935baf..72e0a50 100644 --- a/test/commands/quota.test.js +++ b/test/commands/quota.test.js @@ -121,12 +121,113 @@ describe('quota', () => { expect(stdout).toContain('n/a') }) - it('reports pct as n/a when remaining is known but limit is not', async () => { + it('passes when the remaining pct is at or above --threshold', async () => { + mockProbe({ + 'x-daily-ratelimit-token-remaining': '120000', + 'x-daily-ratelimit-token-limit': '150000', + }) + + // pct = 80, which is >= 50 → no gate trips. + const stdout = await runCmd(QuotaCommand, ['--threshold', '50']) + expect(stdout).toContain('120000') + }) + + it('errors (not exit 0) when --threshold is set but the limit header is absent', async () => { + // remaining is known but no daily-limit header → pct can't be computed. + // The gate must FAIL CLOSED: silently passing would be a false green for a + // CI job gating on `pdcli quota --threshold N`. It still prints the reading. mockProbe({ 'x-daily-ratelimit-token-remaining': '5000' }) - // --min still gates on the absolute remaining; --threshold can't (no pct). - const stdout = await runCmd(QuotaCommand, ['--threshold', '10']) - expect(stdout).toContain('5000') - expect(stdout).toContain('n/a') + const err = await runCmd(QuotaCommand, ['--threshold', '10']).catch( + (e) => e, + ) + + expect(err.oclif.exit).toBe(69) + expect(err.message).toMatch(/threshold/i) + expect(err.stdout).toContain('5000') + expect(err.stdout).toContain('n/a') + }) + + it('still gates on --min when the limit header is absent (needs only remaining)', async () => { + mockProbe({ 'x-daily-ratelimit-token-remaining': '500' }) + + const err = await runCmd(QuotaCommand, ['--min', '1000']).catch((e) => e) + expect(err.oclif.exit).toBe(75) + expect(err.message).toMatch(/min/i) + }) + + describe('probe that is itself rate-limited (Finding C)', () => { + it('prints the reading and exits 0 (no gate) when the daily budget is exhausted', async () => { + // The probe 429s with an exhausted budget — the transport would throw + // RateLimitError (75) before quota prints. quota must swallow it, read + // lastRateLimit (snapshotted off the 429), and print the reading. + mockApi().get('/api/v1/users/me').reply( + 429, + {}, + { + 'x-daily-ratelimit-token-remaining': '0', + 'x-daily-ratelimit-token-limit': '150000', + 'x-ratelimit-reset': '5', + }, + ) + + const stdout = await runCmd(QuotaCommand, ['--output', 'json']) + expect(JSON.parse(stdout)).toEqual({ + daily: { remaining: 0, limit: 150000, pct: 0 }, + reset: 5, + }) + }) + + it('prints the reading first, then still exits 75 under --min', async () => { + mockApi().get('/api/v1/users/me').reply( + 429, + {}, + { + 'x-daily-ratelimit-token-remaining': '0', + 'x-daily-ratelimit-token-limit': '150000', + }, + ) + + const err = await runCmd(QuotaCommand, [ + '--min', + '1000', + '--output', + 'json', + ]).catch((e) => e) + + expect(err.oclif.exit).toBe(75) + expect(JSON.parse(err.stdout)).toMatchObject({ daily: { remaining: 0 } }) + }) + + it('prints the last-seen reading when a burst 429 surfaces under --no-retry', async () => { + mockApi().get('/api/v1/users/me').reply( + 429, + {}, + { + 'x-daily-ratelimit-token-remaining': '4100', + 'x-daily-ratelimit-token-limit': '150000', + 'x-ratelimit-reset': '2', + }, + ) + + const stdout = await runCmd(QuotaCommand, [ + '--no-retry', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual({ + daily: { remaining: 4100, limit: 150000, pct: 3 }, + reset: 2, + }) + }) + + it('re-throws a non-rate-limit probe error unchanged (e.g. 5xx)', async () => { + mockApi() + .get('/api/v1/users/me') + .reply(500, { success: false, error: 'boom' }) + + const err = await runCmd(QuotaCommand, ['--no-retry']).catch((e) => e) + expect(err.oclif.exit).toBe(69) + }) }) }) diff --git a/test/lib/client.test.js b/test/lib/client.test.js index 1ddcba7..751b5ac 100644 --- a/test/lib/client.test.js +++ b/test/lib/client.test.js @@ -1515,6 +1515,64 @@ describe('localhost mock endpoint (PDCLI_BASE_URL)', () => { stderr.mockRestore() }) + it('sends the env token — never the caller keychain/OAuth token — to the mock host', async () => { + // A skipAuth command (e.g. `auth status`) builds the client directly with + // the REAL stored credential (here an OAuth access token). When the mock + // override is honored, the request must carry the ENV token under token + // auth — the caller's secret must never reach the override host. + process.env.PDCLI_BASE_URL = 'http://127.0.0.1:4010' + process.env.PDCLI_API_TOKEN = 'dummy-env' + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + const client = createClient({ + apiDomain: 'https://acme.pipedrive.com', + token: 'real-keychain-secret', + authMode: 'oauth', + retry: false, + timeout: 5000, + }) + + const scope = nock('http://127.0.0.1:4010') + .get('/api/v2/deals/1') + .matchHeader('x-api-token', 'dummy-env') + .matchHeader('authorization', (v) => v === undefined) + .reply(200, { success: true, data: { id: 1 } }) + + const res = await client.get('/api/v2/deals/1') + expect(res.data).toEqual({ id: 1 }) + expect(scope.isDone()).toBe(true) + stderr.mockRestore() + }) + + it('does not invoke onRefresh against the mock host (no minted token leaks)', async () => { + // Forcing token auth is not enough on its own: if the OAuth refresh + // callback survived, a 401 from the mock would mint a real access token and + // re-send it. onRefresh must be disabled under the mock override. + process.env.PDCLI_BASE_URL = 'http://127.0.0.1:4010' + process.env.PDCLI_API_TOKEN = 'dummy-env' + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const onRefresh = vi.fn().mockResolvedValue('minted-real-token') + + const client = createClient({ + apiDomain: 'https://acme.pipedrive.com', + token: 'real-keychain-secret', + authMode: 'oauth', + onRefresh, + retry: false, + timeout: 5000, + }) + + nock('http://127.0.0.1:4010') + .get('/api/v2/deals/1') + .reply(401, { success: false, error: 'nope' }) + + await expect(client.get('/api/v2/deals/1')).rejects.toMatchObject({ + statusCode: 401, + }) + expect(onRefresh).not.toHaveBeenCalled() + stderr.mockRestore() + }) + it('hard-exits 78 when PDCLI_BASE_URL is not localhost', () => { process.env.PDCLI_BASE_URL = 'https://evil.example.com' process.env.PDCLI_API_TOKEN = 'dummy' diff --git a/test/lib/mcp/catalog.test.js b/test/lib/mcp/catalog.test.js index b7a723f..6072011 100644 --- a/test/lib/mcp/catalog.test.js +++ b/test/lib/mcp/catalog.test.js @@ -362,13 +362,18 @@ describe('real-config classification audit', () => { } }) - it('locks the curated split: 46 reads + 14 writes, nothing destructive', () => { + it('locks the curated split: 45 reads + 14 writes, nothing destructive', () => { const kinds = [...CURATED].map(classifyKind) - expect(kinds.filter((k) => k === 'read')).toHaveLength(46) + expect(kinds.filter((k) => k === 'read')).toHaveLength(45) expect(kinds.filter((k) => k === 'write')).toHaveLength(14) expect(kinds).not.toContain('destructive') }) + it('keeps lookup out of the curated default (its no-match exit 3 reads as a tool error)', () => { + expect(CURATED.has('lookup')).toBe(false) + expect(classifyKind('lookup')).toBe('read') // still reachable via --all-tools + }) + it('curates user:find so agents can resolve owner names to ids', () => { expect(CURATED.has('user:find')).toBe(true) }) diff --git a/website/src/content/docs/automation/exit-codes.mdx b/website/src/content/docs/automation/exit-codes.mdx index 4c79ec6..9cc9e08 100644 --- a/website/src/content/docs/automation/exit-codes.mdx +++ b/website/src/content/docs/automation/exit-codes.mdx @@ -13,6 +13,7 @@ command shares the same ladder. | ---- | ------- | ----------------- | ----------------------- | | `0` | Success | 2xx | Continue. | | `1` | Generic error | — | Inspect the message; safe to surface and stop. `audit --strict` returns 1 on must-severity findings; a partial bulk/import failure returns 1 when some rows failed for non-data reasons. | +| `3` | No match (`lookup` only) | — | `pdcli lookup` exits `3` when it finds zero rows, so a script can branch create-vs-update: `pdcli lookup … && pdcli … update \|\| pdcli … create`. A distinct code (not `1`) so a real error isn't mistaken for "not found". Not part of the general ladder — specific to `lookup`. | | `64` | Usage / bad input | — | A bad invocation: an unknown flag or a missing/invalid argument (caught by the parser), or a value pdcli validated and rejected (invalid `--period`, unknown `--checks` name, several pipelines with no `--pipeline`, a CSV missing its name column, `--ids` over 100). Fix the invocation — retrying won't help. | | `65` | Data / validation | `400`, `422` | The request body or input data was rejected: a bad field value, a non-numeric id cell in a CSV, an ambiguous upsert match, malformed `--body` JSON, or a conversion the API rejected. Fix the payload; don't retry unchanged. | | `69` | Service unavailable | `5xx`, network | Pipedrive is down/erroring, **or the host is unreachable** (DNS failure, connection refused, or a `--timeout`). pdcli already retried with backoff; safe to retry later. | @@ -58,6 +59,7 @@ human form; `--verbose` there adds the request path, status code, and `error_inf pdcli deal get 42 --output json > deal.json case $? in 0) echo "ok" ;; + 3) echo "no match — create it" ;; 64) echo "bad invocation — fix flags/args" ; exit 1 ;; 65) echo "bad request — not retrying" ; exit 1 ;; 75|69) echo "transient — retry later" ; exit 1 ;; diff --git a/website/src/content/docs/guides/mock.mdx b/website/src/content/docs/guides/mock.mdx index 73735de..64d7323 100644 --- a/website/src/content/docs/guides/mock.mdx +++ b/website/src/content/docs/guides/mock.mdx @@ -33,10 +33,12 @@ deliberately narrow: ## Recipe: Prism against the Pipedrive OpenAPI spec [Prism](https://github.com/stoplightio/prism) turns an OpenAPI document into a -local mock server. Point it at the Pipedrive v2 spec: +local mock server. Point it at your local copy of the Pipedrive v2 OpenAPI spec +(download it from the [Pipedrive developer docs](https://developers.pipedrive.com/docs/api/v1) +and save it as `pipedrive-v2.yaml`): ```bash -prism mock ~/dev/wavyx/pddoc/output/openapi-v2.yaml +prism mock ./pipedrive-v2.yaml # → Prism is listening on http://127.0.0.1:4010 ``` From 025dfc2aa1692d71dc12f63de93098207a30a29e Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 9 Jul 2026 14:01:45 +0200 Subject: [PATCH 3/3] chore: release v0.21.0 Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9 --- CHANGELOG.md | 25 +++++++++++ docs/commands.md | 44 ++++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- .../src/content/docs/reference/commands.mdx | 43 +++++++++++++++++- website/src/data/cli-stats.json | 4 +- 6 files changed, 115 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a7e70..882d88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to `pdcli` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). +## [0.21.0] - 2026-07-09 + +Dev loop: check your rate budget, resolve records to ids, and point pdcli at a +local mock. + +### Added + +- **`pdcli quota` (alias `ratelimit`)** — reads Pipedrive's daily token-budget + headers via one cheap request and prints remaining/limit/percent. `--min ` + and `--threshold ` gate CI (exit 75 when too low) so a batch can check + headroom first: `pdcli quota --min 5000 && pdcli sync warehouse`. The budget is + company-wide, so treat the number as a hint. An exhausted budget still prints + the reading instead of erroring out. +- **`pdcli lookup --field --value `** — exact-match id lookup + (deal, person, org, product, lead) via the field-search endpoint, resolving a + human field name to its custom-field key. `--first` returns one row for shell + composition (`--first --jq .id`); **no match exits 3** so a script can branch + create-vs-update. Matching is case-sensitive and the search index is eventually + consistent. +- **Localhost mock endpoint** — set `PDCLI_BASE_URL` to a `localhost`/`127.0.0.1` + address (e.g. a Prism mock of the OpenAPI spec) to run pdcli against it. Any + non-localhost value is refused (exit 78), the real keychain/OAuth token is + never sent to the override host (a `PDCLI_API_TOKEN` is required), and a loud + banner prints to stderr. + ## [0.20.0] - 2026-07-09 Distribution: install pdcli however your pipeline prefers, and drive the diff --git a/docs/commands.md b/docs/commands.md index 7c802a8..0880449 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the pdcli command-line interface. -Reference for `pdcli` v0.20.0 (146 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`. +Reference for `pdcli` v0.21.0 (148 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`. ## Top-level @@ -148,6 +148,48 @@ pdcli funnel pdcli funnel --pipeline 1 --period 180d ``` +### `pdcli lookup` + +Find item IDs by an exact field value — the create-vs-update primitive. +Matching is CASE-SENSITIVE, and the search index is eventually consistent (a just-created record is not immediately findable), so a lookup-then-create loop can double-create in fast pipelines. Exit 3 means no match (branch to create); exit 0 with rows means it exists. + +``` +pdcli lookup [flags] +``` + +- `--field ` _(required)_ — Field name (built-in or custom) to search on +- `--value ` _(required)_ — Value to match against the field +- `--match ` — Match mode (case-sensitive) +- `--first` — Return only the first match (a single object) + +Examples: + +```bash +pdcli lookup deal --field "PO Number" --value PO-1234 +pdcli lookup person --field Email --value a@b.com --first --jq .id +pdcli lookup org --field Name --value Acme --match beginning +``` + +### `pdcli quota` + +Show the remaining Pipedrive daily API token budget. Makes one cheap probe request purely to read the rate-limit headers. The budget is COMPANY-WIDE — shared across every integration and user on the account — so treat the reading as a hint, not a guarantee. --min / --threshold gate CI: they exit 75 (rate-limited) when the budget is too low. + +``` +pdcli quota [flags] +``` + +- `--min ` — Exit 75 if the remaining daily token budget is below this many tokens +- `--threshold ` — Exit 75 if the remaining daily token budget is below this percentage + +Examples: + +```bash +pdcli quota +pdcli quota --output json +pdcli quota --min 5000 +pdcli quota --threshold 10 +``` + ### `pdcli search` Search across deals, persons, organizations, products, leads, files, and projects diff --git a/package-lock.json b/package-lock.json index 8347f57..782d176 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wavyx/pdcli", - "version": "0.20.0", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wavyx/pdcli", - "version": "0.20.0", + "version": "0.21.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "8.5.0", diff --git a/package.json b/package.json index 40575ce..0cd02f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wavyx/pdcli", - "version": "0.20.0", + "version": "0.21.0", "publishConfig": { "access": "public" }, diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index 59093ef..d2d01a4 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -5,7 +5,7 @@ description: Every pdcli command, flag, and example — generated from the CLI m {/* AUTO-GENERATED from the oclif manifest by scripts/gen-commands.mjs — do not edit by hand. */} -All 146 commands in `pdcli` v0.20.0. Every command also +All 148 commands in `pdcli` v0.21.0. Every command also accepts the [global flags](/pdcli/reference/config/) `--output`, `--jq`, `--fields`, `--profile`, `--limit`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. Run `pdcli --help` for the live version. @@ -151,6 +151,47 @@ pdcli funnel pdcli funnel --pipeline 1 --period 180d ``` +### `pdcli lookup` + +Find item IDs by an exact field value — the create-vs-update primitive. + +```text +pdcli lookup [flags] +``` + +| Flag | Description | +| --- | --- | +| `--field` <value> | Field name (built-in or custom) to search on | +| `--value` <value> | Value to match against the field | +| `--match` <exact\|beginning\|middle> | Match mode (case-sensitive) | +| `--first` | Return only the first match (a single object) | + +```bash +pdcli lookup deal --field "PO Number" --value PO-1234 +pdcli lookup person --field Email --value a@b.com --first --jq .id +pdcli lookup org --field Name --value Acme --match beginning +``` + +### `pdcli quota` + +Show the remaining Pipedrive daily API token budget. Makes one cheap probe request purely to read the rate-limit headers. The budget is COMPANY-WIDE — shared across every integration and user on the account — so treat the reading as a hint, not a guarantee. --min / --threshold gate CI: they exit 75 (rate-limited) when the budget is too low. + +```text +pdcli quota [flags] +``` + +| Flag | Description | +| --- | --- | +| `--min` <value> | Exit 75 if the remaining daily token budget is below this many tokens | +| `--threshold` <value> | Exit 75 if the remaining daily token budget is below this percentage | + +```bash +pdcli quota +pdcli quota --output json +pdcli quota --min 5000 +pdcli quota --threshold 10 +``` + ### `pdcli search` Search across deals, persons, organizations, products, leads, files, and projects diff --git a/website/src/data/cli-stats.json b/website/src/data/cli-stats.json index c0c8df7..d452ef8 100644 --- a/website/src/data/cli-stats.json +++ b/website/src/data/cli-stats.json @@ -1,6 +1,6 @@ { - "version": "0.20.0", - "commands": 146, + "version": "0.21.0", + "commands": 148, "topics": 27, "formats": 4 }