@wavyx/pdcli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavyx/pdcli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -68,6 +68,9 @@
68
68
  "field": {
69
69
  "description": "Custom-field discovery and name/key resolution"
70
70
  },
71
+ "metrics": {
72
+ "description": "Sales metrics and KPIs"
73
+ },
71
74
  "user": {
72
75
  "description": "Users"
73
76
  },
@@ -76,6 +79,9 @@
76
79
  },
77
80
  "config": {
78
81
  "description": "Configuration"
82
+ },
83
+ "alias": {
84
+ "description": "Command shortcuts (alias set/list/unset)"
79
85
  }
80
86
  },
81
87
  "hooks": {
@@ -120,6 +126,7 @@
120
126
  "scripts": {
121
127
  "build": "oclif manifest",
122
128
  "docs:commands": "node scripts/gen-commands.mjs",
129
+ "docs:demo": "node scripts/gen-demo.mjs",
123
130
  "lint": "eslint . && prettier --check .",
124
131
  "lint:fix": "eslint --fix . && prettier --write .",
125
132
  "test": "vitest run",
@@ -22,6 +22,12 @@ export default class BaseCommand extends Command {
22
22
  description: 'Comma-separated fields to display',
23
23
  helpGroup: 'GLOBAL',
24
24
  }),
25
+ 'resolve-fields': Flags.boolean({
26
+ description:
27
+ 'Resolve custom-field hash keys to names (and option ids to labels) in json/yaml/csv output of get and core list commands',
28
+ helpGroup: 'GLOBAL',
29
+ default: false,
30
+ }),
25
31
  profile: Flags.string({
26
32
  description: 'Named auth profile to use',
27
33
  helpGroup: 'GLOBAL',
@@ -118,9 +124,13 @@ export default class BaseCommand extends Command {
118
124
  })
119
125
  }
120
126
 
121
- /** The profile's `default_output` config value, when valid. */
127
+ /**
128
+ * The profile's `default_output` config value, when valid. Safe to call
129
+ * before parsing completes (handleError runs for parse failures too,
130
+ * when `this.flags` is still undefined).
131
+ */
122
132
  storedDefaultOutput() {
123
- const stored = loadConfig(this.flags.profile).default_output
133
+ const stored = loadConfig(this.flags?.profile).default_output
124
134
  return ['table', 'json', 'yaml', 'csv'].includes(stored)
125
135
  ? stored
126
136
  : undefined
@@ -141,12 +151,31 @@ export default class BaseCommand extends Command {
141
151
  /**
142
152
  * @param {object | object[]} data
143
153
  * @param {Record<string, import('./lib/output/table.js').Column>} columns
154
+ * @param {{ entity?: string }} [options] entity context enables
155
+ * --resolve-fields custom-field resolution on machine-format lists
144
156
  */
145
- async outputResults(data, columns) {
157
+ async outputResults(data, columns, { entity } = {}) {
158
+ if (
159
+ entity &&
160
+ this.flags['resolve-fields'] &&
161
+ this.resolveFormat() !== 'table' &&
162
+ Array.isArray(data) &&
163
+ data.some((row) => row?.custom_fields)
164
+ ) {
165
+ const { getFields, makeResolver } = await import('./lib/fields.js')
166
+ // getFields is memoized per run — one defs fetch covers the whole list.
167
+ const resolver = makeResolver(await getFields(this.apiClient, entity))
168
+ data = data.map((row) =>
169
+ row?.custom_fields ? resolver.resolveCustomFields(row) : row,
170
+ )
171
+ }
172
+
146
173
  if (this.flags.jq) {
147
174
  // node-jq ships a native binary — load it only when actually used.
175
+ // Single records pass UNWRAPPED: `--jq .id` works on a get without
176
+ // the historical `.[0]` indirection (changed in 0.9.0).
148
177
  const { run } = await import('node-jq')
149
- const input = JSON.stringify(Array.isArray(data) ? data : [data])
178
+ const input = JSON.stringify(data)
150
179
  const result = await run(this.flags.jq, input, {
151
180
  input: 'string',
152
181
  output: 'pretty',
@@ -56,6 +56,6 @@ export default class ActivityListCommand extends BaseCommand {
56
56
  this.apiClient.pageV2('/api/v2/activities', query),
57
57
  limit,
58
58
  )
59
- await this.outputResults(items, columns)
59
+ await this.outputResults(items, columns, { entity: 'activity' })
60
60
  }
61
61
  }
@@ -0,0 +1,31 @@
1
+ import BaseCommand from '../../base-command.js'
2
+ import { getAliases } from '../../lib/aliases.js'
3
+
4
+ const columns = {
5
+ name: { header: 'Alias' },
6
+ command: { header: 'Command' },
7
+ }
8
+
9
+ export default class AliasListCommand extends BaseCommand {
10
+ static skipAuth = true
11
+
12
+ static description = 'List all configured aliases'
13
+
14
+ static examples = ['<%= config.bin %> alias list']
15
+
16
+ async run() {
17
+ const aliases = getAliases()
18
+ const entries = Object.entries(aliases).map(([name, command]) => ({
19
+ name,
20
+ command,
21
+ }))
22
+
23
+ if (entries.length === 0) {
24
+ this.log('No aliases configured.')
25
+ this.log('Create one: pdcli alias set <name> "<command>"')
26
+ return
27
+ }
28
+
29
+ await this.outputResults(entries, columns)
30
+ }
31
+ }
@@ -0,0 +1,97 @@
1
+ import { Args } from '@oclif/core'
2
+ import chalk from 'chalk'
3
+ import BaseCommand from '../../base-command.js'
4
+ import { setAlias, getAliases } from '../../lib/aliases.js'
5
+ import { CliError } from '../../lib/errors.js'
6
+
7
+ const MAX_ALIAS_HOPS = 10
8
+
9
+ export default class AliasSetCommand extends BaseCommand {
10
+ static skipAuth = true
11
+
12
+ static description = 'Create or update an alias'
13
+
14
+ static examples = [
15
+ '<%= config.bin %> alias set wd "deal list --status won"',
16
+ '<%= config.bin %> alias set open "deal list --status open --limit 50"',
17
+ ]
18
+
19
+ static args = {
20
+ name: Args.string({ required: true, description: 'Alias name' }),
21
+ command: Args.string({ required: true, description: 'Command to alias' }),
22
+ }
23
+
24
+ async run() {
25
+ const { args } = await this.parse(AliasSetCommand)
26
+ const name = args.name
27
+
28
+ // A dotted name corrupts conf's dotted-path store/read (the value would
29
+ // be written nested and never round-trip back as a flat alias key).
30
+ if (name.includes('.')) {
31
+ throw new CliError(
32
+ `Cannot create alias ${chalk.cyan(name)}: alias names may not contain '.' (dots).`,
33
+ { exitCode: 64 },
34
+ )
35
+ }
36
+
37
+ if (this.config.findCommand(name)) {
38
+ throw new CliError(
39
+ `Cannot create alias ${chalk.cyan(name)}: it shadows an existing pdcli command.`,
40
+ { exitCode: 64 },
41
+ )
42
+ }
43
+
44
+ // A name matching a topic (e.g. `deal`) is reachable by oclif's dispatcher
45
+ // before command-not-found fires, so the alias would never run.
46
+ if (this.config.findTopic(name)) {
47
+ throw new CliError(
48
+ `Cannot create alias ${chalk.cyan(name)}: it shadows an existing pdcli topic.`,
49
+ { exitCode: 64 },
50
+ )
51
+ }
52
+
53
+ const firstToken = args.command.split(/\s+/).filter(Boolean)[0]
54
+
55
+ // Direct self-reference: `alias set x "x ..."` loops immediately.
56
+ if (firstToken === name) {
57
+ throw new CliError(
58
+ `Cannot create alias ${chalk.cyan(name)}: the command refers to itself.`,
59
+ { exitCode: 64 },
60
+ )
61
+ }
62
+
63
+ // Transitive cycle: walk the existing alias graph from firstToken. If it
64
+ // leads back to `name` within MAX_ALIAS_HOPS, expanding this alias would
65
+ // re-enter forever.
66
+ const aliases = getAliases() ?? {}
67
+ const seen = new Set([name])
68
+ let token = firstToken
69
+ for (let hop = 0; hop < MAX_ALIAS_HOPS; hop++) {
70
+ if (token === name) {
71
+ const cycle = [...seen, name].join(' -> ')
72
+ throw new CliError(
73
+ `Cannot create alias ${chalk.cyan(name)}: it forms a cycle (${cycle}).`,
74
+ { exitCode: 64 },
75
+ )
76
+ }
77
+ const next = aliases[token]
78
+ if (!next) break // token is not an alias — chain terminates safely
79
+ if (seen.has(token)) break // pre-existing cycle not involving `name`
80
+ seen.add(token)
81
+ token = next.split(/\s+/).filter(Boolean)[0]
82
+ }
83
+
84
+ // Aliases run with full credentials — flag ones that hide destructive
85
+ // operations so a shared config can't smuggle in a silent delete.
86
+ if (/\b(DELETE|delete|merge)\b/.test(args.command)) {
87
+ process.stderr.write(
88
+ `${chalk.yellow('Warning:')} this alias wraps a destructive command — ` +
89
+ `it will run without additional confirmation prompts beyond the ` +
90
+ `command's own.\n`,
91
+ )
92
+ }
93
+
94
+ setAlias(name, args.command)
95
+ this.log(chalk.green(`Alias set: ${chalk.cyan(name)} → ${args.command}`))
96
+ }
97
+ }
@@ -0,0 +1,26 @@
1
+ import { Args } from '@oclif/core'
2
+ import chalk from 'chalk'
3
+ import BaseCommand from '../../base-command.js'
4
+ import { getAlias, unsetAlias } from '../../lib/aliases.js'
5
+
6
+ export default class AliasUnsetCommand extends BaseCommand {
7
+ static skipAuth = true
8
+
9
+ static description = 'Remove an alias'
10
+
11
+ static examples = ['<%= config.bin %> alias unset wd']
12
+
13
+ static args = {
14
+ name: Args.string({ required: true, description: 'Alias name' }),
15
+ }
16
+
17
+ async run() {
18
+ const { args } = await this.parse(AliasUnsetCommand)
19
+ if (!getAlias(args.name)) {
20
+ this.log(chalk.yellow(`Alias not found: ${args.name}`))
21
+ return
22
+ }
23
+ unsetAlias(args.name)
24
+ this.log(chalk.green(`Alias removed: ${args.name}`))
25
+ }
26
+ }
@@ -2,6 +2,12 @@ import { Args } from '@oclif/core'
2
2
  import chalk from 'chalk'
3
3
  import BaseCommand from '../../base-command.js'
4
4
  import { setProfileConfig } from '../../lib/config.js'
5
+ import { CliError } from '../../lib/errors.js'
6
+
7
+ /** Allowed values for keys pdcli itself consumes; others are free-form. */
8
+ const VALIDATED_KEYS = {
9
+ default_output: ['table', 'json', 'yaml', 'csv'],
10
+ }
5
11
 
6
12
  export default class ConfigSetCommand extends BaseCommand {
7
13
  static skipAuth = true
@@ -21,6 +27,14 @@ export default class ConfigSetCommand extends BaseCommand {
21
27
  async run() {
22
28
  const { args } = await this.parse(ConfigSetCommand)
23
29
 
30
+ const allowed = VALIDATED_KEYS[args.key]
31
+ if (allowed && !allowed.includes(args.value)) {
32
+ throw new CliError(
33
+ `Invalid value "${args.value}" for ${args.key} — expected one of: ${allowed.join(', ')}`,
34
+ { exitCode: 64 },
35
+ )
36
+ }
37
+
24
38
  setProfileConfig(this.activeProfile, args.key, args.value)
25
39
  this.log(
26
40
  `Set ${chalk.cyan(args.key)} = ${chalk.green(args.value)} for profile ${chalk.cyan(this.activeProfile)}`,
@@ -0,0 +1,32 @@
1
+ import { Args } from '@oclif/core'
2
+ import chalk from 'chalk'
3
+ import BaseCommand from '../../base-command.js'
4
+ import { getProfileConfig, deleteProfileConfig } from '../../lib/config.js'
5
+
6
+ export default class ConfigUnsetCommand extends BaseCommand {
7
+ static skipAuth = true
8
+
9
+ static description = 'Remove a config key from the active profile'
10
+
11
+ static examples = ['<%= config.bin %> config unset default_output']
12
+
13
+ static args = {
14
+ key: Args.string({ required: true, description: 'Config key to remove' }),
15
+ }
16
+
17
+ async run() {
18
+ const { args } = await this.parse(ConfigUnsetCommand)
19
+
20
+ if (getProfileConfig(this.activeProfile, args.key) === undefined) {
21
+ this.log(
22
+ `${chalk.cyan(args.key)} is not set for profile ${chalk.cyan(this.activeProfile)}`,
23
+ )
24
+ return
25
+ }
26
+
27
+ deleteProfileConfig(this.activeProfile, args.key)
28
+ this.log(
29
+ `Removed ${chalk.cyan(args.key)} from profile ${chalk.cyan(this.activeProfile)}`,
30
+ )
31
+ }
32
+ }
@@ -57,6 +57,6 @@ export default class DealListCommand extends BaseCommand {
57
57
  this.apiClient.pageV2('/api/v2/deals', query),
58
58
  limit,
59
59
  )
60
- await this.outputResults(items, columns)
60
+ await this.outputResults(items, columns, { entity: 'deal' })
61
61
  }
62
62
  }
@@ -0,0 +1,56 @@
1
+ import { Flags } from '@oclif/core'
2
+ import BaseCommand from '../../base-command.js'
3
+ import { outputRecord } from '../../lib/entity-view.js'
4
+ import { CliError } from '../../lib/errors.js'
5
+
6
+ export default class FileRemoteLinkCommand extends BaseCommand {
7
+ static description = 'Link an existing remote file (Google Drive) to an item'
8
+
9
+ static examples = [
10
+ '<%= config.bin %> file remote-link --deal 42 --remote-id 1AbC',
11
+ '<%= config.bin %> file remote-link --person 9 --remote-id 1AbC --output json',
12
+ ]
13
+
14
+ static flags = {
15
+ ...BaseCommand.baseFlags,
16
+ deal: Flags.integer({ description: 'Link to a deal ID' }),
17
+ org: Flags.integer({ description: 'Link to an organization ID' }),
18
+ person: Flags.integer({ description: 'Link to a person ID' }),
19
+ 'remote-id': Flags.string({
20
+ required: true,
21
+ description: 'ID of the remote file (e.g. Google Drive file ID)',
22
+ }),
23
+ 'remote-location': Flags.string({
24
+ description: 'Remote storage location',
25
+ options: ['googledrive'],
26
+ default: 'googledrive',
27
+ }),
28
+ }
29
+
30
+ async run() {
31
+ const { flags } = await this.parse(FileRemoteLinkCommand)
32
+
33
+ const items = [
34
+ ['deal', flags.deal],
35
+ ['organization', flags.org],
36
+ ['person', flags.person],
37
+ ].filter(([, id]) => id != null)
38
+
39
+ if (items.length !== 1) {
40
+ throw new CliError('Pass exactly one of --deal, --org, or --person', {
41
+ exitCode: 64,
42
+ })
43
+ }
44
+
45
+ const [item_type, item_id] = items[0]
46
+
47
+ const res = await this.apiClient.postForm('/api/v1/files/remoteLink', {
48
+ item_type,
49
+ item_id,
50
+ remote_id: flags['remote-id'],
51
+ remote_location: flags['remote-location'],
52
+ })
53
+
54
+ await outputRecord(this, res.data)
55
+ }
56
+ }
@@ -2,8 +2,13 @@ import { Flags } from '@oclif/core'
2
2
  import BaseCommand from '../base-command.js'
3
3
  import { collectPages } from '../lib/pagination.js'
4
4
  import { parsePeriod, formatApiDatetime } from '../lib/period.js'
5
- import { computeFunnel } from '../lib/analytics.js'
6
- import { CliError } from '../lib/errors.js'
5
+ import { computeFunnel, computeExactFunnel } from '../lib/analytics.js'
6
+ import { CliError, ApiError } from '../lib/errors.js'
7
+
8
+ /** Token cost of one GET /deals/{id}/changelog request (rate-limit budget). */
9
+ const CHANGELOG_COST = 20
10
+ /** Above this deal count, mining gets expensive — warn before proceeding. */
11
+ const MINE_WARN_THRESHOLD = 100
7
12
 
8
13
  export default class FunnelCommand extends BaseCommand {
9
14
  static description =
@@ -23,6 +28,14 @@ export default class FunnelCommand extends BaseCommand {
23
28
  pipeline: Flags.integer({
24
29
  description: 'Pipeline ID (required when the account has several)',
25
30
  }),
31
+ exact: Flags.boolean({
32
+ description:
33
+ 'Mine real stage transitions from each deal’s changelog instead ' +
34
+ 'of approximating from the final stage (one request per deal). ' +
35
+ '--period scopes only closed (won/lost) deals; open deals are ' +
36
+ 'always included.',
37
+ default: false,
38
+ }),
26
39
  }
27
40
 
28
41
  async run() {
@@ -71,6 +84,36 @@ export default class FunnelCommand extends BaseCommand {
71
84
  ),
72
85
  ])
73
86
 
87
+ if (flags.exact) {
88
+ const exact = await this.mineExactFunnel(
89
+ [...open, ...won, ...lost],
90
+ stages,
91
+ pipelineId,
92
+ )
93
+ const columns = {
94
+ stage: { header: 'Stage' },
95
+ entered: { header: 'Entered (observed)' },
96
+ conversionFromPrev: {
97
+ // Not funnel conversion — exact entries are non-monotonic so this
98
+ // ratio can exceed 100%. Labelled to avoid that misreading.
99
+ header: 'Entered vs prev',
100
+ get: (row) =>
101
+ row.conversionFromPrev == null
102
+ ? ''
103
+ : `${(row.conversionFromPrev * 100).toFixed(0)}%`,
104
+ },
105
+ }
106
+ // `won` is a single total: tables report it once under the rows;
107
+ // machine formats carry it as a top-level field next to the rows.
108
+ if (this.resolveFormat() === 'table') {
109
+ await this.outputResults(exact.rows, columns)
110
+ this.log(`Won: ${exact.won}`)
111
+ } else {
112
+ await this.outputResults(exact, columns)
113
+ }
114
+ return
115
+ }
116
+
74
117
  const funnel = computeFunnel([...won, ...lost], open, stages, {
75
118
  pipelineId,
76
119
  })
@@ -89,4 +132,56 @@ export default class FunnelCommand extends BaseCommand {
89
132
  openValue: { header: 'Open value' },
90
133
  })
91
134
  }
135
+
136
+ /**
137
+ * Mine real stage transitions from each deal's v1 changelog. The changelog
138
+ * uses a flat v2-style cursor (additional_data.next_cursor on a v1 path), so
139
+ * the v2 pager works directly. Warns on stderr before mining a large set —
140
+ * each request costs 20 tokens — then lets the client's rate limiter pace it.
141
+ * @param {object[]} deals deals to mine (current stage_id needed per deal)
142
+ * @param {object[]} stages
143
+ * @param {number} pipelineId
144
+ */
145
+ async mineExactFunnel(deals, stages, pipelineId) {
146
+ if (deals.length > MINE_WARN_THRESHOLD) {
147
+ process.stderr.write(
148
+ `Mining stage history for ${deals.length} deals ` +
149
+ `(~${deals.length} requests, ${CHANGELOG_COST} tokens each); ` +
150
+ `rate limiting may slow this down.\n`,
151
+ )
152
+ }
153
+
154
+ const transitionsByDeal = []
155
+ let skipped = 0
156
+ for (const deal of deals) {
157
+ try {
158
+ const rows = await collectPages(
159
+ this.apiClient.pageV2(`/api/v1/deals/${deal.id}/changelog`, {
160
+ limit: 500,
161
+ }),
162
+ )
163
+ transitionsByDeal.push({
164
+ dealId: deal.id,
165
+ stageId: deal.stage_id,
166
+ rows,
167
+ })
168
+ } catch (err) {
169
+ // One bad changelog request must not abort the whole mine: skip the
170
+ // deal, count it, and warn once after mining completes.
171
+ if (err instanceof ApiError) {
172
+ skipped++
173
+ continue
174
+ }
175
+ throw err
176
+ }
177
+ }
178
+
179
+ if (skipped > 0) {
180
+ process.stderr.write(
181
+ `skipped ${skipped} deal(s) whose changelog could not be fetched\n`,
182
+ )
183
+ }
184
+
185
+ return computeExactFunnel(transitionsByDeal, stages, { pipelineId })
186
+ }
92
187
  }
@@ -0,0 +1,27 @@
1
+ import BaseCommand from '../../../base-command.js'
2
+
3
+ const columns = {
4
+ id: { header: 'ID' },
5
+ name: { header: 'Name' },
6
+ color: { header: 'Color' },
7
+ }
8
+
9
+ export default class LeadLabelListCommand extends BaseCommand {
10
+ static description = 'List lead labels'
11
+
12
+ static examples = [
13
+ '<%= config.bin %> lead label list',
14
+ '<%= config.bin %> lead label list --output json',
15
+ ]
16
+
17
+ static flags = {
18
+ ...BaseCommand.baseFlags,
19
+ }
20
+
21
+ async run() {
22
+ await this.parse(LeadLabelListCommand)
23
+ // leadLabels has no pagination — all labels are always returned.
24
+ const body = await this.apiClient.get('/api/v1/leadLabels')
25
+ await this.outputResults(body.data, columns)
26
+ }
27
+ }