From 0a3457395befbf76418195c88eca5518cef75e51 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 9 Jul 2026 14:40:40 +0200 Subject: [PATCH 1/3] feat(integrations): webhook listen, mail-in-context, filters-as-code - webhook listen: a local webhook dev loop. Tunnel mode registers a temporary webhook against a user-supplied --url, prints deliveries (NDJSON under --output json) and optionally forwards them; --synthetic emits webhook-shaped events off the changes feed with no inbound network. Temp webhooks are deleted on exit/SIGINT, and orphaned ones from a crashed run are swept on the next start. - deal context grows a mail summary (message count, last message time and direction, participants, latest subject) that degrades to null when mail scope/sync is unavailable; new mail list --deal lists a deal's messages (bodies excluded by default). - filters-as-code: filter create/update take --conditions (json, @file, or stdin; passed through verbatim, field references are numeric field_id), filter helpers lists the valid fields/operators, filter export emits a round-trippable filter definition (single or --all). - MCP: filter create/update classified write; helpers/export/mail:list read; webhook:listen excluded (long-running, like watch). Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9 --- package.json | 3 + src/commands/deal/context.js | 81 ++- src/commands/filter/create.js | 60 ++ src/commands/filter/export.js | 70 +++ src/commands/filter/helpers.js | 58 ++ src/commands/filter/update.js | 55 ++ src/commands/mail/list.js | 81 +++ src/commands/webhook/listen.js | 505 +++++++++++++++++ src/lib/mcp/catalog.js | 3 + test/commands/deal/context.test.js | 195 ++++++- test/commands/filter/create.test.js | 136 +++++ test/commands/filter/export.test.js | 126 +++++ test/commands/filter/helpers.test.js | 100 ++++ test/commands/filter/update.test.js | 90 +++ test/commands/mail/list.test.js | 178 ++++++ test/commands/webhook/listen.test.js | 813 +++++++++++++++++++++++++++ test/lib/mcp/catalog.test.js | 6 + 17 files changed, 2555 insertions(+), 5 deletions(-) create mode 100644 src/commands/filter/create.js create mode 100644 src/commands/filter/export.js create mode 100644 src/commands/filter/helpers.js create mode 100644 src/commands/filter/update.js create mode 100644 src/commands/mail/list.js create mode 100644 src/commands/webhook/listen.js create mode 100644 test/commands/filter/create.test.js create mode 100644 test/commands/filter/export.test.js create mode 100644 test/commands/filter/helpers.test.js create mode 100644 test/commands/filter/update.test.js create mode 100644 test/commands/mail/list.test.js create mode 100644 test/commands/webhook/listen.test.js diff --git a/package.json b/package.json index 0cd02f4..c3e47a1 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,9 @@ "note": { "description": "Notes (list, get, create, update, delete) and comments" }, + "mail": { + "description": "Mail messages synced to a deal (list); bodies excluded by design" + }, "pipeline": { "description": "Pipelines (list, get, health)" }, diff --git a/src/commands/deal/context.js b/src/commands/deal/context.js index b62a541..cd79bb1 100644 --- a/src/commands/deal/context.js +++ b/src/commands/deal/context.js @@ -3,6 +3,51 @@ import BaseCommand from '../../base-command.js' import { collectPages } from '../../lib/pagination.js' import { getFields, makeResolver } from '../../lib/fields.js' import { assembleContext } from '../../lib/deal-context.js' +import { unwrapMailMessage, mailDirection } from '../mail/list.js' + +/** + * Condense a deal's mail messages into an agent-friendly signal. Returns null + * when there is nothing to summarize (no synced mail linked to the deal). + * Bodies are excluded by design — the 225-char snippet is the only preview. + * @param {object[]} messages already-unwrapped mail message objects + * @returns {{ message_count: number, last_message_at: string|null, + * last_direction: 'sent'|'received', latest_subject: string|null, + * participants: { name: string|null, email: string|null, + * linked_person_id: number|null }[] } | null} + */ +export function summarizeMail(messages) { + if (!Array.isArray(messages) || messages.length === 0) return null + + // Latest by message_time — list order is not guaranteed. ISO 8601 strings + // compare correctly lexically. + let latest = messages[0] + for (const m of messages) { + if ((m.message_time ?? '') > (latest.message_time ?? '')) latest = m + } + + const participants = [] + const seen = new Set() + for (const m of messages) { + for (const p of [...(m.from ?? []), ...(m.to ?? [])]) { + const key = p.email_address ?? p.name ?? p.linked_person_id + if (key == null || seen.has(key)) continue + seen.add(key) + participants.push({ + name: p.name ?? null, + email: p.email_address ?? null, + linked_person_id: p.linked_person_id ?? null, + }) + } + } + + return { + message_count: messages.length, + last_message_at: latest.message_time ?? null, + last_direction: mailDirection(latest), + latest_subject: latest.subject ?? null, + participants, + } +} export default class DealContextCommand extends BaseCommand { static description = @@ -30,6 +75,11 @@ export default class DealContextCommand extends BaseCommand { 'no-participants': Flags.boolean({ description: 'Skip the participants slice', }), + 'no-mail': Flags.boolean({ description: 'Skip the mail summary slice' }), + 'mail-limit': Flags.integer({ + description: 'Max mail messages to scan for the summary', + default: 50, + }), 'activity-limit': Flags.integer({ description: 'Max activities to include', default: 50, @@ -48,9 +98,27 @@ export default class DealContextCommand extends BaseCommand { const dealBody = await this.apiClient.get(`/api/v2/deals/${id}`) const deal = dealBody.data + // Mail needs the mail:read scope + a configured email sync; many api-token + // users have neither, so a 403 / permission error (or any mail-only + // failure) degrades to null rather than sinking the whole context bundle. + const fetchMail = async () => { + if (flags['no-mail']) return null + try { + const items = await collectPages( + this.apiClient.pageV1(`/api/v1/deals/${id}/mailMessages`, { + limit: 100, + }), + flags['mail-limit'], + ) + return summarizeMail(items.map(unwrapMailMessage)) + } catch { + return null + } + } + // The id-only slices need only the deal id; person/org need the resolved // join keys. All run concurrently after the deal GET. - const [person, org, activities, notes, products, participants] = + const [person, org, activities, notes, products, participants, mail] = await Promise.all([ deal.person_id != null ? this.apiClient @@ -94,6 +162,7 @@ export default class DealContextCommand extends BaseCommand { limit: 500, }), ), + fetchMail(), ]) // Resolve custom-field hash keys → names per entity so the bundle is @@ -127,6 +196,9 @@ export default class DealContextCommand extends BaseCommand { }, { now, activitiesFetched: !flags['no-activities'] }, ) + // assembleContext is mail-agnostic; the mail summary is attached here so + // an unfetched slice is `null`, never `false`. + bundle.mail = mail if (this.resolveFormat() !== 'table') { await this.outputResults(bundle, {}) @@ -148,5 +220,12 @@ export default class DealContextCommand extends BaseCommand { .filter(([, v]) => v === true) .map(([k]) => k) this.log(` Flags: ${risks.length > 0 ? risks.join(', ') : 'none'}`) + if (bundle.mail) { + const m = bundle.mail + this.log( + ` Mail: ${m.message_count} msgs · last ${m.last_direction}` + + (m.last_message_at ? ` ${m.last_message_at}` : ''), + ) + } } } diff --git a/src/commands/filter/create.js b/src/commands/filter/create.js new file mode 100644 index 0000000..fa60162 --- /dev/null +++ b/src/commands/filter/create.js @@ -0,0 +1,60 @@ +import { Flags } from '@oclif/core' +import BaseCommand from '../../base-command.js' +import { resolveBody } from '../../lib/body.js' +import { outputRecord } from '../../lib/entity-view.js' +import { CliError } from '../../lib/errors.js' + +const TYPES = [ + 'deals', + 'leads', + 'org', + 'people', + 'products', + 'activity', + 'projects', +] + +export default class FilterCreateCommand extends BaseCommand { + static description = 'Create a filter' + + static examples = [ + '<%= config.bin %> filter create --name "Open deals" --type deals --conditions @conditions.json', + 'cat conditions.json | <%= config.bin %> filter create --name "Open deals" --type deals', + ] + + static flags = { + ...BaseCommand.baseFlags, + name: Flags.string({ required: true, description: 'Filter name' }), + type: Flags.string({ + required: true, + description: 'Filter type', + options: TYPES, + }), + conditions: Flags.string({ + description: + 'Conditions JSON (a value, @file, or piped stdin). Fields are ' + + 'referenced by numeric field_id — run `pdcli filter helpers` for the ' + + 'valid operators. The blob needs the two-level glue structure ' + + '{"glue":"and","conditions":[{"glue":"and",...},{"glue":"or",...}]}.', + }), + } + + async run() { + const { flags } = await this.parse(FilterCreateCommand) + + const raw = await resolveBody({ body: flags.conditions }) + let conditions + try { + conditions = JSON.parse(raw) + } catch (err) { + throw new CliError(`--conditions is not valid JSON: ${err.message}`, { + exitCode: 65, + }) + } + + const res = await this.apiClient.post('/api/v1/filters', { + body: { name: flags.name, type: flags.type, conditions }, + }) + await outputRecord(this, res.data) + } +} diff --git a/src/commands/filter/export.js b/src/commands/filter/export.js new file mode 100644 index 0000000..9fabead --- /dev/null +++ b/src/commands/filter/export.js @@ -0,0 +1,70 @@ +import { Args, Flags } from '@oclif/core' +import BaseCommand from '../../base-command.js' +import { CliError } from '../../lib/errors.js' + +/** + * Reduce a full filter record to the create-compatible shape so exported + * filters are git-diffable and portable across accounts. + * @param {object} filter + * @returns {{ name: string, type: string, conditions: unknown }} + */ +function toPortable(filter) { + return { + name: filter.name, + type: filter.type, + conditions: filter.conditions, + } +} + +export default class FilterExportCommand extends BaseCommand { + static description = + 'Export a filter (or all filters) as create-compatible JSON' + + static examples = [ + '<%= config.bin %> filter export 5 > filter.json', + '<%= config.bin %> filter export --all > filters.json', + ] + + static args = { + id: Args.integer({ description: 'Filter ID' }), + } + + static flags = { + ...BaseCommand.baseFlags, + all: Flags.boolean({ description: 'Export every filter' }), + } + + async run() { + const { args, flags } = await this.parse(FilterExportCommand) + + if (flags.all) { + const list = await this.apiClient.get('/api/v1/filters') + const out = [] + for (const summary of list.data ?? []) { + const res = await this.apiClient.get(`/api/v1/filters/${summary.id}`) + out.push(toPortable(res.data)) + } + return this.emit(out) + } + + if (args.id == null) { + throw new CliError('Provide a filter ID or --all', { exitCode: 64 }) + } + + const res = await this.apiClient.get(`/api/v1/filters/${args.id}`) + return this.emit(toPortable(res.data)) + } + + /** + * Emit the portable payload as round-trippable JSON. --jq still applies so + * exports can be sliced in a pipeline; otherwise raw pretty JSON is printed. + * @param {unknown} payload + */ + async emit(payload) { + if (this.flags.jq) { + await this.outputResults(payload, {}) + return + } + this.log(JSON.stringify(payload, null, 2)) + } +} diff --git a/src/commands/filter/helpers.js b/src/commands/filter/helpers.js new file mode 100644 index 0000000..11861a9 --- /dev/null +++ b/src/commands/filter/helpers.js @@ -0,0 +1,58 @@ +import { Flags } from '@oclif/core' +import BaseCommand from '../../base-command.js' + +const columns = { + type: { header: 'Field type' }, + operator: { header: 'Operator' }, + meaning: { header: 'Meaning' }, +} + +/** + * Flatten the helpers `operators` map into type/operator/meaning rows. + * Most entries are `{ operator: label }` maps; the `enum` group is instead an + * array of single-key `{ operator: label }` objects, so both shapes are + * handled here. + * @param {Record} operators + * @param {string} [typeFilter] only emit rows for this field data type + * @returns {{ type: string, operator: string, meaning: string }[]} + */ +function flattenOperators(operators, typeFilter) { + const rows = [] + for (const [type, ops] of Object.entries(operators ?? {})) { + if (typeFilter && type !== typeFilter) continue + const entries = Array.isArray(ops) + ? ops.flatMap((o) => Object.entries(o)) + : Object.entries(ops) + for (const [operator, meaning] of entries) { + rows.push({ type, operator, meaning }) + } + } + return rows +} + +export default class FilterHelpersCommand extends BaseCommand { + static description = + 'List the operators available for authoring filter conditions' + + static examples = [ + '<%= config.bin %> filter helpers', + '<%= config.bin %> filter helpers --type varchar', + '<%= config.bin %> filter helpers --output json', + ] + + static flags = { + ...BaseCommand.baseFlags, + type: Flags.string({ + description: + 'Only show operators for this field data type (e.g. varchar, date, int)', + }), + } + + async run() { + const { flags } = await this.parse(FilterHelpersCommand) + + const body = await this.apiClient.get('/api/v1/filters/helpers') + const rows = flattenOperators(body.data?.operators, flags.type) + await this.outputResults(rows, columns) + } +} diff --git a/src/commands/filter/update.js b/src/commands/filter/update.js new file mode 100644 index 0000000..9211bda --- /dev/null +++ b/src/commands/filter/update.js @@ -0,0 +1,55 @@ +import { Args, Flags } from '@oclif/core' +import BaseCommand from '../../base-command.js' +import { resolveBody } from '../../lib/body.js' +import { outputRecord } from '../../lib/entity-view.js' +import { CliError } from '../../lib/errors.js' + +export default class FilterUpdateCommand extends BaseCommand { + static description = 'Update a filter (only provided fields change)' + + static examples = [ + '<%= config.bin %> filter update 5 --name "Renamed filter"', + '<%= config.bin %> filter update 5 --conditions @conditions.json', + ] + + static args = { + id: Args.integer({ required: true, description: 'Filter ID' }), + } + + static flags = { + ...BaseCommand.baseFlags, + name: Flags.string({ description: 'Filter name' }), + conditions: Flags.string({ + description: + 'Conditions JSON (a value or @file). Fields are referenced by numeric ' + + 'field_id — run `pdcli filter helpers` for the valid operators.', + }), + } + + async run() { + const { args, flags } = await this.parse(FilterUpdateCommand) + + const body = {} + if (flags.name !== undefined) body.name = flags.name + if (flags.conditions !== undefined) { + const raw = await resolveBody({ body: flags.conditions }) + try { + body.conditions = JSON.parse(raw) + } catch (err) { + throw new CliError(`--conditions is not valid JSON: ${err.message}`, { + exitCode: 65, + }) + } + } + + if (Object.keys(body).length === 0) { + throw new CliError( + 'Nothing to update — pass --name and/or --conditions', + { exitCode: 64 }, + ) + } + + const res = await this.apiClient.put(`/api/v1/filters/${args.id}`, { body }) + await outputRecord(this, res.data) + } +} diff --git a/src/commands/mail/list.js b/src/commands/mail/list.js new file mode 100644 index 0000000..9f3c885 --- /dev/null +++ b/src/commands/mail/list.js @@ -0,0 +1,81 @@ +import { Flags } from '@oclif/core' +import BaseCommand from '../../base-command.js' +import { collectPages } from '../../lib/pagination.js' + +/** + * The deal mailMessages list wraps each message under + * `{ object, timestamp, data: {…message…} }`; unwrap to the message object. + * Single-message fetches return the message directly, so a value with no + * nested `data` object passes through unchanged. + * @param {object} item + * @returns {object} + */ +export function unwrapMailMessage(item) { + return item?.data && typeof item.data === 'object' ? item.data : item +} + +/** + * 'sent' when the mail left the mailbox, else 'received'. `sent_flag` is a + * number-boolean (0/1). + * @param {object} msg + * @returns {'sent' | 'received'} + */ +export function mailDirection(msg) { + return msg?.sent_flag ? 'sent' : 'received' +} + +/** First participant's address (or name) from a from/to array. */ +function firstAddress(parties) { + if (!Array.isArray(parties) || parties.length === 0) return '' + return parties[0].email_address ?? parties[0].name ?? '' +} + +const columns = { + id: { header: 'ID' }, + message_time: { header: 'Time' }, + direction: { header: 'Dir', get: (row) => mailDirection(row) }, + from: { header: 'From', get: (row) => firstAddress(row.from) }, + to: { header: 'To', get: (row) => firstAddress(row.to) }, + subject: { + header: 'Subject', + get: (row) => (row.subject ?? '').slice(0, 40), + }, + snippet: { + header: 'Snippet', + get: (row) => (row.snippet ?? '').slice(0, 60), + }, +} + +export default class MailListCommand extends BaseCommand { + static description = + 'List the synced email linked to a deal. Message bodies are excluded by ' + + 'design (privacy posture) — the 225-char snippet is the preview; each row ' + + 'carries has_body_flag and body_url so you can fetch a body yourself. ' + + 'Requires the mail:read scope and a configured email sync.' + + static examples = [ + '<%= config.bin %> mail list --deal 42', + '<%= config.bin %> mail list --deal 42 --output json', + ] + + static flags = { + ...BaseCommand.baseFlags, + deal: Flags.integer({ + required: true, + description: 'Deal ID to list mail for', + }), + } + + async run() { + const { flags } = await this.parse(MailListCommand) + const limit = flags.limit ?? 100 + + const items = await collectPages( + this.apiClient.pageV1(`/api/v1/deals/${flags.deal}/mailMessages`, { + limit: Math.min(limit, 100), + }), + limit, + ) + await this.outputResults(items.map(unwrapMailMessage), columns) + } +} diff --git a/src/commands/webhook/listen.js b/src/commands/webhook/listen.js new file mode 100644 index 0000000..8032e5f --- /dev/null +++ b/src/commands/webhook/listen.js @@ -0,0 +1,505 @@ +import { createServer } from 'node:http' +import { Flags } from '@oclif/core' +import chalk from 'chalk' +import BaseCommand from '../../base-command.js' +import { CliError } from '../../lib/errors.js' +import { resolveSince, formatApiDatetime } from '../../lib/period.js' +import { categorizeChange } from '../../lib/changes.js' +import { collectPages } from '../../lib/pagination.js' +import { + getProfileConfig, + setProfileConfig, + deleteProfileConfig, +} from '../../lib/config.js' + +/** Name stamped on every temp webhook so a later run can sweep leftovers. */ +const MARKER = 'pdcli-listen' +/** Per-profile config key holding the last temp webhook id (orphan GC). */ +const LISTEN_ID_KEY = 'listen_webhook_id' +/** Per-profile resume watermark for --synthetic (kept apart from `changes`). */ +const WATERMARK_KEY = 'listen_watermark' +const DEFAULT_PORT = 3000 +const DEFAULT_INTERVAL_MS = 10_000 + +/** v2 entities that support `updated_since` + update_time ordering. */ +const ENTITY_PATHS = { + deals: '/api/v2/deals', + persons: '/api/v2/persons', + organizations: '/api/v2/organizations', + activities: '/api/v2/activities', + products: '/api/v2/products', +} +const ENTITY_SINGULAR = { + deals: 'deal', + persons: 'person', + organizations: 'organization', + activities: 'activity', + products: 'product', +} + +/** + * Test seams, overridden per-test and reset afterwards. Keeping them on a + * single mutable object lets the command run its real code paths (server bind, + * signal registration, poll loop) while a test drives them deterministically. + * @type {{ signals?: import('node:events').EventEmitter, onListening?: (port: number) => void, sleep?: (ms: number) => Promise }} + */ +export const testHooks = { + signals: undefined, + onListening: undefined, + sleep: undefined, +} + +/** + * Does `event` ("deal.change") match any of the `entity.action` patterns? + * `*` is a wildcard on either side; a bare `entity` means `entity.*`. An empty + * pattern list matches everything. + * @param {string} event + * @param {string[]} [patterns] + * @returns {boolean} + */ +export function matchesEvents(event, patterns) { + if (!patterns || patterns.length === 0) return true + const [entity, action] = event.split('.') + return patterns.some((p) => { + const [pe, pa] = p.split('.') + return ( + (pe === '*' || pe === entity) && + (pa == null || pa === '*' || pa === action) + ) + }) +} + +/** + * Derive an `entity.action` key from a delivery payload, tolerating the v2 + * (`meta.entity`/`meta.action`) shape and unknown/legacy payloads. + * @param {object|null} payload + * @returns {string} + */ +export function eventKey(payload) { + const meta = payload?.meta ?? {} + const entity = meta.entity ?? 'unknown' + const action = meta.action ?? 'unknown' + return `${entity}.${action}` +} + +/** + * Build the webhook-delivery envelope a real webhook would send for a changed + * record: `{ event, meta, current, previous }`. `previous` is null — the feed + * carries only the current state. + * @param {string} entity plural entity name (deals, persons, …) + * @param {object} record the changed record + * @param {string|null} since boundary used to classify create vs change + * @returns {{ event: string, meta: object, current: object, previous: null }} + */ +export function toSyntheticEvent(entity, record, since) { + const singular = ENTITY_SINGULAR[entity] + const action = + categorizeChange(record, since) === 'created' ? 'create' : 'change' + return { + event: `${singular}.${action}`, + meta: { + action, + entity: singular, + id: record.id, + updateTime: record.update_time ?? null, + }, + current: record, + previous: null, + } +} + +/** + * Ascending-by-update_time comparator for synthetic events; events with no + * update_time sort last. Kept pure and exported so every ordering branch is + * covered independently of the engine's sort argument order. + * @param {{ meta: { updateTime: string|null } }} a + * @param {{ meta: { updateTime: string|null } }} b + * @returns {number} + */ +export function compareByUpdateTime(a, b) { + const au = a.meta.updateTime + const bu = b.meta.updateTime + if (au == null) return bu == null ? 0 : 1 + if (bu == null) return -1 + return au.localeCompare(bu) +} + +/** + * One-line human summary of a delivery for a TTY. + * @param {object|null} payload + * @returns {string} + */ +export function formatDeliveryLine(payload) { + const key = eventKey(payload) + const id = payload?.meta?.id ?? payload?.current?.id + const when = new Date().toISOString() + const suffix = id == null ? '' : ` ${chalk.dim(`#${id}`)}` + return `${chalk.dim(when)} ${chalk.cyan(key)}${suffix}` +} + +/** + * POST a payload to the --forward-to sink. Forwarding is best-effort: a failed + * relay must never sink the listener, so errors are logged and swallowed. + * @param {string} url + * @param {string} body + * @param {string} [contentType] + */ +async function forwardEvent(url, body, contentType) { + try { + await fetch(url, { + method: 'POST', + headers: { 'content-type': contentType ?? 'application/json' }, + body, + }) + } catch (err) { + process.stderr.write(`forward-to failed: ${err.message}\n`) + } +} + +export default class WebhookListenCommand extends BaseCommand { + static description = + 'Run a local webhook dev loop. In tunnel mode it registers a temporary ' + + 'Pipedrive webhook pointing at your public tunnel (--url) and prints/forwards ' + + 'each delivery, deleting the webhook on exit. In --synthetic mode it polls the ' + + 'changes feed instead and emits the same delivery envelope with zero inbound ' + + 'network — useful behind a firewall or for reactive agents.' + + static examples = [ + '<%= config.bin %> webhook listen --url https://abc123.ngrok.app --forward-to http://localhost:3000', + '<%= config.bin %> webhook listen --url https://abc123.ngrok.app --events deal.change,person.*', + '<%= config.bin %> webhook listen --synthetic --since 15m', + ] + + static flags = { + ...BaseCommand.baseFlags, + url: Flags.string({ + description: + 'Public tunnel URL that forwards to the local receiver (tunnel mode)', + exclusive: ['synthetic'], + }), + 'forward-to': Flags.string({ + description: 'POST each delivery to this local URL as well', + }), + events: Flags.string({ + description: + 'Comma-separated entity.action filters, e.g. deal.change,person.*', + }), + port: Flags.integer({ + description: 'Local receiver port (tunnel mode)', + default: DEFAULT_PORT, + }), + synthetic: Flags.boolean({ + description: + 'Poll the changes feed and emit webhook-shaped events (no inbound network)', + default: false, + exclusive: ['url'], + }), + since: Flags.string({ + description: + 'Synthetic start point: RFC3339 timestamp or Nd/Nm (else the stored watermark)', + }), + interval: Flags.integer({ + description: 'Milliseconds between synthetic poll cycles', + default: DEFAULT_INTERVAL_MS, + }), + once: Flags.boolean({ + description: 'Process a single delivery / poll cycle then exit', + default: false, + }), + 'max-events': Flags.integer({ + description: 'Exit after emitting this many events', + min: 1, + }), + } + + async run() { + const { flags } = await this.parse(WebhookListenCommand) + if (flags.synthetic) return this.runSynthetic(flags) + if (!flags.url) { + throw new CliError( + 'Provide --url for tunnel mode, or --synthetic to poll the changes feed', + { exitCode: 64 }, + ) + } + return this.runTunnel(flags) + } + + /** + * Print (and optionally forward) a single delivery, honoring the --events + * filter. Returns whether the event passed the filter (i.e. was emitted). + * @param {object|null} payload + * @param {{ format: string, patterns: string[] }} ctx + * @returns {boolean} + */ + emitEvent(payload, { format, patterns }) { + if (!matchesEvents(eventKey(payload), patterns)) return false + this.log( + format === 'table' + ? formatDeliveryLine(payload) + : JSON.stringify(payload), + ) + return true + } + + /** + * Delete leftover pdcli-listen webhooks from a previous crashed run. Best + * effort: a failed list must not block starting a fresh listener. + * @param {string} profile + */ + async sweepOrphans(profile) { + const storedId = getProfileConfig(profile, LISTEN_ID_KEY) + let list + try { + const body = await this.apiClient.get('/api/v1/webhooks') + list = body?.data ?? [] + } catch (err) { + process.stderr.write(`Orphan sweep skipped: ${err.message}\n`) + return + } + const orphans = list.filter( + (w) => w.name === MARKER || (storedId != null && w.id === storedId), + ) + for (const w of orphans) { + try { + await this.apiClient.del(`/api/v1/webhooks/${w.id}`) + process.stderr.write(`Swept orphaned listen webhook ${w.id}\n`) + } catch (err) { + process.stderr.write( + `Could not sweep webhook ${w.id}: ${err.message}\n`, + ) + } + } + if (storedId != null) deleteProfileConfig(profile, LISTEN_ID_KEY) + } + + /** + * Tunnel mode: bind a local receiver, register a temp catch-all webhook at + * the user's tunnel URL, print/forward deliveries, and guarantee the webhook + * is deleted on shutdown (signal or --once/--max-events). + * @param {object} flags + */ + async runTunnel(flags) { + const format = this.resolveFormat() + const patterns = flags.events + ? flags.events + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [] + const profile = this.activeProfile + const forwardTo = flags['forward-to'] + const signals = testHooks.signals ?? process + + await this.sweepOrphans(profile) + + return new Promise((resolve, reject) => { + let webhookId + let count = 0 + let closing = false + + const shutdown = async (signal) => { + if (closing) return + closing = true + server.close() + try { + await this.apiClient.del(`/api/v1/webhooks/${webhookId}`) + deleteProfileConfig(profile, LISTEN_ID_KEY) + } catch (err) { + process.stderr.write( + `Cleanup of webhook ${webhookId} failed: ${err.message}\n`, + ) + } + // Deregister after cleanup: while the async delete is in flight the + // `closing` guard (not listener removal) dedupes a second signal. + signals.off('SIGINT', onSigint) + signals.off('SIGTERM', onSigterm) + if (signal) { + process.stderr.write( + `\nReceived ${signal} — deleted webhook ${webhookId}\n`, + ) + } + resolve() + } + const onSigint = () => shutdown('SIGINT') + const onSigterm = () => shutdown('SIGTERM') + + const server = createServer((req, res) => { + if (req.method !== 'POST') { + res.writeHead(405) + res.end() + return + } + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', async () => { + res.writeHead(200) + res.end() + const raw = Buffer.concat(chunks).toString('utf8') + let payload + try { + payload = JSON.parse(raw) + } catch { + payload = { raw } + } + if (this.emitEvent(payload, { format, patterns })) { + if (forwardTo) { + await forwardEvent(forwardTo, raw, req.headers['content-type']) + } + count++ + if ( + flags.once || + (flags['max-events'] != null && count >= flags['max-events']) + ) { + await shutdown() + } + } + }) + }) + + server.on('error', (err) => { + reject( + new CliError( + `Cannot bind the local receiver on port ${flags.port}: ${err.message}`, + { + exitCode: 78, + }, + ), + ) + }) + + // Bind loopback only: a tunnel (ngrok/cloudflared) forwards to localhost, + // so there's no reason to expose the receiver on other interfaces — and + // it makes an in-use port fail deterministically across OSes. + server.listen(flags.port, '127.0.0.1', async () => { + const port = server.address().port + try { + const created = await this.apiClient.post('/api/v1/webhooks', { + body: { + subscription_url: flags.url, + event_action: '*', + event_object: '*', + version: '2.0', + name: MARKER, + }, + }) + webhookId = created.data.id + } catch (err) { + server.close() + reject(err) + return + } + setProfileConfig(profile, LISTEN_ID_KEY, webhookId) + signals.on('SIGINT', onSigint) + signals.on('SIGTERM', onSigterm) + process.stderr.write( + `Listening on :${port} — webhook ${webhookId} → ${flags.url}\n`, + ) + testHooks.onListening?.(port) + }) + }) + } + + /** + * Synthetic mode: poll the changes feed and emit webhook-shaped envelopes, + * advancing a resume watermark after each cycle. No inbound network, so it + * works behind a firewall and for reactive agents. + * @param {object} flags + */ + async runSynthetic(flags) { + const format = this.resolveFormat() + const patterns = flags.events + ? flags.events + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [] + const profile = this.activeProfile + const forwardTo = flags['forward-to'] + const sleep = + testHooks.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))) + const signals = testHooks.signals ?? process + + let stopping = false + const onSignal = () => { + stopping = true + } + signals.on('SIGINT', onSignal) + signals.on('SIGTERM', onSignal) + + let since + if (flags.since != null) { + since = resolveSince(flags.since) + } else { + const stored = getProfileConfig(profile, WATERMARK_KEY) + if (stored == null) { + signals.off('SIGINT', onSignal) + signals.off('SIGTERM', onSignal) + throw new CliError( + 'No stored watermark — pass --since for the first --synthetic run', + { exitCode: 64 }, + ) + } + since = stored + } + + let count = 0 + try { + while (!stopping) { + const events = await this.pollSynthetic(since) + for (const evt of events) { + const emitted = this.emitEvent(evt, { format, patterns }) + if (emitted && forwardTo) { + await forwardEvent( + forwardTo, + JSON.stringify(evt), + 'application/json', + ) + } + if (evt.meta.updateTime != null) { + since = formatApiDatetime( + new Date(new Date(evt.meta.updateTime).getTime() + 1000), + ) + } + if (emitted) { + count++ + if (flags['max-events'] != null && count >= flags['max-events']) { + stopping = true + break + } + } + } + setProfileConfig(profile, WATERMARK_KEY, since) + if (flags.once || stopping) break + await sleep(flags.interval) + } + } finally { + signals.off('SIGINT', onSignal) + signals.off('SIGTERM', onSignal) + } + } + + /** + * Fetch all entities changed since `since` and shape them into + * webhook-delivery envelopes, sorted by update_time ascending. + * @param {string} since + * @returns {Promise} + */ + async pollSynthetic(since) { + const query = { + updated_since: since, + sort_by: 'update_time', + sort_direction: 'asc', + limit: 500, + } + const events = [] + await Promise.all( + Object.entries(ENTITY_PATHS).map(async ([entity, path]) => { + const rows = await collectPages(this.apiClient.pageV2(path, query)) + for (const row of rows) + events.push(toSyntheticEvent(entity, row, since)) + }), + ) + events.sort(compareByUpdateTime) + return events + } +} diff --git a/src/lib/mcp/catalog.js b/src/lib/mcp/catalog.js index b1c3c6d..7acd52c 100644 --- a/src/lib/mcp/catalog.js +++ b/src/lib/mcp/catalog.js @@ -19,6 +19,7 @@ export const EXCLUDED = new Set([ 'doctor', 'sync:warehouse', 'watch', + 'webhook:listen', ]) // Topics excluded by prefix so future subcommands stay out: `auth:*` manage @@ -67,6 +68,8 @@ export const READ_LEAVES = new Set([ 'forecast', 'conversion-matrix', 'version', + 'helpers', + 'export', ]) // Read-only analytics command ids whose leaf is not a shared verb. diff --git a/test/commands/deal/context.test.js b/test/commands/deal/context.test.js index d42905c..9ce1737 100644 --- a/test/commands/deal/context.test.js +++ b/test/commands/deal/context.test.js @@ -10,7 +10,7 @@ vi.mock('../../../src/lib/config.js', () => ({ loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), })) -const { default: DealContextCommand } = +const { default: DealContextCommand, summarizeMail } = await import('../../../src/commands/deal/context.js') import { runCmd, mockApi } from '../../helpers.js' import { clearFieldsCache } from '../../../src/lib/fields.js' @@ -59,7 +59,52 @@ function mockFields() { .reply(200, { success: true, data: [] }) } -function mockFullBundle() { +// The deal mailMessages list wraps each message under { object, timestamp, data }. +const MAIL_WRAPPED = [ + { + object: 'mailMessage', + timestamp: '2026-06-01 10:00:00', + data: { + id: 1, + subject: 'Re: proposal', + snippet: 'Thanks for sending this over', + message_time: '2026-06-01T10:00:00Z', + sent_flag: 0, + from: [ + { email_address: 'jane@acme.com', name: 'Jane', linked_person_id: 10 }, + ], + to: [ + { email_address: 'rep@us.com', name: 'Rep', linked_person_id: null }, + ], + }, + }, + { + object: 'mailMessage', + timestamp: '2026-06-02 09:00:00', + data: { + id: 2, + subject: 'Following up', + snippet: 'Just checking in', + message_time: '2026-06-02T09:00:00Z', + sent_flag: 1, + from: [ + { email_address: 'rep@us.com', name: 'Rep', linked_person_id: null }, + ], + to: [ + { email_address: 'jane@acme.com', name: 'Jane', linked_person_id: 10 }, + ], + }, + }, +] + +function mockMail(status, body) { + return mockApi() + .get('/api/v1/deals/42/mailMessages') + .query(true) + .reply(status, body) +} + +function mockCoreBundle() { mockApi().get('/api/v2/deals/42').reply(200, { success: true, data: DEAL }) mockApi() .get('/api/v2/persons/10') @@ -89,6 +134,15 @@ function mockFullBundle() { mockFields() } +function mockFullBundle() { + mockCoreBundle() + mockMail(200, { + success: true, + data: MAIL_WRAPPED, + additional_data: { pagination: { more_items_in_collection: false } }, + }) +} + describe('deal context', () => { beforeEach(() => { nock.cleanAll() @@ -124,6 +178,21 @@ describe('deal context', () => { noteCount: 1, productCount: 1, }) + // Mail summary: latest message (by message_time) drives the signal. + expect(b.mail).toMatchObject({ + message_count: 2, + last_message_at: '2026-06-02T09:00:00Z', + last_direction: 'sent', + latest_subject: 'Following up', + }) + expect(b.mail.participants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: 'jane@acme.com', + linked_person_id: 10, + }), + ]), + ) }) it('renders a compact summary in table mode', async () => { @@ -132,6 +201,59 @@ describe('deal context', () => { expect(stdout).toContain('Acme expansion') expect(stdout).toContain('Jane Doe') expect(stdout).toContain('Acme Inc') + expect(stdout).toMatch(/Mail: 2 msgs .* sent/) + }) + + it('renders the mail line without a timestamp when message_time is absent', async () => { + mockCoreBundle() + mockMail(200, { + success: true, + data: [ + { + object: 'mailMessage', + data: { id: 9, from: [{ email_address: 'z@z.com' }] }, // no message_time + }, + ], + additional_data: { pagination: { more_items_in_collection: false } }, + }) + const stdout = await runCmd(DealContextCommand, ['42', '--output', 'table']) + expect(stdout).toMatch(/Mail: 1 msgs · last received$/m) + }) + + it('sets mail to null and skips the fetch with --no-mail', async () => { + mockCoreBundle() + // Register the mail interceptor but expect it to stay unused. + const mail = mockMail(200, { success: true, data: MAIL_WRAPPED }) + const stdout = await runCmd(DealContextCommand, [ + '42', + '--no-mail', + '--output', + 'json', + ]) + const b = JSON.parse(stdout) + expect(b.mail).toBeNull() + expect(mail.isDone()).toBe(false) + }) + + it('degrades mail to null on a 403 (no mail:read scope) without failing', async () => { + mockCoreBundle() + mockMail(403, { success: false, error: 'Scope mail:read is missing' }) + const stdout = await runCmd(DealContextCommand, ['42', '--output', 'json']) + const b = JSON.parse(stdout) + expect(b.deal.id).toBe(42) + expect(b.mail).toBeNull() + }) + + it('sets mail to null when the deal has no synced mail', async () => { + mockCoreBundle() + mockMail(200, { + success: true, + data: [], + additional_data: { pagination: { more_items_in_collection: false } }, + }) + const stdout = await runCmd(DealContextCommand, ['42', '--output', 'json']) + const b = JSON.parse(stdout) + expect(b.mail).toBeNull() }) it('skips slices with --no-* flags (no fetch, empty in the bundle)', async () => { @@ -170,6 +292,7 @@ describe('deal context', () => { '--no-notes', '--no-products', '--no-participants', + '--no-mail', '--output', 'json', ]) @@ -219,7 +342,12 @@ describe('deal context', () => { .query(true) .reply(200, { success: true, data: [] }) - const stdout = await runCmd(DealContextCommand, ['42', '--output', 'table']) + const stdout = await runCmd(DealContextCommand, [ + '42', + '--no-mail', + '--output', + 'table', + ]) expect(stdout).toContain('Deal 42') expect(stdout).toContain('Person: — · Org: —') expect(stdout).toMatch(/Flags:.*missingContact/) @@ -253,10 +381,69 @@ describe('deal context', () => { .query(true) .reply(200, { success: true, data: [] }) - const stdout = await runCmd(DealContextCommand, ['42', '--output', 'json']) + const stdout = await runCmd(DealContextCommand, [ + '42', + '--no-mail', + '--output', + 'json', + ]) const b = JSON.parse(stdout) expect(b.person).toBeNull() expect(b.org).toBeNull() expect(b.flags.missingContact).toBe(true) + expect(b.mail).toBeNull() + }) + + describe('summarizeMail', () => { + it('returns null for a non-array or empty input', () => { + expect(summarizeMail(null)).toBeNull() + expect(summarizeMail([])).toBeNull() + }) + + it('dedupes participants, skips keyless parties, keeps the latest by time', () => { + const summary = summarizeMail([ + { + id: 1, + message_time: '2026-06-02T00:00:00Z', + subject: 'B', + sent_flag: 1, + from: [{ email_address: 'a@b.com', name: 'A', linked_person_id: 1 }], + to: [{ name: 'NameOnly' }, { linked_person_id: 5 }, {}], + }, + // No message_time / subject / to → older; from repeats a@b.com (deduped). + { id: 2, from: [{ email_address: 'a@b.com' }] }, + ]) + expect(summary.message_count).toBe(2) + expect(summary.last_message_at).toBe('2026-06-02T00:00:00Z') + expect(summary.latest_subject).toBe('B') + expect(summary.last_direction).toBe('sent') + expect(summary.participants).toEqual([ + { name: 'A', email: 'a@b.com', linked_person_id: 1 }, + { name: 'NameOnly', email: null, linked_person_id: null }, + { name: null, email: null, linked_person_id: 5 }, + ]) + }) + + it('promotes a later message and handles a missing from array', () => { + const summary = summarizeMail([ + { id: 3, to: [{ email_address: 'x@y.com' }] }, // no from / time / subject + { id: 4, message_time: '2026-01-01T00:00:00Z', from: [] }, + ]) + // id4 has a time, id3 does not → id4 is latest, but it has no subject. + expect(summary.last_message_at).toBe('2026-01-01T00:00:00Z') + expect(summary.latest_subject).toBeNull() + expect(summary.participants).toEqual([ + { name: null, email: 'x@y.com', linked_person_id: null }, + ]) + }) + + it('reports null time/subject when the sole message lacks them', () => { + const summary = summarizeMail([ + { id: 5, from: [{ email_address: 'z@z.com' }] }, + ]) + expect(summary.last_message_at).toBeNull() + expect(summary.latest_subject).toBeNull() + expect(summary.last_direction).toBe('received') + }) }) }) diff --git a/test/commands/filter/create.test.js b/test/commands/filter/create.test.js new file mode 100644 index 0000000..c61aaf9 --- /dev/null +++ b/test/commands/filter/create.test.js @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +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: FilterCreateCommand } = + await import('../../../src/commands/filter/create.js') +import { runCmd, mockApi } from '../../helpers.js' + +const CONDITIONS = { + glue: 'and', + conditions: [ + { + glue: 'and', + conditions: [ + { object: 'deal', field_id: '123', operator: '=', value: 'x' }, + ], + }, + { glue: 'or', conditions: [] }, + ], +} + +describe('filter create', () => { + beforeEach(() => { + nock.cleanAll() + mockResolveCredentials.mockResolvedValue({ + companyDomain: 'acme', + token: 'tok', + source: 'profile', + }) + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('POSTs name, type and inline conditions JSON', async () => { + mockApi() + .post('/api/v1/filters', { + name: 'Open deals', + type: 'deals', + conditions: CONDITIONS, + }) + .reply(201, { + success: true, + data: { id: 42, name: 'Open deals', type: 'deals' }, + }) + + const stdout = await runCmd(FilterCreateCommand, [ + '--name', + 'Open deals', + '--type', + 'deals', + '--conditions', + JSON.stringify(CONDITIONS), + '--output', + 'json', + ]) + + expect(JSON.parse(stdout).id).toBe(42) + }) + + it('reads conditions from an @file', async () => { + const file = join(tmpdir(), `pdcli-filter-${Date.now()}.json`) + writeFileSync(file, JSON.stringify(CONDITIONS)) + + mockApi() + .post('/api/v1/filters', { + name: 'From file', + type: 'deals', + conditions: CONDITIONS, + }) + .reply(201, { success: true, data: { id: 43 } }) + + try { + const stdout = await runCmd(FilterCreateCommand, [ + '--name', + 'From file', + '--type', + 'deals', + '--conditions', + `@${file}`, + '--output', + 'json', + ]) + expect(JSON.parse(stdout).id).toBe(43) + } finally { + rmSync(file, { force: true }) + } + }) + + it('rejects malformed conditions JSON with exit 65', async () => { + const err = await FilterCreateCommand.run([ + '--name', + 'Bad', + '--type', + 'deals', + '--conditions', + '{not json', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(65) + }) + + it('rejects an unknown --type with exit 64', async () => { + const err = await FilterCreateCommand.run([ + '--name', + 'X', + '--type', + 'contacts', + '--conditions', + '{}', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('requires --name', async () => { + const err = await FilterCreateCommand.run([ + '--type', + 'deals', + '--conditions', + '{}', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) +}) diff --git a/test/commands/filter/export.test.js b/test/commands/filter/export.test.js new file mode 100644 index 0000000..1b954c0 --- /dev/null +++ b/test/commands/filter/export.test.js @@ -0,0 +1,126 @@ +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: FilterExportCommand } = + await import('../../../src/commands/filter/export.js') +import { runCmd, mockApi } from '../../helpers.js' + +const CONDITIONS = { + glue: 'and', + conditions: [ + { glue: 'and', conditions: [] }, + { glue: 'or', conditions: [] }, + ], +} + +describe('filter export', () => { + beforeEach(() => { + nock.cleanAll() + mockResolveCredentials.mockResolvedValue({ + companyDomain: 'acme', + token: 'tok', + source: 'profile', + }) + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('exports a single filter as a create-shaped object', async () => { + mockApi() + .get('/api/v1/filters/5') + .reply(200, { + success: true, + data: { + id: 5, + name: 'Open deals', + type: 'deals', + user_id: 99, + conditions: CONDITIONS, + }, + }) + + const stdout = await runCmd(FilterExportCommand, ['5']) + const out = JSON.parse(stdout) + + expect(out).toEqual({ + name: 'Open deals', + type: 'deals', + conditions: CONDITIONS, + }) + }) + + it('exports every filter with --all', async () => { + mockApi() + .get('/api/v1/filters') + .reply(200, { + success: true, + data: [ + { id: 5, name: 'A', type: 'deals' }, + { id: 6, name: 'B', type: 'people' }, + ], + }) + mockApi() + .get('/api/v1/filters/5') + .reply(200, { + success: true, + data: { id: 5, name: 'A', type: 'deals', conditions: CONDITIONS }, + }) + mockApi() + .get('/api/v1/filters/6') + .reply(200, { + success: true, + data: { id: 6, name: 'B', type: 'people', conditions: CONDITIONS }, + }) + + const stdout = await runCmd(FilterExportCommand, ['--all']) + const out = JSON.parse(stdout) + + expect(out).toEqual([ + { name: 'A', type: 'deals', conditions: CONDITIONS }, + { name: 'B', type: 'people', conditions: CONDITIONS }, + ]) + }) + + it('emits an empty array when --all finds no filters', async () => { + mockApi().get('/api/v1/filters').reply(200, { success: true }) + + const stdout = await runCmd(FilterExportCommand, ['--all']) + expect(JSON.parse(stdout)).toEqual([]) + }) + + it('honors --jq over the exported object', async () => { + mockApi() + .get('/api/v1/filters/5') + .reply(200, { + success: true, + data: { id: 5, name: 'Open deals', type: 'deals', conditions: {} }, + }) + + const stdout = await runCmd(FilterExportCommand, [ + '5', + '--jq', + '.name', + '--output', + 'json', + ]) + + expect(stdout.trim()).toBe('"Open deals"') + }) + + it('errors with exit 64 when neither id nor --all is given', async () => { + const err = await FilterExportCommand.run([]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) +}) diff --git a/test/commands/filter/helpers.test.js b/test/commands/filter/helpers.test.js new file mode 100644 index 0000000..66d8b00 --- /dev/null +++ b/test/commands/filter/helpers.test.js @@ -0,0 +1,100 @@ +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: FilterHelpersCommand } = + await import('../../../src/commands/filter/helpers.js') +import { runCmd, mockApi } from '../../helpers.js' + +const HELPERS = { + operators: { + varchar: { + '=': 'is', + '!=': 'is not', + }, + date: { + '=': 'is', + '>': 'is later than', + }, + enum: [{ '=': 'is' }, { 'IS NULL': 'is empty' }], + }, +} + +describe('filter helpers', () => { + beforeEach(() => { + nock.cleanAll() + mockResolveCredentials.mockResolvedValue({ + companyDomain: 'acme', + token: 'tok', + source: 'profile', + }) + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('flattens operators into type/operator/meaning rows (json)', async () => { + mockApi() + .get('/api/v1/filters/helpers') + .reply(200, { success: true, data: HELPERS }) + + const stdout = await runCmd(FilterHelpersCommand, ['--output', 'json']) + const rows = JSON.parse(stdout) + + expect( + rows.some( + (r) => r.type === 'varchar' && r.operator === '=' && r.meaning === 'is', + ), + ).toBe(true) + // The enum array-of-objects shape is flattened too. + expect( + rows.some((r) => r.type === 'enum' && r.operator === 'IS NULL'), + ).toBe(true) + }) + + it('renders a table with the operator columns', async () => { + mockApi() + .get('/api/v1/filters/helpers') + .reply(200, { success: true, data: HELPERS }) + + const stdout = await runCmd(FilterHelpersCommand, ['--output', 'table']) + expect(stdout).toContain('varchar') + expect(stdout).toContain('is later than') + }) + + it('emits no rows when the helpers payload has no operators', async () => { + mockApi() + .get('/api/v1/filters/helpers') + .reply(200, { success: true, data: {} }) + + const stdout = await runCmd(FilterHelpersCommand, ['--output', 'json']) + expect(JSON.parse(stdout)).toEqual([]) + }) + + it('filters the operators to a single field data type', async () => { + mockApi() + .get('/api/v1/filters/helpers') + .reply(200, { success: true, data: HELPERS }) + + const stdout = await runCmd(FilterHelpersCommand, [ + '--type', + 'date', + '--output', + 'json', + ]) + const rows = JSON.parse(stdout) + + expect(rows.every((r) => r.type === 'date')).toBe(true) + expect(rows.length).toBe(2) + }) +}) diff --git a/test/commands/filter/update.test.js b/test/commands/filter/update.test.js new file mode 100644 index 0000000..2e5ad06 --- /dev/null +++ b/test/commands/filter/update.test.js @@ -0,0 +1,90 @@ +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: FilterUpdateCommand } = + await import('../../../src/commands/filter/update.js') +import { runCmd, mockApi } from '../../helpers.js' + +const CONDITIONS = { + glue: 'and', + conditions: [ + { glue: 'and', conditions: [] }, + { glue: 'or', conditions: [] }, + ], +} + +describe('filter update', () => { + beforeEach(() => { + nock.cleanAll() + mockResolveCredentials.mockResolvedValue({ + companyDomain: 'acme', + token: 'tok', + source: 'profile', + }) + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('PUTs the name only', async () => { + mockApi() + .put('/api/v1/filters/5', { name: 'Renamed' }) + .reply(200, { success: true, data: { id: 5, name: 'Renamed' } }) + + const stdout = await runCmd(FilterUpdateCommand, [ + '5', + '--name', + 'Renamed', + '--output', + 'json', + ]) + + expect(JSON.parse(stdout).name).toBe('Renamed') + }) + + it('PUTs updated conditions from inline JSON', async () => { + mockApi() + .put('/api/v1/filters/5', { conditions: CONDITIONS }) + .reply(200, { success: true, data: { id: 5 } }) + + const stdout = await runCmd(FilterUpdateCommand, [ + '5', + '--conditions', + JSON.stringify(CONDITIONS), + '--output', + 'json', + ]) + + expect(JSON.parse(stdout).id).toBe(5) + }) + + it('errors with exit 64 when nothing is provided', async () => { + const err = await FilterUpdateCommand.run(['5']).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('rejects malformed conditions JSON with exit 65', async () => { + const err = await FilterUpdateCommand.run([ + '5', + '--conditions', + '{bad', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(65) + }) + + it('requires the id arg', async () => { + const err = await FilterUpdateCommand.run(['--name', 'X']).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) +}) diff --git a/test/commands/mail/list.test.js b/test/commands/mail/list.test.js new file mode 100644 index 0000000..598ee5a --- /dev/null +++ b/test/commands/mail/list.test.js @@ -0,0 +1,178 @@ +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: MailListCommand, + unwrapMailMessage, + mailDirection, +} = await import('../../../src/commands/mail/list.js') +import { runCmd, mockApi } from '../../helpers.js' + +// The deal mailMessages list wraps each message under { object, timestamp, data }. +const WRAPPED = [ + { + object: 'mailMessage', + timestamp: '2026-06-01 10:00:00', + data: { + id: 1, + subject: 'Re: proposal', + snippet: 'Thanks for sending this over', + message_time: '2026-06-01T10:00:00Z', + sent_flag: 0, + has_body_flag: 1, + body_url: 'https://example.cloudfront.net/1', + from: [ + { email_address: 'jane@acme.com', name: 'Jane', linked_person_id: 10 }, + ], + to: [ + { email_address: 'rep@us.com', name: 'Rep', linked_person_id: null }, + ], + }, + }, +] + +describe('mail list', () => { + beforeEach(() => { + nock.cleanAll() + mockResolveCredentials.mockResolvedValue({ + mode: 'token', + companyDomain: 'acme', + token: 'tok', + source: 'profile', + }) + }) + afterEach(() => nock.cleanAll()) + + it('lists a deal mail messages, unwrapped, as JSON', async () => { + mockApi() + .get('/api/v1/deals/42/mailMessages') + .query(true) + .reply(200, { + success: true, + data: WRAPPED, + additional_data: { pagination: { more_items_in_collection: false } }, + }) + + const stdout = await runCmd(MailListCommand, [ + '--deal', + '42', + '--output', + 'json', + ]) + const rows = JSON.parse(stdout) + expect(rows).toHaveLength(1) + // Unwrapped: the message fields sit at the top level, not under .data. + expect(rows[0].id).toBe(1) + expect(rows[0].subject).toBe('Re: proposal') + expect(rows[0].snippet).toBe('Thanks for sending this over') + expect(rows[0].from[0].email_address).toBe('jane@acme.com') + }) + + it('renders a table with the derived direction and addresses', async () => { + mockApi() + .get('/api/v1/deals/42/mailMessages') + .query(true) + .reply(200, { + success: true, + data: WRAPPED, + additional_data: { pagination: { more_items_in_collection: false } }, + }) + + const stdout = await runCmd(MailListCommand, [ + '--deal', + '42', + '--output', + 'table', + ]) + expect(stdout).toContain('received') + expect(stdout).toContain('jane@acme.com') + expect(stdout).toContain('Re: proposal') + }) + + it('renders sparse rows: name-only, addressless, and empty recipient lists', async () => { + mockApi() + .get('/api/v1/deals/42/mailMessages') + .query(true) + .reply(200, { + success: true, + data: [ + { + object: 'mailMessage', + data: { + id: 10, + message_time: '2026-06-03T10:00:00Z', + sent_flag: 1, + // no subject, no snippet + from: [{ name: 'No Address' }], // name fallback for From + to: [], // empty → blank To + }, + }, + { + object: 'mailMessage', + data: { + id: 11, + message_time: '2026-06-03T11:00:00Z', + from: [{}], // neither email nor name → blank From + to: [{ email_address: 'a@b.com' }], + }, + }, + ], + additional_data: { pagination: { more_items_in_collection: false } }, + }) + + const stdout = await runCmd(MailListCommand, [ + '--deal', + '42', + '--output', + 'table', + ]) + expect(stdout).toContain('No Address') + expect(stdout).toContain('a@b.com') + expect(stdout).toContain('sent') + }) + + it('surfaces a permission error (403) rather than swallowing it', async () => { + mockApi() + .get('/api/v1/deals/42/mailMessages') + .query(true) + .reply(403, { success: false, error: 'Scope mail:read is missing' }) + + // A 403 is a real failure for an explicit `mail list` — the permission + // error surfaces (exit 77 = EX_NOPERM), it is not degraded to an empty list. + await expect( + runCmd(MailListCommand, ['--deal', '42', '--output', 'json']), + ).rejects.toMatchObject({ oclif: { exit: 77 } }) + }) + + describe('unwrapMailMessage', () => { + it('unwraps the { object, timestamp, data } list shape', () => { + expect( + unwrapMailMessage({ object: 'mailMessage', data: { id: 7 } }), + ).toEqual({ id: 7 }) + }) + it('returns an already-flat message unchanged', () => { + expect(unwrapMailMessage({ id: 7, subject: 'x' })).toEqual({ + id: 7, + subject: 'x', + }) + }) + }) + + describe('mailDirection', () => { + it('maps sent_flag 1 to sent and 0 to received', () => { + expect(mailDirection({ sent_flag: 1 })).toBe('sent') + expect(mailDirection({ sent_flag: 0 })).toBe('received') + expect(mailDirection({})).toBe('received') + }) + }) +}) diff --git a/test/commands/webhook/listen.test.js b/test/commands/webhook/listen.test.js new file mode 100644 index 0000000..3993778 --- /dev/null +++ b/test/commands/webhook/listen.test.js @@ -0,0 +1,813 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import nock from 'nock' +import { EventEmitter } from 'node:events' +import { request as httpRequest } from 'node:http' + +const mockResolveCredentials = vi.fn() +vi.mock('../../../src/lib/auth.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, resolveCredentials: mockResolveCredentials } +}) + +// Stateful config mock: profile keys live in a plain object so the command's +// watermark + orphan-id bookkeeping is observable from the test. +let store +vi.mock('../../../src/lib/config.js', () => ({ + loadConfig: vi.fn(() => ({ activeProfile: 'default' })), + getProfileConfig: vi.fn((_p, key) => store[key]), + setProfileConfig: vi.fn((_p, key, value) => { + store[key] = value + }), + deleteProfileConfig: vi.fn((_p, key) => { + delete store[key] + }), +})) + +const listenModule = await import('../../../src/commands/webhook/listen.js') +const { + default: WebhookListenCommand, + testHooks, + matchesEvents, + eventKey, + toSyntheticEvent, + formatDeliveryLine, + compareByUpdateTime, +} = listenModule +import { runCmd, mockApi } from '../../helpers.js' + +const DAY = 86_400_000 +const daysAgo = (n) => new Date(Date.now() - n * DAY).toISOString() + +/** POST a JSON body to the local receiver and resolve its HTTP status. */ +async function deliver(port, payload, { raw } = {}) { + const res = await fetch(`http://127.0.0.1:${port}/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: raw ?? JSON.stringify(payload), + }) + return res.status +} + +/** POST via raw node http (no content-type header) to exercise the default. */ +function deliverNoContentType(port, payload) { + return new Promise((resolve, reject) => { + const req = httpRequest( + { host: '127.0.0.1', port, method: 'POST', path: '/' }, + (res) => { + res.resume() + res.on('end', resolve) + }, + ) + req.on('error', reject) + req.end(JSON.stringify(payload)) + }) +} + +describe('webhook listen', () => { + beforeEach(() => { + nock.cleanAll() + store = {} + testHooks.signals = undefined + testHooks.onListening = undefined + testHooks.sleep = undefined + mockResolveCredentials.mockResolvedValue({ + mode: 'token', + companyDomain: 'acme', + token: 'tok', + source: 'profile', + }) + // Real loopback receiver is allowed; the Pipedrive API host is nocked. + nock.disableNetConnect() + nock.enableNetConnect('127.0.0.1') + }) + + afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() + testHooks.signals = undefined + testHooks.onListening = undefined + testHooks.sleep = undefined + }) + + // ---- pure helpers ------------------------------------------------------- + + describe('matchesEvents', () => { + it('matches everything when no patterns are given', () => { + expect(matchesEvents('deal.change', [])).toBe(true) + expect(matchesEvents('deal.change', undefined)).toBe(true) + }) + it('matches an exact entity.action pattern', () => { + expect(matchesEvents('deal.change', ['deal.change'])).toBe(true) + expect(matchesEvents('person.change', ['deal.change'])).toBe(false) + }) + it('supports wildcards on either side', () => { + expect(matchesEvents('person.create', ['person.*'])).toBe(true) + expect(matchesEvents('deal.delete', ['*.delete'])).toBe(true) + expect(matchesEvents('deal.delete', ['person'])).toBe(false) + }) + it('treats a bare entity pattern as entity.*', () => { + expect(matchesEvents('deal.change', ['deal'])).toBe(true) + }) + }) + + describe('eventKey', () => { + it('reads meta.entity + meta.action', () => { + expect(eventKey({ meta: { entity: 'deal', action: 'change' } })).toBe( + 'deal.change', + ) + }) + it('falls back to unknown parts', () => { + expect(eventKey({})).toBe('unknown.unknown') + expect(eventKey(null)).toBe('unknown.unknown') + }) + }) + + describe('toSyntheticEvent', () => { + it('shapes a record like a webhook delivery envelope', () => { + const rec = { + id: 1, + title: 'X', + add_time: daysAgo(2), + update_time: daysAgo(1), + } + const evt = toSyntheticEvent('deals', rec, daysAgo(30)) + expect(evt.event).toBe('deal.create') + expect(evt.meta).toMatchObject({ + action: 'create', + entity: 'deal', + id: 1, + }) + expect(evt.current).toBe(rec) + expect(evt.previous).toBeNull() + }) + it('maps an older record to a change action', () => { + const rec = { id: 2, update_time: daysAgo(1), add_time: daysAgo(100) } + const evt = toSyntheticEvent('persons', rec, daysAgo(30)) + expect(evt.event).toBe('person.change') + }) + }) + + describe('compareByUpdateTime', () => { + const ev = (t) => ({ meta: { updateTime: t } }) + it('orders timestamps ascending', () => { + expect( + compareByUpdateTime( + ev('2026-01-01T00:00:00Z'), + ev('2026-02-01T00:00:00Z'), + ), + ).toBeLessThan(0) + }) + it('sorts a missing timestamp after a present one', () => { + expect(compareByUpdateTime(ev(null), ev('2026-01-01T00:00:00Z'))).toBe(1) + expect(compareByUpdateTime(ev('2026-01-01T00:00:00Z'), ev(null))).toBe(-1) + }) + it('treats two missing timestamps as equal', () => { + expect(compareByUpdateTime(ev(null), ev(null))).toBe(0) + }) + }) + + describe('formatDeliveryLine', () => { + it('includes the event key and id', () => { + const line = formatDeliveryLine({ + meta: { entity: 'deal', action: 'change', id: 7 }, + }) + expect(line).toContain('deal.change') + expect(line).toContain('#7') + }) + it('omits the id fragment when none is present', () => { + const line = formatDeliveryLine({ + meta: { entity: 'deal', action: 'change' }, + }) + expect(line).toContain('deal.change') + expect(line).not.toContain('#') + }) + it('falls back to current.id when meta has no id', () => { + const line = formatDeliveryLine({ + meta: { entity: 'deal', action: 'change' }, + current: { id: 21 }, + }) + expect(line).toContain('#21') + }) + }) + + // ---- flag validation ---------------------------------------------------- + + it('errors (64) without --url and without --synthetic', async () => { + const err = await WebhookListenCommand.run([]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + // ---- tunnel mode -------------------------------------------------------- + + it('creates a temp webhook, prints a delivery, and deletes on shutdown (--once)', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post( + '/api/v1/webhooks', + (b) => b.name === 'pdcli-listen' && b.event_action === '*', + ) + .reply(201, { success: true, data: { id: 42 } }) + const del = mockApi() + .delete('/api/v1/webhooks/42') + .reply(200, { success: true, data: { id: 42 } }) + + testHooks.onListening = async (port) => { + await deliver(port, { meta: { entity: 'deal', action: 'change', id: 9 } }) + } + + const stdout = await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + '--once', + '--output', + 'json', + ]) + expect(JSON.parse(stdout.trim())).toMatchObject({ + meta: { entity: 'deal', action: 'change', id: 9 }, + }) + expect(del.isDone()).toBe(true) + // The orphan-GC id is cleared after a clean shutdown. + expect(store.listen_webhook_id).toBeUndefined() + }) + + it('renders a human line in a TTY (table) and forwards the raw payload', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 1 } }) + mockApi() + .delete('/api/v1/webhooks/1') + .reply(200, { success: true, data: {} }) + + // Real sink server captures what the command forwards. + const { createServer } = await import('node:http') + const received = [] + const sink = createServer((req, res) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => { + received.push(Buffer.concat(chunks).toString('utf8')) + res.writeHead(200) + res.end() + }) + }) + await new Promise((r) => sink.listen(0, '127.0.0.1', r)) + const sinkUrl = `http://127.0.0.1:${sink.address().port}/` + + testHooks.onListening = async (port) => { + await deliver(port, { + meta: { entity: 'person', action: 'create', id: 3 }, + }) + } + + const stdout = await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--forward-to', + sinkUrl, + '--port', + '0', + '--once', + ]) + await new Promise((r) => sink.close(r)) + expect(stdout).toContain('person.create') + expect(received).toHaveLength(1) + expect(JSON.parse(received[0])).toMatchObject({ meta: { id: 3 } }) + }) + + it('filters deliveries by --events (non-matching are dropped, not counted)', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 5 } }) + mockApi() + .delete('/api/v1/webhooks/5') + .reply(200, { success: true, data: {} }) + + testHooks.onListening = async (port) => { + // A non-matching event first (dropped), then a matching one triggers --once. + await deliver(port, { + meta: { entity: 'person', action: 'change', id: 1 }, + }) + await deliver(port, { meta: { entity: 'deal', action: 'change', id: 2 } }) + } + + const stdout = await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--events', + 'deal.*', + '--port', + '0', + '--once', + '--output', + 'json', + ]) + const lines = stdout.trim().split('\n').filter(Boolean) + expect(lines).toHaveLength(1) + expect(JSON.parse(lines[0]).meta.id).toBe(2) + }) + + it('stops after --max-events deliveries', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 8 } }) + mockApi() + .delete('/api/v1/webhooks/8') + .reply(200, { success: true, data: {} }) + + testHooks.onListening = async (port) => { + await deliver(port, { meta: { entity: 'deal', action: 'change', id: 1 } }) + await deliver(port, { meta: { entity: 'deal', action: 'change', id: 2 } }) + } + + const stdout = await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--max-events', + '2', + '--port', + '0', + '--output', + 'json', + ]) + expect(stdout.trim().split('\n').filter(Boolean)).toHaveLength(2) + }) + + it('answers 405 to non-POST probes and tolerates non-JSON bodies', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 3 } }) + mockApi() + .delete('/api/v1/webhooks/3') + .reply(200, { success: true, data: {} }) + + let probeStatus + testHooks.onListening = async (port) => { + probeStatus = (await fetch(`http://127.0.0.1:${port}/`)).status + await deliver(port, null, { raw: 'not json' }) + } + + const stdout = await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + '--once', + '--output', + 'json', + ]) + expect(probeStatus).toBe(405) + expect(JSON.parse(stdout.trim())).toEqual({ raw: 'not json' }) + }) + + it('sweeps orphaned pdcli-listen webhooks from a previous crashed run', async () => { + store.listen_webhook_id = 77 + mockApi() + .get('/api/v1/webhooks') + .reply(200, { + success: true, + data: [ + { id: 77, name: 'pdcli-listen' }, + { id: 88, name: 'someone-elses-hook' }, + ], + }) + const sweepDel = mockApi() + .delete('/api/v1/webhooks/77') + .reply(200, { success: true }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 90 } }) + mockApi() + .delete('/api/v1/webhooks/90') + .reply(200, { success: true, data: {} }) + + const signals = new EventEmitter() + testHooks.signals = signals + testHooks.onListening = () => signals.emit('SIGINT') + + await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + ]) + expect(sweepDel.isDone()).toBe(true) + }) + + it('tolerates a failed orphan-list sweep (best effort)', async () => { + mockApi().get('/api/v1/webhooks').reply(500, { success: false }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 11 } }) + mockApi() + .delete('/api/v1/webhooks/11') + .reply(200, { success: true, data: {} }) + + const signals = new EventEmitter() + testHooks.signals = signals + testHooks.onListening = () => signals.emit('SIGTERM') + + await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + '--no-retry', + ]) + // No throw; the run cleaned up its own webhook via the SIGTERM path. + expect(store.listen_webhook_id).toBeUndefined() + }) + + it('swallows a forward-to failure and defaults a missing content-type', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 12 } }) + mockApi() + .delete('/api/v1/webhooks/12') + .reply(200, { success: true, data: {} }) + + // A forward-to that is guaranteed to refuse the connection. + const { createServer } = await import('node:http') + const dead = createServer(() => {}) + await new Promise((r) => dead.listen(0, '127.0.0.1', r)) + const deadUrl = `http://127.0.0.1:${dead.address().port}/` + await new Promise((r) => dead.close(r)) + + testHooks.onListening = async (port) => { + // No content-type header on this delivery → forwardEvent defaults it. + await deliverNoContentType(port, { + meta: { entity: 'deal', action: 'change', id: 1 }, + }) + } + + await expect( + runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--forward-to', + deadUrl, + '--port', + '0', + '--once', + '--output', + 'json', + ]), + ).resolves.toBeDefined() + }) + + it('continues when sweeping an orphaned webhook fails to delete', async () => { + mockApi() + .get('/api/v1/webhooks') + .reply(200, { success: true, data: [{ id: 70, name: 'pdcli-listen' }] }) + mockApi().delete('/api/v1/webhooks/70').reply(500, { success: false }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 71 } }) + mockApi() + .delete('/api/v1/webhooks/71') + .reply(200, { success: true, data: {} }) + + const signals = new EventEmitter() + testHooks.signals = signals + testHooks.onListening = () => signals.emit('SIGINT') + + await expect( + runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + '--no-retry', + ]), + ).resolves.toBeDefined() + }) + + it('handles an empty (204) webhook list during the orphan sweep', async () => { + mockApi().get('/api/v1/webhooks').reply(204) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 33 } }) + mockApi() + .delete('/api/v1/webhooks/33') + .reply(200, { success: true, data: {} }) + + const signals = new EventEmitter() + testHooks.signals = signals + testHooks.onListening = () => signals.emit('SIGINT') + + await expect( + runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + ]), + ).resolves.toBeDefined() + }) + + it('shuts down and deletes the webhook on SIGINT', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 55 } }) + const del = mockApi() + .delete('/api/v1/webhooks/55') + .reply(200, { success: true }) + + const signals = new EventEmitter() + testHooks.signals = signals + testHooks.onListening = () => signals.emit('SIGINT') + + await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + ]) + expect(del.isDone()).toBe(true) + }) + + it('a repeated signal is a no-op (idempotent shutdown)', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 66 } }) + const del = mockApi() + .delete('/api/v1/webhooks/66') + .reply(200, { success: true }) + + const signals = new EventEmitter() + testHooks.signals = signals + testHooks.onListening = () => { + signals.emit('SIGINT') + signals.emit('SIGINT') // second one must not double-delete + } + + await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + ]) + expect(del.isDone()).toBe(true) + }) + + it('swallows a cleanup DELETE failure on shutdown', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 4 } }) + mockApi().delete('/api/v1/webhooks/4').reply(500, { success: false }) + + const signals = new EventEmitter() + testHooks.signals = signals + testHooks.onListening = () => signals.emit('SIGINT') + + // Must resolve despite the DELETE failing. + await expect( + runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + '--no-retry', + ]), + ).resolves.toBeDefined() + }) + + it('rejects when the receiver port cannot be bound', async () => { + const { createServer } = await import('node:http') + const blocker = createServer(() => {}) + await new Promise((r) => blocker.listen(0, '127.0.0.1', r)) + const busyPort = blocker.address().port + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + + const err = await WebhookListenCommand.run([ + '--url', + 'https://tunnel.example', + '--port', + String(busyPort), + ]).catch((e) => e) + await new Promise((r) => blocker.close(r)) + expect(err.exitCode ?? err.oclif?.exit).toBe(78) + }) + + it('rejects (and closes the server) when the webhook cannot be created', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi().post('/api/v1/webhooks').reply(500, { success: false }) + + const err = await WebhookListenCommand.run([ + '--url', + 'https://tunnel.example', + '--port', + '0', + '--no-retry', + ]).catch((e) => e) + expect(err).toBeDefined() + expect(err.exitCode ?? err.oclif?.exit).not.toBe(0) + }) + + // ---- synthetic mode ----------------------------------------------------- + + function mockCycle(deals = [], others = {}) { + const data = { + deals, + persons: others.persons ?? [], + organizations: others.organizations ?? [], + activities: others.activities ?? [], + products: others.products ?? [], + } + for (const [name, items] of Object.entries(data)) { + mockApi() + .get(`/api/v2/${name}`) + .query(() => true) + .reply(200, { success: true, data: items }) + } + } + + it('emits webhook-shaped events from the changes feed (--synthetic --once)', async () => { + mockCycle([ + { id: 1, title: 'A', add_time: daysAgo(2), update_time: daysAgo(1) }, + ]) + const stdout = await runCmd(WebhookListenCommand, [ + '--synthetic', + '--since', + '30d', + '--once', + '--output', + 'json', + ]) + const evt = JSON.parse(stdout.trim()) + expect(evt).toMatchObject({ + event: 'deal.create', + meta: { entity: 'deal', action: 'create', id: 1 }, + previous: null, + }) + expect(evt.current.id).toBe(1) + expect(store.listen_watermark).toBeDefined() + }) + + it('forwards synthetic events when --forward-to is set', async () => { + mockCycle([ + { id: 2, title: 'B', add_time: daysAgo(2), update_time: daysAgo(1) }, + ]) + const { createServer } = await import('node:http') + const received = [] + const sink = createServer((req, res) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => { + received.push(Buffer.concat(chunks).toString('utf8')) + res.writeHead(200) + res.end() + }) + }) + await new Promise((r) => sink.listen(0, '127.0.0.1', r)) + const sinkUrl = `http://127.0.0.1:${sink.address().port}/` + + await runCmd(WebhookListenCommand, [ + '--synthetic', + '--since', + '30d', + '--once', + '--forward-to', + sinkUrl, + '--output', + 'json', + ]) + await new Promise((r) => sink.close(r)) + expect(received).toHaveLength(1) + expect(JSON.parse(received[0]).current.id).toBe(2) + }) + + it('resumes from the stored watermark when --since is omitted', async () => { + store.listen_watermark = daysAgo(5) + let sentSince + mockApi() + .get('/api/v2/deals') + .query((q) => { + sentSince = q.updated_since + return true + }) + .reply(200, { success: true, data: [] }) + for (const name of ['persons', 'organizations', 'activities', 'products']) { + mockApi() + .get(`/api/v2/${name}`) + .query(() => true) + .reply(200, { success: true, data: [] }) + } + await runCmd(WebhookListenCommand, [ + '--synthetic', + '--once', + '--output', + 'json', + ]) + expect(sentSince).toBe(store.listen_watermark) + }) + + it('errors (64) in synthetic mode with no --since and no stored watermark', async () => { + const err = await WebhookListenCommand.run(['--synthetic']).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('loops across cycles, sleeping between them, until --max-events', async () => { + // Cycle 1 emits two events, cycle 2 one more → 3 reaches --max-events 3. + mockCycle([ + { id: 1, title: 'a', add_time: daysAgo(2), update_time: daysAgo(3) }, + { id: 2, title: 'b', add_time: daysAgo(2), update_time: daysAgo(2) }, + ]) + mockCycle([ + { id: 3, title: 'c', add_time: daysAgo(2), update_time: daysAgo(1) }, + ]) + // No injected sleep: exercise the real timer (interval 0 keeps it fast). + const stdout = await runCmd(WebhookListenCommand, [ + '--synthetic', + '--since', + '30d', + '--interval', + '0', + '--max-events', + '3', + '--output', + 'json', + ]) + expect(stdout.trim().split('\n').filter(Boolean)).toHaveLength(3) + }) + + it('sorts synthetic events by update_time, missing timestamps last', async () => { + mockCycle([ + { id: 1, title: 'a', add_time: daysAgo(2), update_time: daysAgo(1) }, + { id: 2, title: 'b', add_time: daysAgo(2), update_time: daysAgo(2) }, + { id: 3, title: 'c', add_time: daysAgo(2) }, // no update_time → last + { id: 4, title: 'd', add_time: daysAgo(2) }, // no update_time → last + ]) + const stdout = await runCmd(WebhookListenCommand, [ + '--synthetic', + '--since', + '30d', + '--once', + '--output', + 'json', + ]) + const ids = stdout + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l).meta.id) + // Oldest-first among timestamped rows; the two null rows keep their order. + expect(ids).toEqual([2, 1, 3, 4]) + }) + + it('stops the synthetic loop on SIGINT during the sleep window', async () => { + mockCycle() // one empty cycle + const signals = new EventEmitter() + testHooks.signals = signals + // First sleep fires the signal; the loop then exits on the stopping flag. + testHooks.sleep = vi.fn(() => { + signals.emit('SIGINT') + return Promise.resolve() + }) + + await expect( + runCmd(WebhookListenCommand, [ + '--synthetic', + '--since', + '30d', + '--output', + 'json', + ]), + ).resolves.toBeDefined() + }) + + it('drops synthetic events filtered out by --events but still advances', async () => { + mockCycle( + [{ id: 1, title: 'a', add_time: daysAgo(2), update_time: daysAgo(1) }], + { + persons: [ + { id: 7, name: 'p', add_time: daysAgo(2), update_time: daysAgo(1) }, + ], + }, + ) + const stdout = await runCmd(WebhookListenCommand, [ + '--synthetic', + '--since', + '30d', + '--once', + '--events', + 'person.*', + '--output', + 'json', + ]) + const lines = stdout.trim().split('\n').filter(Boolean) + expect(lines).toHaveLength(1) + expect(JSON.parse(lines[0]).meta.entity).toBe('person') + // Watermark still advanced past the deal it dropped. + expect(store.listen_watermark).toBeDefined() + }) +}) diff --git a/test/lib/mcp/catalog.test.js b/test/lib/mcp/catalog.test.js index 6072011..b4579dc 100644 --- a/test/lib/mcp/catalog.test.js +++ b/test/lib/mcp/catalog.test.js @@ -215,9 +215,13 @@ describe('real-config classification audit', () => { 'file:remote-link': 'write', 'file:update': 'write', 'file:upload': 'write', + 'filter:create': 'write', 'filter:delete': 'destructive', + 'filter:export': 'read', 'filter:get': 'read', + 'filter:helpers': 'read', 'filter:list': 'read', + 'filter:update': 'write', funnel: 'read', 'goal:list': 'read', 'lead:convert': 'destructive', @@ -228,6 +232,7 @@ describe('real-config classification audit', () => { 'lead:list': 'read', 'lead:update': 'write', lookup: 'read', + 'mail:list': 'read', 'mcp:serve': 'excluded', 'metrics:aging': 'read', 'metrics:conversion-matrix': 'read', @@ -304,6 +309,7 @@ describe('real-config classification audit', () => { 'webhook:create': 'write', 'webhook:delete': 'destructive', 'webhook:list': 'read', + 'webhook:listen': 'excluded', } // Walk the actual command files (mirrors the oclif pattern strategy in From 29948f53c67cf0515d86f34b0c6fea663181af85 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 9 Jul 2026 15:25:44 +0200 Subject: [PATCH 2/3] fix(integrations): address v0.22 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webhook listen: - Each run registers a uniquely-named webhook (pdcli-listen:) and tracks a per-profile set of live ids, so the startup orphan-sweep only deletes stale, untracked pdcli-listen webhooks — a concurrent listener on the same account is never clobbered. - The receiver requires the generated basic-auth the temp webhook was registered with; a forged POST to the tunnel is rejected (401), not printed or forwarded. - --synthetic no longer drops events sharing the last-emitted second when --max-events is hit (drains the boundary second first). - A post-bind server error now runs full cleanup (delete webhook, clear own tracking, remove signal handlers, close server) before exiting. mail: - Deal mail paging is offset-based; /deals/{id}/mailMessages returns no next_start, so the shared cursor pager would have looped / duplicated. - The deal-context mail summary is now opt-in (--mail): mail needs a scope most token users lack, so it no longer costs a wasted call per context. filters: - filter export documents the round-trip: the exported {name,type, conditions} is fed to filter create as separate flags, not one blob. Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9 --- src/commands/deal/context.js | 18 +- src/commands/filter/export.js | 8 +- src/commands/mail/list.js | 46 ++++- src/commands/webhook/listen.js | 163 +++++++++++++--- test/commands/deal/context.test.js | 61 +++--- test/commands/mail/list.test.js | 47 +++++ test/commands/webhook/listen.test.js | 271 +++++++++++++++++++++++---- 7 files changed, 509 insertions(+), 105 deletions(-) diff --git a/src/commands/deal/context.js b/src/commands/deal/context.js index cd79bb1..d65d14c 100644 --- a/src/commands/deal/context.js +++ b/src/commands/deal/context.js @@ -3,7 +3,7 @@ import BaseCommand from '../../base-command.js' import { collectPages } from '../../lib/pagination.js' import { getFields, makeResolver } from '../../lib/fields.js' import { assembleContext } from '../../lib/deal-context.js' -import { unwrapMailMessage, mailDirection } from '../mail/list.js' +import { fetchDealMail, mailDirection } from '../mail/list.js' /** * Condense a deal's mail messages into an agent-friendly signal. Returns null @@ -75,7 +75,10 @@ export default class DealContextCommand extends BaseCommand { 'no-participants': Flags.boolean({ description: 'Skip the participants slice', }), - 'no-mail': Flags.boolean({ description: 'Skip the mail summary slice' }), + mail: Flags.boolean({ + description: + 'Include a mail summary (off by default; needs the mail:read scope and email sync)', + }), 'mail-limit': Flags.integer({ description: 'Max mail messages to scan for the summary', default: 50, @@ -102,15 +105,14 @@ export default class DealContextCommand extends BaseCommand { // users have neither, so a 403 / permission error (or any mail-only // failure) degrades to null rather than sinking the whole context bundle. const fetchMail = async () => { - if (flags['no-mail']) return null + if (!flags.mail) return null try { - const items = await collectPages( - this.apiClient.pageV1(`/api/v1/deals/${id}/mailMessages`, { - limit: 100, - }), + const items = await fetchDealMail( + this.apiClient, + id, flags['mail-limit'], ) - return summarizeMail(items.map(unwrapMailMessage)) + return summarizeMail(items) } catch { return null } diff --git a/src/commands/filter/export.js b/src/commands/filter/export.js index 9fabead..c3d9c27 100644 --- a/src/commands/filter/export.js +++ b/src/commands/filter/export.js @@ -18,11 +18,17 @@ function toPortable(filter) { export default class FilterExportCommand extends BaseCommand { static description = - 'Export a filter (or all filters) as create-compatible JSON' + 'Export a filter (or all filters) as portable {name, type, conditions} ' + + 'JSON. To recreate it, feed the fields to `filter create` separately ' + + '(the create command takes --name/--type/--conditions, not one blob).' static examples = [ '<%= config.bin %> filter export 5 > filter.json', '<%= config.bin %> filter export --all > filters.json', + '# recreate on another account (conditions reference numeric field_id):\n' + + '<%= config.bin %> filter create --name "$(jq -r .name filter.json)" ' + + '--type "$(jq -r .type filter.json)" ' + + '--conditions "$(jq -c .conditions filter.json)"', ] static args = { diff --git a/src/commands/mail/list.js b/src/commands/mail/list.js index 9f3c885..fa2b64c 100644 --- a/src/commands/mail/list.js +++ b/src/commands/mail/list.js @@ -1,6 +1,5 @@ import { Flags } from '@oclif/core' import BaseCommand from '../../base-command.js' -import { collectPages } from '../../lib/pagination.js' /** * The deal mailMessages list wraps each message under @@ -24,6 +23,38 @@ export function mailDirection(msg) { return msg?.sent_flag ? 'sent' : 'received' } +/** + * Page a deal's mail messages with OFFSET paging and return unwrapped message + * objects. Unlike every other v1 list, `/deals/{id}/mailMessages` paginates + * with start/limit/more_items_in_collection but returns NO `next_start`, so the + * shared cursor pager (pageV1) would re-request page 0 forever / duplicate rows. + * Advance `start` by the returned count instead, and stop on an empty page. + * @param {object} client + * @param {number} dealId + * @param {number} [limit] + * @returns {Promise} + */ +export async function fetchDealMail(client, dealId, limit = 100) { + const path = `/api/v1/deals/${dealId}/mailMessages` + const out = [] + let start = 0 + while (out.length < limit) { + const body = await client.get(path, { + query: { start, limit: Math.min(limit - out.length, 100) }, + }) + const page = body.data ?? [] + out.push(...page) + if ( + page.length === 0 || + !body.additional_data?.pagination?.more_items_in_collection + ) { + break + } + start += page.length + } + return out.slice(0, limit).map(unwrapMailMessage) +} + /** First participant's address (or name) from a from/to array. */ function firstAddress(parties) { if (!Array.isArray(parties) || parties.length === 0) return '' @@ -68,14 +99,11 @@ export default class MailListCommand extends BaseCommand { async run() { const { flags } = await this.parse(MailListCommand) - const limit = flags.limit ?? 100 - - const items = await collectPages( - this.apiClient.pageV1(`/api/v1/deals/${flags.deal}/mailMessages`, { - limit: Math.min(limit, 100), - }), - limit, + const items = await fetchDealMail( + this.apiClient, + flags.deal, + flags.limit ?? 100, ) - await this.outputResults(items.map(unwrapMailMessage), columns) + await this.outputResults(items, columns) } } diff --git a/src/commands/webhook/listen.js b/src/commands/webhook/listen.js index 8032e5f..fb75951 100644 --- a/src/commands/webhook/listen.js +++ b/src/commands/webhook/listen.js @@ -1,4 +1,5 @@ import { createServer } from 'node:http' +import { randomUUID, randomBytes } from 'node:crypto' import { Flags } from '@oclif/core' import chalk from 'chalk' import BaseCommand from '../../base-command.js' @@ -12,14 +13,67 @@ import { deleteProfileConfig, } from '../../lib/config.js' -/** Name stamped on every temp webhook so a later run can sweep leftovers. */ +/** Name prefix stamped on every temp webhook; each run appends a unique token + * (`pdcli-listen:`) so concurrent sessions never share an identity and a + * later run can recognise — but not clobber — another run's live webhook. */ const MARKER = 'pdcli-listen' -/** Per-profile config key holding the last temp webhook id (orphan GC). */ +/** Per-profile config key holding the SET of this profile's live temp webhook + * ids (orphan GC). A set — not a scalar — so one session's shutdown clears only + * its own entry and leaves a concurrent session's tracking intact. */ const LISTEN_ID_KEY = 'listen_webhook_id' /** Per-profile resume watermark for --synthetic (kept apart from `changes`). */ const WATERMARK_KEY = 'listen_watermark' const DEFAULT_PORT = 3000 const DEFAULT_INTERVAL_MS = 10_000 +/** A MARKER webhook created within this window is assumed to belong to a + * concurrently-starting listener and is left alone by the sweep; older ones + * (and same-profile tracked ids, regardless of age) are also protected. Only + * untracked MARKER webhooks older than this are treated as crash leftovers. */ +const STALE_MS = 60_000 + +/** Whole-second bucket of an update_time (v2 timestamps are seconds-precision); + * a missing timestamp returns -Infinity (such rows can't be resumed anyway). */ +function secondOf(updateTime) { + if (updateTime == null) return -Infinity + return Math.floor(new Date(updateTime).getTime() / 1000) +} + +/** Is a listed webhook a stale crash leftover we may sweep? A MARKER webhook + * with no usable `add_time` is treated as stale; otherwise it must be older + * than STALE_MS so we never race-delete a freshly-created concurrent listener. + * @param {string|undefined} addTime + * @param {number} now epoch ms + * @returns {boolean} + */ +function isStaleWebhook(addTime, now) { + const t = addTime ? Date.parse(addTime) : NaN + if (Number.isNaN(t)) return true + return now - t > STALE_MS +} + +/** Read this profile's tracked temp-webhook id set as an array. */ +function getListenIds(profile) { + const raw = getProfileConfig(profile, LISTEN_ID_KEY) + return Array.isArray(raw) ? raw : [] +} + +/** Record this run's own temp-webhook id without disturbing other entries. */ +function addListenId(profile, id) { + setProfileConfig(profile, LISTEN_ID_KEY, [...getListenIds(profile), id]) +} + +/** Drop only this run's own id; delete the key once the set is empty. */ +function removeListenId(profile, id) { + const ids = getListenIds(profile).filter((x) => x !== id) + if (ids.length === 0) deleteProfileConfig(profile, LISTEN_ID_KEY) + else setProfileConfig(profile, LISTEN_ID_KEY, ids) +} + +/** Constant-shape check that an inbound Authorization header matches the + * generated basic-auth creds registered on the temp webhook. */ +function authMatches(header, expected) { + return typeof header === 'string' && header === expected +} /** v2 entities that support `updated_since` + update_time ordering. */ const ENTITY_PATHS = { @@ -242,12 +296,15 @@ export default class WebhookListenCommand extends BaseCommand { } /** - * Delete leftover pdcli-listen webhooks from a previous crashed run. Best - * effort: a failed list must not block starting a fresh listener. + * Delete STALE leftover pdcli-listen webhooks from a previous crashed run. + * Best effort: a failed list must not block starting a fresh listener. Scoped + * to avoid clobbering a concurrently-live listener — a webhook is swept only + * when it (a) carries the MARKER prefix, (b) is NOT a tracked id of this + * profile (a live/own session), and (c) is stale (older than STALE_MS, so a + * freshly-created concurrent listener survives). * @param {string} profile */ async sweepOrphans(profile) { - const storedId = getProfileConfig(profile, LISTEN_ID_KEY) let list try { const body = await this.apiClient.get('/api/v1/webhooks') @@ -256,20 +313,24 @@ export default class WebhookListenCommand extends BaseCommand { process.stderr.write(`Orphan sweep skipped: ${err.message}\n`) return } - const orphans = list.filter( - (w) => w.name === MARKER || (storedId != null && w.id === storedId), - ) + const now = Date.now() + const tracked = new Set(getListenIds(profile)) + const prefix = `${MARKER}:` + const orphans = list.filter((w) => { + if (!w.name?.startsWith(prefix)) return false + if (tracked.has(w.id)) return false + return isStaleWebhook(w.add_time, now) + }) for (const w of orphans) { try { await this.apiClient.del(`/api/v1/webhooks/${w.id}`) - process.stderr.write(`Swept orphaned listen webhook ${w.id}\n`) + process.stderr.write(`Swept stale listen webhook ${w.id}\n`) } catch (err) { process.stderr.write( `Could not sweep webhook ${w.id}: ${err.message}\n`, ) } } - if (storedId != null) deleteProfileConfig(profile, LISTEN_ID_KEY) } /** @@ -292,27 +353,41 @@ export default class WebhookListenCommand extends BaseCommand { await this.sweepOrphans(profile) + // A unique identity per run: a random name suffix keeps concurrent sessions + // apart, and generated basic-auth creds let the receiver reject any forged + // POST from someone who merely discovered the public tunnel URL. + const ownName = `${MARKER}:${randomUUID()}` + const authUser = randomBytes(12).toString('hex') + const authPass = randomBytes(24).toString('hex') + const expectedAuth = + 'Basic ' + Buffer.from(`${authUser}:${authPass}`).toString('base64') + return new Promise((resolve, reject) => { let webhookId let count = 0 let closing = false - const shutdown = async (signal) => { - if (closing) return - closing = true + // Delete the temp webhook, clear only our own tracking entry, and + // deregister the signal handlers. Shared by clean shutdown and the + // post-bind error path so neither orphans the webhook or leaks listeners. + const cleanup = async () => { server.close() try { await this.apiClient.del(`/api/v1/webhooks/${webhookId}`) - deleteProfileConfig(profile, LISTEN_ID_KEY) + removeListenId(profile, webhookId) } catch (err) { process.stderr.write( `Cleanup of webhook ${webhookId} failed: ${err.message}\n`, ) } - // Deregister after cleanup: while the async delete is in flight the - // `closing` guard (not listener removal) dedupes a second signal. signals.off('SIGINT', onSigint) signals.off('SIGTERM', onSigterm) + } + + const shutdown = async (signal) => { + if (closing) return + closing = true + await cleanup() if (signal) { process.stderr.write( `\nReceived ${signal} — deleted webhook ${webhookId}\n`, @@ -329,6 +404,13 @@ export default class WebhookListenCommand extends BaseCommand { res.end() return } + // Reject forged deliveries: only POSTs carrying the generated basic-auth + // creds (which only Pipedrive was handed) are printed/forwarded. + if (!authMatches(req.headers['authorization'], expectedAuth)) { + res.writeHead(401) + res.end() + return + } const chunks = [] req.on('data', (c) => chunks.push(c)) req.on('end', async () => { @@ -356,15 +438,24 @@ export default class WebhookListenCommand extends BaseCommand { }) }) - server.on('error', (err) => { + const rejectBind = (err) => reject( new CliError( - `Cannot bind the local receiver on port ${flags.port}: ${err.message}`, - { - exitCode: 78, - }, + `Local webhook receiver failed on port ${flags.port}: ${err.message}`, + { exitCode: 78 }, ), ) + + server.on('error', (err) => { + // Pre-bind (e.g. EADDRINUSE): nothing to unwind, just reject. Post-bind + // (e.g. EMFILE) the webhook already exists, so run the full cleanup + // before rejecting rather than orphaning it and leaking listeners. + if (webhookId == null) { + rejectBind(err) + return + } + closing = true + cleanup().finally(() => rejectBind(err)) }) // Bind loopback only: a tunnel (ngrok/cloudflared) forwards to localhost, @@ -379,7 +470,9 @@ export default class WebhookListenCommand extends BaseCommand { event_action: '*', event_object: '*', version: '2.0', - name: MARKER, + name: ownName, + http_auth_user: authUser, + http_auth_password: authPass, }, }) webhookId = created.data.id @@ -388,13 +481,17 @@ export default class WebhookListenCommand extends BaseCommand { reject(err) return } - setProfileConfig(profile, LISTEN_ID_KEY, webhookId) + addListenId(profile, webhookId) signals.on('SIGINT', onSigint) signals.on('SIGTERM', onSigterm) process.stderr.write( `Listening on :${port} — webhook ${webhookId} → ${flags.url}\n`, ) - testHooks.onListening?.(port) + testHooks.onListening?.( + port, + { user: authUser, password: authPass }, + server, + ) }) }) } @@ -446,7 +543,15 @@ export default class WebhookListenCommand extends BaseCommand { try { while (!stopping) { const events = await this.pollSynthetic(since) + // Once --max-events is hit we must not stop mid-second: the watermark + // advances to (last update_time + 1s), which would skip un-emitted + // events sharing that boundary second. So on hitting the cap we record + // its second and keep emitting until the next event is strictly later, + // then stop — every same-second event is emitted, nothing is dropped. + let cutSecond = null for (const evt of events) { + const sec = secondOf(evt.meta.updateTime) + if (cutSecond != null && sec !== cutSecond) break const emitted = this.emitEvent(evt, { format, patterns }) if (emitted && forwardTo) { await forwardEvent( @@ -462,12 +567,16 @@ export default class WebhookListenCommand extends BaseCommand { } if (emitted) { count++ - if (flags['max-events'] != null && count >= flags['max-events']) { - stopping = true - break + if ( + flags['max-events'] != null && + count >= flags['max-events'] && + cutSecond == null + ) { + cutSecond = sec } } } + if (cutSecond != null) stopping = true setProfileConfig(profile, WATERMARK_KEY, since) if (flags.once || stopping) break await sleep(flags.interval) diff --git a/test/commands/deal/context.test.js b/test/commands/deal/context.test.js index 9ce1737..2f37cb6 100644 --- a/test/commands/deal/context.test.js +++ b/test/commands/deal/context.test.js @@ -158,7 +158,12 @@ describe('deal context', () => { it('assembles the full bundle with hydrated contacts and resolved fields (JSON)', async () => { mockFullBundle() - const stdout = await runCmd(DealContextCommand, ['42', '--output', 'json']) + const stdout = await runCmd(DealContextCommand, [ + '42', + '--mail', + '--output', + 'json', + ]) const b = JSON.parse(stdout) expect(b.deal.id).toBe(42) // custom field hash resolved to name + option label @@ -197,7 +202,12 @@ describe('deal context', () => { it('renders a compact summary in table mode', async () => { mockFullBundle() - const stdout = await runCmd(DealContextCommand, ['42', '--output', 'table']) + const stdout = await runCmd(DealContextCommand, [ + '42', + '--mail', + '--output', + 'table', + ]) expect(stdout).toContain('Acme expansion') expect(stdout).toContain('Jane Doe') expect(stdout).toContain('Acme Inc') @@ -216,20 +226,20 @@ describe('deal context', () => { ], additional_data: { pagination: { more_items_in_collection: false } }, }) - const stdout = await runCmd(DealContextCommand, ['42', '--output', 'table']) + const stdout = await runCmd(DealContextCommand, [ + '42', + '--mail', + '--output', + 'table', + ]) expect(stdout).toMatch(/Mail: 1 msgs · last received$/m) }) - it('sets mail to null and skips the fetch with --no-mail', async () => { + it('does not fetch mail by default (mail is opt-in)', async () => { mockCoreBundle() - // Register the mail interceptor but expect it to stay unused. + // Register the mail interceptor but expect it to stay unused without --mail. const mail = mockMail(200, { success: true, data: MAIL_WRAPPED }) - const stdout = await runCmd(DealContextCommand, [ - '42', - '--no-mail', - '--output', - 'json', - ]) + const stdout = await runCmd(DealContextCommand, ['42', '--output', 'json']) const b = JSON.parse(stdout) expect(b.mail).toBeNull() expect(mail.isDone()).toBe(false) @@ -238,7 +248,12 @@ describe('deal context', () => { it('degrades mail to null on a 403 (no mail:read scope) without failing', async () => { mockCoreBundle() mockMail(403, { success: false, error: 'Scope mail:read is missing' }) - const stdout = await runCmd(DealContextCommand, ['42', '--output', 'json']) + const stdout = await runCmd(DealContextCommand, [ + '42', + '--mail', + '--output', + 'json', + ]) const b = JSON.parse(stdout) expect(b.deal.id).toBe(42) expect(b.mail).toBeNull() @@ -251,7 +266,12 @@ describe('deal context', () => { data: [], additional_data: { pagination: { more_items_in_collection: false } }, }) - const stdout = await runCmd(DealContextCommand, ['42', '--output', 'json']) + const stdout = await runCmd(DealContextCommand, [ + '42', + '--mail', + '--output', + 'json', + ]) const b = JSON.parse(stdout) expect(b.mail).toBeNull() }) @@ -292,7 +312,6 @@ describe('deal context', () => { '--no-notes', '--no-products', '--no-participants', - '--no-mail', '--output', 'json', ]) @@ -342,12 +361,7 @@ describe('deal context', () => { .query(true) .reply(200, { success: true, data: [] }) - const stdout = await runCmd(DealContextCommand, [ - '42', - '--no-mail', - '--output', - 'table', - ]) + const stdout = await runCmd(DealContextCommand, ['42', '--output', 'table']) expect(stdout).toContain('Deal 42') expect(stdout).toContain('Person: — · Org: —') expect(stdout).toMatch(/Flags:.*missingContact/) @@ -381,12 +395,7 @@ describe('deal context', () => { .query(true) .reply(200, { success: true, data: [] }) - const stdout = await runCmd(DealContextCommand, [ - '42', - '--no-mail', - '--output', - 'json', - ]) + const stdout = await runCmd(DealContextCommand, ['42', '--output', 'json']) const b = JSON.parse(stdout) expect(b.person).toBeNull() expect(b.org).toBeNull() diff --git a/test/commands/mail/list.test.js b/test/commands/mail/list.test.js index 598ee5a..1c2d160 100644 --- a/test/commands/mail/list.test.js +++ b/test/commands/mail/list.test.js @@ -154,6 +154,53 @@ describe('mail list', () => { ).rejects.toMatchObject({ oclif: { exit: 77 } }) }) + it('offset-pages when more_items is set (endpoint has no next_start)', async () => { + const msg = (id) => ({ object: 'mailMessage', data: { id } }) + mockApi() + .get('/api/v1/deals/42/mailMessages') + .query((q) => q.start === '0') + .reply(200, { + success: true, + data: [msg(1), msg(2)], + additional_data: { pagination: { more_items_in_collection: true } }, + }) + mockApi() + .get('/api/v1/deals/42/mailMessages') + .query((q) => q.start === '2') + .reply(200, { + success: true, + data: [msg(3)], + additional_data: { pagination: { more_items_in_collection: false } }, + }) + + const stdout = await runCmd(MailListCommand, [ + '--deal', + '42', + '--output', + 'json', + ]) + expect(JSON.parse(stdout).map((r) => r.id)).toEqual([1, 2, 3]) + }) + + it('stops when a page carries no data (deal with no synced mail)', async () => { + // No `data` key at all — exercises the `body.data ?? []` fallback too. + mockApi() + .get('/api/v1/deals/42/mailMessages') + .query(true) + .reply(200, { + success: true, + additional_data: { pagination: { more_items_in_collection: true } }, + }) + + const stdout = await runCmd(MailListCommand, [ + '--deal', + '42', + '--output', + 'json', + ]) + expect(JSON.parse(stdout)).toEqual([]) + }) + describe('unwrapMailMessage', () => { it('unwraps the { object, timestamp, data } list shape', () => { expect( diff --git a/test/commands/webhook/listen.test.js b/test/commands/webhook/listen.test.js index 3993778..bb99b05 100644 --- a/test/commands/webhook/listen.test.js +++ b/test/commands/webhook/listen.test.js @@ -34,25 +34,37 @@ const { compareByUpdateTime, } = listenModule import { runCmd, mockApi } from '../../helpers.js' +import { formatApiDatetime } from '../../../src/lib/period.js' const DAY = 86_400_000 const daysAgo = (n) => new Date(Date.now() - n * DAY).toISOString() +/** Build a Basic auth header value from the receiver's generated creds. */ +function basicAuth(creds) { + return ( + 'Basic ' + Buffer.from(`${creds.user}:${creds.password}`).toString('base64') + ) +} + /** POST a JSON body to the local receiver and resolve its HTTP status. */ -async function deliver(port, payload, { raw } = {}) { +async function deliver(port, payload, { raw, creds } = {}) { + const headers = { 'content-type': 'application/json' } + if (creds) headers.authorization = basicAuth(creds) const res = await fetch(`http://127.0.0.1:${port}/`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers, body: raw ?? JSON.stringify(payload), }) return res.status } /** POST via raw node http (no content-type header) to exercise the default. */ -function deliverNoContentType(port, payload) { +function deliverNoContentType(port, payload, creds) { return new Promise((resolve, reject) => { + const headers = {} + if (creds) headers.Authorization = basicAuth(creds) const req = httpRequest( - { host: '127.0.0.1', port, method: 'POST', path: '/' }, + { host: '127.0.0.1', port, method: 'POST', path: '/', headers }, (res) => { res.resume() res.on('end', resolve) @@ -204,15 +216,19 @@ describe('webhook listen', () => { mockApi() .post( '/api/v1/webhooks', - (b) => b.name === 'pdcli-listen' && b.event_action === '*', + (b) => b.name.startsWith('pdcli-listen:') && b.event_action === '*', ) .reply(201, { success: true, data: { id: 42 } }) const del = mockApi() .delete('/api/v1/webhooks/42') .reply(200, { success: true, data: { id: 42 } }) - testHooks.onListening = async (port) => { - await deliver(port, { meta: { entity: 'deal', action: 'change', id: 9 } }) + testHooks.onListening = async (port, creds) => { + await deliver( + port, + { meta: { entity: 'deal', action: 'change', id: 9 } }, + { creds }, + ) } const stdout = await runCmd(WebhookListenCommand, [ @@ -256,10 +272,14 @@ describe('webhook listen', () => { await new Promise((r) => sink.listen(0, '127.0.0.1', r)) const sinkUrl = `http://127.0.0.1:${sink.address().port}/` - testHooks.onListening = async (port) => { - await deliver(port, { - meta: { entity: 'person', action: 'create', id: 3 }, - }) + testHooks.onListening = async (port, creds) => { + await deliver( + port, + { + meta: { entity: 'person', action: 'create', id: 3 }, + }, + { creds }, + ) } const stdout = await runCmd(WebhookListenCommand, [ @@ -286,12 +306,20 @@ describe('webhook listen', () => { .delete('/api/v1/webhooks/5') .reply(200, { success: true, data: {} }) - testHooks.onListening = async (port) => { + testHooks.onListening = async (port, creds) => { // A non-matching event first (dropped), then a matching one triggers --once. - await deliver(port, { - meta: { entity: 'person', action: 'change', id: 1 }, - }) - await deliver(port, { meta: { entity: 'deal', action: 'change', id: 2 } }) + await deliver( + port, + { + meta: { entity: 'person', action: 'change', id: 1 }, + }, + { creds }, + ) + await deliver( + port, + { meta: { entity: 'deal', action: 'change', id: 2 } }, + { creds }, + ) } const stdout = await runCmd(WebhookListenCommand, [ @@ -319,9 +347,17 @@ describe('webhook listen', () => { .delete('/api/v1/webhooks/8') .reply(200, { success: true, data: {} }) - testHooks.onListening = async (port) => { - await deliver(port, { meta: { entity: 'deal', action: 'change', id: 1 } }) - await deliver(port, { meta: { entity: 'deal', action: 'change', id: 2 } }) + testHooks.onListening = async (port, creds) => { + await deliver( + port, + { meta: { entity: 'deal', action: 'change', id: 1 } }, + { creds }, + ) + await deliver( + port, + { meta: { entity: 'deal', action: 'change', id: 2 } }, + { creds }, + ) } const stdout = await runCmd(WebhookListenCommand, [ @@ -347,9 +383,9 @@ describe('webhook listen', () => { .reply(200, { success: true, data: {} }) let probeStatus - testHooks.onListening = async (port) => { + testHooks.onListening = async (port, creds) => { probeStatus = (await fetch(`http://127.0.0.1:${port}/`)).status - await deliver(port, null, { raw: 'not json' }) + await deliver(port, null, { raw: 'not json', creds }) } const stdout = await runCmd(WebhookListenCommand, [ @@ -365,22 +401,38 @@ describe('webhook listen', () => { expect(JSON.parse(stdout.trim())).toEqual({ raw: 'not json' }) }) - it('sweeps orphaned pdcli-listen webhooks from a previous crashed run', async () => { - store.listen_webhook_id = 77 + it('sweeps only stale leftovers and never a concurrently-live listener', async () => { + // A concurrent session's own id is tracked in the shared config set. + store.listen_webhook_id = [999] + const stale = '2020-01-01 00:00:00' // long ago → crash leftover + const fresh = new Date().toISOString() // just created → live listener mockApi() .get('/api/v1/webhooks') .reply(200, { success: true, data: [ - { id: 77, name: 'pdcli-listen' }, - { id: 88, name: 'someone-elses-hook' }, + { id: 100, name: 'pdcli-listen:crashed', add_time: stale }, // swept + { id: 150, name: 'pdcli-listen:notime' }, // no add_time → swept + { id: 200, name: 'pdcli-listen:live', add_time: fresh }, // fresh → kept + { id: 999, name: 'pdcli-listen:mine', add_time: stale }, // tracked → kept + { id: 300, name: 'someone-elses-hook', add_time: stale }, // no prefix → kept + { id: 500, add_time: stale }, // no name → kept ], }) - const sweepDel = mockApi() - .delete('/api/v1/webhooks/77') + const del100 = mockApi() + .delete('/api/v1/webhooks/100') + .reply(200, { success: true }) + const del150 = mockApi() + .delete('/api/v1/webhooks/150') .reply(200, { success: true }) mockApi() - .post('/api/v1/webhooks') + .post( + '/api/v1/webhooks', + (b) => + b.name.startsWith('pdcli-listen:') && + typeof b.http_auth_user === 'string' && + typeof b.http_auth_password === 'string', + ) .reply(201, { success: true, data: { id: 90 } }) mockApi() .delete('/api/v1/webhooks/90') @@ -396,7 +448,11 @@ describe('webhook listen', () => { '--port', '0', ]) - expect(sweepDel.isDone()).toBe(true) + // Only the stale leftovers were swept; live + tracked + foreign left alone. + expect(del100.isDone()).toBe(true) + expect(del150.isDone()).toBe(true) + // The concurrent listener's tracking id survives this run's shutdown. + expect(store.listen_webhook_id).toEqual([999]) }) it('tolerates a failed orphan-list sweep (best effort)', async () => { @@ -439,11 +495,15 @@ describe('webhook listen', () => { const deadUrl = `http://127.0.0.1:${dead.address().port}/` await new Promise((r) => dead.close(r)) - testHooks.onListening = async (port) => { + testHooks.onListening = async (port, creds) => { // No content-type header on this delivery → forwardEvent defaults it. - await deliverNoContentType(port, { - meta: { entity: 'deal', action: 'change', id: 1 }, - }) + await deliverNoContentType( + port, + { + meta: { entity: 'deal', action: 'change', id: 1 }, + }, + creds, + ) } await expect( @@ -464,7 +524,16 @@ describe('webhook listen', () => { it('continues when sweeping an orphaned webhook fails to delete', async () => { mockApi() .get('/api/v1/webhooks') - .reply(200, { success: true, data: [{ id: 70, name: 'pdcli-listen' }] }) + .reply(200, { + success: true, + data: [ + { + id: 70, + name: 'pdcli-listen:crashed', + add_time: '2020-01-01 00:00:00', + }, + ], + }) mockApi().delete('/api/v1/webhooks/70').reply(500, { success: false }) mockApi() .post('/api/v1/webhooks') @@ -613,6 +682,89 @@ describe('webhook listen', () => { expect(err.exitCode ?? err.oclif?.exit).not.toBe(0) }) + it('rejects a forged POST lacking the generated basic-auth creds', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post( + '/api/v1/webhooks', + (b) => + typeof b.http_auth_user === 'string' && + typeof b.http_auth_password === 'string', + ) + .reply(201, { success: true, data: { id: 61 } }) + mockApi() + .delete('/api/v1/webhooks/61') + .reply(200, { success: true, data: {} }) + + let noAuthStatus, wrongAuthStatus + testHooks.onListening = async (port, creds) => { + // No Authorization header at all → 401, never emitted or forwarded. + noAuthStatus = await deliver(port, { + meta: { entity: 'deal', action: 'change', id: 1 }, + }) + // Wrong credentials → 401. + wrongAuthStatus = await deliver( + port, + { meta: { entity: 'deal', action: 'change', id: 2 } }, + { creds: { user: 'nope', password: 'wrong' } }, + ) + // Correct credentials → accepted, triggers --once. + await deliver( + port, + { meta: { entity: 'deal', action: 'change', id: 3 } }, + { creds }, + ) + } + + const stdout = await runCmd(WebhookListenCommand, [ + '--url', + 'https://tunnel.example', + '--port', + '0', + '--once', + '--output', + 'json', + ]) + expect(noAuthStatus).toBe(401) + expect(wrongAuthStatus).toBe(401) + const lines = stdout.trim().split('\n').filter(Boolean) + expect(lines).toHaveLength(1) + expect(JSON.parse(lines[0]).meta.id).toBe(3) // only the authenticated one + }) + + it('cleans up the webhook + listeners on a post-bind server error', async () => { + mockApi().get('/api/v1/webhooks').reply(200, { success: true, data: [] }) + mockApi() + .post('/api/v1/webhooks') + .reply(201, { success: true, data: { id: 78 } }) + const del = mockApi() + .delete('/api/v1/webhooks/78') + .reply(200, { success: true, data: {} }) + + const signals = new EventEmitter() + testHooks.signals = signals + // Simulate e.g. EMFILE surfacing on the server AFTER a successful bind. + testHooks.onListening = (_port, _creds, server) => { + server.emit('error', new Error('EMFILE: too many open files')) + } + + const err = await WebhookListenCommand.run([ + '--url', + 'https://tunnel.example', + '--port', + '0', + '--no-retry', + ]).catch((e) => e) + + expect(err.exitCode ?? err.oclif?.exit).toBe(78) + // The already-created webhook was deleted, not orphaned. + expect(del.isDone()).toBe(true) + // Signal handlers were removed and the own config entry cleared. + expect(signals.listenerCount('SIGINT')).toBe(0) + expect(signals.listenerCount('SIGTERM')).toBe(0) + expect(store.listen_webhook_id).toBeUndefined() + }) + // ---- synthetic mode ----------------------------------------------------- function mockCycle(deals = [], others = {}) { @@ -740,6 +892,57 @@ describe('webhook listen', () => { expect(stdout.trim().split('\n').filter(Boolean)).toHaveLength(3) }) + it('does not drop same-second events when --max-events lands mid-second', async () => { + // ids 1 & 2 share one update_time second; id 3 is a later second. With + // --max-events 1 the naive loop would stop after id 1, then advance the + // watermark past the whole second — silently losing id 2. The cut-second + // guard drains the rest of that second (id 2) before stopping, and stops + // before the strictly-later id 3. + const sameSecond = '2026-07-01 12:00:00' + const laterSecond = '2026-07-01 12:00:05' + mockCycle([ + { + id: 1, + title: 'a', + add_time: '2026-06-01 00:00:00', + update_time: sameSecond, + }, + { + id: 2, + title: 'b', + add_time: '2026-06-01 00:00:00', + update_time: sameSecond, + }, + { + id: 3, + title: 'c', + add_time: '2026-06-01 00:00:00', + update_time: laterSecond, + }, + ]) + const stdout = await runCmd(WebhookListenCommand, [ + '--synthetic', + '--since', + '90d', + '--max-events', + '1', + '--output', + 'json', + ]) + const ids = stdout + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l).meta.id) + // Both same-second events are emitted; the later-second one is not. + expect(ids).toEqual([1, 2]) + // Watermark advanced one second past the boundary (nothing replays/drops). + const expectedWatermark = formatApiDatetime( + new Date(new Date(sameSecond).getTime() + 1000), + ) + expect(store.listen_watermark).toBe(expectedWatermark) + }) + it('sorts synthetic events by update_time, missing timestamps last', async () => { mockCycle([ { id: 1, title: 'a', add_time: daysAgo(2), update_time: daysAgo(1) }, From 892be28183b95377d05fba8842249d9ca4e44b1e Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 9 Jul 2026 15:26:47 +0200 Subject: [PATCH 3/3] chore: release v0.22.0 Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9 --- CHANGELOG.md | 23 ++++ docs/commands.md | 123 +++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- .../src/content/docs/reference/commands.mdx | 123 +++++++++++++++++- website/src/data/cli-stats.json | 6 +- 6 files changed, 273 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 882d88f..c6c7ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to `pdcli` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). +## [0.22.0] - 2026-07-09 + +Integrations: a local webhook dev loop, mail signal for agents, and filters as +code. The last release before the 1.0 contract freeze. + +### Added + +- **`pdcli webhook listen`** — a stripe-listen-style dev loop. Tunnel mode + registers a temporary webhook against your public tunnel (`--url`), prints and + optionally forwards (`--forward-to`) each delivery, and deletes the webhook on + exit; the receiver only accepts deliveries carrying the generated basic-auth, + and orphaned webhooks from a crashed run are swept on the next start. + `--synthetic` emits the same delivery envelope off the changes feed with zero + inbound network (for firewalled CI and reactive agents). +- **Mail signal** — `deal context --mail` adds a mail summary (message count, + last message time and direction, participants, latest subject); it is opt-in + because mail needs a scope most tokens lack. New `pdcli mail list --deal ` + lists a deal's messages (bodies excluded by design). +- **Filters as code** — `filter create`/`filter update` take conditions as JSON + (inline, `@file`, or stdin), `filter helpers` lists the operators for authoring + them, and `filter export` emits a portable filter definition (single or + `--all`). Condition field references use numeric field_id. + ## [0.21.0] - 2026-07-09 Dev loop: check your rate budget, resolve records to ids, and point pdcli at a diff --git a/docs/commands.md b/docs/commands.md index 0880449..c0d5117 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.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`. +Reference for `pdcli` v0.22.0 (154 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`. ## Top-level @@ -627,6 +627,8 @@ pdcli deal context [flags] - `--no-notes` — Skip the notes slice - `--no-products` — Skip the products slice - `--no-participants` — Skip the participants slice +- `--mail` — Include a mail summary (off by default; needs the mail:read scope and email sync) +- `--mail-limit ` — Max mail messages to scan for the summary - `--activity-limit ` — Max activities to include - `--note-limit ` — Max notes to include @@ -1269,6 +1271,25 @@ pdcli file upload ./report.pdf --deal 42 ## pdcli filter +### `pdcli filter create` + +Create a filter + +``` +pdcli filter create [flags] +``` + +- `--name ` _(required)_ — Filter name +- `--type ` _(required)_ — Filter type +- `--conditions ` — Conditions JSON (a value, @file, or piped stdin). Fields are referenced by numeric field_id — run `pdcli filter helpers` for the valid operators. The blob needs the two-level glue structure {"glue":"and","conditions":[{"glue":"and",...},{"glue":"or",...}]}. + +Examples: + +```bash +pdcli filter create --name "Open deals" --type deals --conditions @conditions.json +cat conditions.json | pdcli filter create --name "Open deals" --type deals +``` + ### `pdcli filter delete` Delete a filter @@ -1286,6 +1307,25 @@ pdcli filter delete 5 pdcli filter delete 5 --yes ``` +### `pdcli filter export` + +Export a filter (or all filters) as portable {name, type, conditions} JSON. To recreate it, feed the fields to `filter create` separately (the create command takes --name/--type/--conditions, not one blob). + +``` +pdcli filter export [id] [flags] +``` + +- `--all` — Export every filter + +Examples: + +```bash +pdcli filter export 5 > filter.json +pdcli filter export --all > filters.json +# recreate on another account (conditions reference numeric field_id): +pdcli filter create --name "$(jq -r .name filter.json)" --type "$(jq -r .type filter.json)" --conditions "$(jq -c .conditions filter.json)" +``` + ### `pdcli filter get` Get a filter by ID @@ -1301,6 +1341,24 @@ pdcli filter get 5 pdcli filter get 5 --output json ``` +### `pdcli filter helpers` + +List the operators available for authoring filter conditions + +``` +pdcli filter helpers [flags] +``` + +- `--type ` — Only show operators for this field data type (e.g. varchar, date, int) + +Examples: + +```bash +pdcli filter helpers +pdcli filter helpers --type varchar +pdcli filter helpers --output json +``` + ### `pdcli filter list` List filters @@ -1318,6 +1376,24 @@ pdcli filter list pdcli filter list --type deals --output json ``` +### `pdcli filter update` + +Update a filter (only provided fields change) + +``` +pdcli filter update [flags] +``` + +- `--name ` — Filter name +- `--conditions ` — Conditions JSON (a value or @file). Fields are referenced by numeric field_id — run `pdcli filter helpers` for the valid operators. + +Examples: + +```bash +pdcli filter update 5 --name "Renamed filter" +pdcli filter update 5 --conditions @conditions.json +``` + ## pdcli goal ### `pdcli goal list` @@ -1476,6 +1552,25 @@ pdcli lead update adf21080-0e10-11eb-879b-05d71fb426ec --title "Renamed" pdcli lead update adf21080-0e10-11eb-879b-05d71fb426ec --value 7500 --currency USD ``` +## pdcli mail + +### `pdcli mail list` + +List the synced email linked to a deal. Message bodies are excluded by design (privacy posture) — the 225-char snippet is the preview; each row carries has_body_flag and body_url so you can fetch a body yourself. Requires the mail:read scope and a configured email sync. + +``` +pdcli mail list [flags] +``` + +- `--deal ` _(required)_ — Deal ID to list mail for + +Examples: + +```bash +pdcli mail list --deal 42 +pdcli mail list --deal 42 --output json +``` + ## pdcli mcp ### `pdcli mcp serve` @@ -2858,3 +2953,29 @@ pdcli webhook list pdcli webhook list --output json ``` +### `pdcli webhook listen` + +Run a local webhook dev loop. In tunnel mode it registers a temporary Pipedrive webhook pointing at your public tunnel (--url) and prints/forwards each delivery, deleting the webhook on exit. In --synthetic mode it polls the changes feed instead and emits the same delivery envelope with zero inbound network — useful behind a firewall or for reactive agents. + +``` +pdcli webhook listen [flags] +``` + +- `--url ` — Public tunnel URL that forwards to the local receiver (tunnel mode) +- `--forward-to ` — POST each delivery to this local URL as well +- `--events ` — Comma-separated entity.action filters, e.g. deal.change,person.* +- `--port ` — Local receiver port (tunnel mode) +- `--synthetic` — Poll the changes feed and emit webhook-shaped events (no inbound network) +- `--since ` — Synthetic start point: RFC3339 timestamp or Nd/Nm (else the stored watermark) +- `--interval ` — Milliseconds between synthetic poll cycles +- `--once` — Process a single delivery / poll cycle then exit +- `--max-events ` — Exit after emitting this many events + +Examples: + +```bash +pdcli webhook listen --url https://abc123.ngrok.app --forward-to http://localhost:3000 +pdcli webhook listen --url https://abc123.ngrok.app --events deal.change,person.* +pdcli webhook listen --synthetic --since 15m +``` + diff --git a/package-lock.json b/package-lock.json index 782d176..3bfcba7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wavyx/pdcli", - "version": "0.21.0", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wavyx/pdcli", - "version": "0.21.0", + "version": "0.22.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "8.5.0", diff --git a/package.json b/package.json index c3e47a1..5a6e113 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wavyx/pdcli", - "version": "0.21.0", + "version": "0.22.0", "publishConfig": { "access": "public" }, diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index d2d01a4..5e4e580 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 148 commands in `pdcli` v0.21.0. Every command also +All 154 commands in `pdcli` v0.22.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. @@ -607,6 +607,8 @@ pdcli deal context [flags] | `--no-notes` | Skip the notes slice | | `--no-products` | Skip the products slice | | `--no-participants` | Skip the participants slice | +| `--mail` | Include a mail summary (off by default; needs the mail:read scope and email sync) | +| `--mail-limit` <value> | Max mail messages to scan for the summary | | `--activity-limit` <value> | Max activities to include | | `--note-limit` <value> | Max notes to include | @@ -1233,6 +1235,25 @@ pdcli file upload ./report.pdf --deal 42 ## pdcli filter +### `pdcli filter create` + +Create a filter + +```text +pdcli filter create [flags] +``` + +| Flag | Description | +| --- | --- | +| `--name` <value> | Filter name | +| `--type` <deals\|leads\|org\|people\|products\|activity\|projects> | Filter type | +| `--conditions` <value> | Conditions JSON (a value, @file, or piped stdin). Fields are referenced by numeric field_id — run `pdcli filter helpers` for the valid operators. The blob needs the two-level glue structure {"glue":"and","conditions":[{"glue":"and",...},{"glue":"or",...}]}. | + +```bash +pdcli filter create --name "Open deals" --type deals --conditions @conditions.json +cat conditions.json | pdcli filter create --name "Open deals" --type deals +``` + ### `pdcli filter delete` Delete a filter @@ -1250,6 +1271,25 @@ pdcli filter delete 5 pdcli filter delete 5 --yes ``` +### `pdcli filter export` + +Export a filter (or all filters) as portable {name, type, conditions} JSON. To recreate it, feed the fields to `filter create` separately (the create command takes --name/--type/--conditions, not one blob). + +```text +pdcli filter export [id] [flags] +``` + +| Flag | Description | +| --- | --- | +| `--all` | Export every filter | + +```bash +pdcli filter export 5 > filter.json +pdcli filter export --all > filters.json +# recreate on another account (conditions reference numeric field_id): +pdcli filter create --name "$(jq -r .name filter.json)" --type "$(jq -r .type filter.json)" --conditions "$(jq -c .conditions filter.json)" +``` + ### `pdcli filter get` Get a filter by ID @@ -1263,6 +1303,24 @@ pdcli filter get 5 pdcli filter get 5 --output json ``` +### `pdcli filter helpers` + +List the operators available for authoring filter conditions + +```text +pdcli filter helpers [flags] +``` + +| Flag | Description | +| --- | --- | +| `--type` <value> | Only show operators for this field data type (e.g. varchar, date, int) | + +```bash +pdcli filter helpers +pdcli filter helpers --type varchar +pdcli filter helpers --output json +``` + ### `pdcli filter list` List filters @@ -1280,6 +1338,24 @@ pdcli filter list pdcli filter list --type deals --output json ``` +### `pdcli filter update` + +Update a filter (only provided fields change) + +```text +pdcli filter update [flags] +``` + +| Flag | Description | +| --- | --- | +| `--name` <value> | Filter name | +| `--conditions` <value> | Conditions JSON (a value or @file). Fields are referenced by numeric field_id — run `pdcli filter helpers` for the valid operators. | + +```bash +pdcli filter update 5 --name "Renamed filter" +pdcli filter update 5 --conditions @conditions.json +``` + ## pdcli goal ### `pdcli goal list` @@ -1434,6 +1510,25 @@ pdcli lead update adf21080-0e10-11eb-879b-05d71fb426ec --title "Renamed" pdcli lead update adf21080-0e10-11eb-879b-05d71fb426ec --value 7500 --currency USD ``` +## pdcli mail + +### `pdcli mail list` + +List the synced email linked to a deal. Message bodies are excluded by design (privacy posture) — the 225-char snippet is the preview; each row carries has_body_flag and body_url so you can fetch a body yourself. Requires the mail:read scope and a configured email sync. + +```text +pdcli mail list [flags] +``` + +| Flag | Description | +| --- | --- | +| `--deal` <value> | Deal ID to list mail for | + +```bash +pdcli mail list --deal 42 +pdcli mail list --deal 42 --output json +``` + ## pdcli mcp ### `pdcli mcp serve` @@ -2769,3 +2864,29 @@ pdcli webhook list pdcli webhook list --output json ``` +### `pdcli webhook listen` + +Run a local webhook dev loop. In tunnel mode it registers a temporary Pipedrive webhook pointing at your public tunnel (--url) and prints/forwards each delivery, deleting the webhook on exit. In --synthetic mode it polls the changes feed instead and emits the same delivery envelope with zero inbound network — useful behind a firewall or for reactive agents. + +```text +pdcli webhook listen [flags] +``` + +| Flag | Description | +| --- | --- | +| `--url` <value> | Public tunnel URL that forwards to the local receiver (tunnel mode) | +| `--forward-to` <value> | POST each delivery to this local URL as well | +| `--events` <value> | Comma-separated entity.action filters, e.g. deal.change,person.* | +| `--port` <value> | Local receiver port (tunnel mode) | +| `--synthetic` | Poll the changes feed and emit webhook-shaped events (no inbound network) | +| `--since` <value> | Synthetic start point: RFC3339 timestamp or Nd/Nm (else the stored watermark) | +| `--interval` <value> | Milliseconds between synthetic poll cycles | +| `--once` | Process a single delivery / poll cycle then exit | +| `--max-events` <value> | Exit after emitting this many events | + +```bash +pdcli webhook listen --url https://abc123.ngrok.app --forward-to http://localhost:3000 +pdcli webhook listen --url https://abc123.ngrok.app --events deal.change,person.* +pdcli webhook listen --synthetic --since 15m +``` + diff --git a/website/src/data/cli-stats.json b/website/src/data/cli-stats.json index d452ef8..f5b4888 100644 --- a/website/src/data/cli-stats.json +++ b/website/src/data/cli-stats.json @@ -1,6 +1,6 @@ { - "version": "0.21.0", - "commands": 148, - "topics": 27, + "version": "0.22.0", + "commands": 154, + "topics": 28, "formats": 4 }