@wavyx/pdcli 0.7.0 → 0.8.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/CHANGELOG.md +43 -0
- package/README.md +8 -0
- package/oclif.manifest.json +2598 -925
- package/package.json +8 -1
- package/src/base-command.js +12 -2
- package/src/commands/alias/list.js +31 -0
- package/src/commands/alias/set.js +97 -0
- package/src/commands/alias/unset.js +26 -0
- package/src/commands/config/set.js +14 -0
- package/src/commands/config/unset.js +32 -0
- package/src/commands/file/remote-link.js +56 -0
- package/src/commands/funnel.js +97 -2
- package/src/commands/lead/label/list.js +27 -0
- package/src/commands/metrics/coverage.js +251 -0
- package/src/commands/org/merge.js +97 -0
- package/src/commands/person/merge.js +91 -0
- package/src/hooks/command-not-found.js +68 -0
- package/src/lib/aliases.js +35 -0
- package/src/lib/analytics.js +142 -0
- package/src/lib/audit.js +107 -6
- package/src/lib/client.js +30 -0
- package/src/lib/confirm.js +15 -2
- package/src/lib/entity-view.js +15 -9
- package/src/lib/fields.js +4 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import BaseCommand from '../../base-command.js'
|
|
3
|
+
import { collectPages } from '../../lib/pagination.js'
|
|
4
|
+
import { parsePeriod } from '../../lib/period.js'
|
|
5
|
+
import { computeHealth, computeCoverage } from '../../lib/analytics.js'
|
|
6
|
+
import { CliError } from '../../lib/errors.js'
|
|
7
|
+
|
|
8
|
+
/** YYYY-MM-DD — the date format the Goals API period params expect. */
|
|
9
|
+
function toGoalDate(date) {
|
|
10
|
+
return date.toISOString().slice(0, 10)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Normalize a goal type name for comparison: lowercased, spaces→underscores. */
|
|
14
|
+
function normalizeGoalType(name) {
|
|
15
|
+
return String(name ?? '')
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/\s+/g, '_')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Known non-revenue goal type names. A `sum` tracking metric on one of these
|
|
22
|
+
* (e.g. a deals_won value goal) must not silently join the revenue quota.
|
|
23
|
+
*/
|
|
24
|
+
const NON_REVENUE_TYPES = new Set([
|
|
25
|
+
'deals_won',
|
|
26
|
+
'deals_progressed',
|
|
27
|
+
'deals_started',
|
|
28
|
+
'activities_completed',
|
|
29
|
+
'activities_added',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
export default class MetricsCoverageCommand extends BaseCommand {
|
|
33
|
+
static description =
|
|
34
|
+
'Pipeline coverage: probability-weighted open pipeline vs the revenue still needed to hit quota'
|
|
35
|
+
|
|
36
|
+
static examples = [
|
|
37
|
+
'<%= config.bin %> metrics coverage',
|
|
38
|
+
'<%= config.bin %> metrics coverage --pipeline 1',
|
|
39
|
+
'<%= config.bin %> metrics coverage --target 250000',
|
|
40
|
+
'<%= config.bin %> metrics coverage --period 1m --output json',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
static flags = {
|
|
44
|
+
...BaseCommand.baseFlags,
|
|
45
|
+
pipeline: Flags.integer({
|
|
46
|
+
description: 'Pipeline ID (required when the account has several)',
|
|
47
|
+
}),
|
|
48
|
+
period: Flags.string({
|
|
49
|
+
description: 'Goal measurement window (Nd or Nm)',
|
|
50
|
+
default: '90d',
|
|
51
|
+
}),
|
|
52
|
+
target: Flags.integer({
|
|
53
|
+
description:
|
|
54
|
+
'Manual revenue quota override (skips the Goals API entirely)',
|
|
55
|
+
}),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async run() {
|
|
59
|
+
const { flags } = await this.parse(MetricsCoverageCommand)
|
|
60
|
+
const now = new Date()
|
|
61
|
+
|
|
62
|
+
const pipelineId = await this.#resolvePipeline(flags.pipeline)
|
|
63
|
+
|
|
64
|
+
const [stages, open] = await Promise.all([
|
|
65
|
+
collectPages(
|
|
66
|
+
this.apiClient.pageV2('/api/v2/stages', {
|
|
67
|
+
pipeline_id: pipelineId,
|
|
68
|
+
limit: 500,
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
collectPages(
|
|
72
|
+
this.apiClient.pageV2('/api/v2/deals', {
|
|
73
|
+
pipeline_id: pipelineId,
|
|
74
|
+
status: 'open',
|
|
75
|
+
limit: 500,
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
// computeHealth gives both the raw open value (classic coverage input)
|
|
81
|
+
// and the probability-weighted value (risk-adjusted secondary view).
|
|
82
|
+
// Activities are irrelevant here, so pass an empty list.
|
|
83
|
+
const healthRows = computeHealth(open, stages, [], { now })
|
|
84
|
+
const openValue = healthRows.reduce((sum, row) => sum + row.openValue, 0)
|
|
85
|
+
const weightedOpen = healthRows.reduce(
|
|
86
|
+
(sum, row) => sum + row.weightedValue,
|
|
87
|
+
0,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const { goalTarget, progress } =
|
|
91
|
+
flags.target != null
|
|
92
|
+
? { goalTarget: flags.target, progress: 0 }
|
|
93
|
+
: await this.#fetchRevenueGoal(flags.period, now)
|
|
94
|
+
|
|
95
|
+
const coverage = computeCoverage({
|
|
96
|
+
openValue,
|
|
97
|
+
weightedOpen,
|
|
98
|
+
goalTarget,
|
|
99
|
+
progress,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
if (this.resolveFormat() === 'table') {
|
|
103
|
+
await this.#renderTable(coverage)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await this.outputResults(coverage, {})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the pipeline to report on: the explicit flag, else the only
|
|
112
|
+
* pipeline in the account. Several pipelines without a flag is a usage error.
|
|
113
|
+
* @param {number | undefined} flagPipeline
|
|
114
|
+
*/
|
|
115
|
+
async #resolvePipeline(flagPipeline) {
|
|
116
|
+
if (flagPipeline != null) return flagPipeline
|
|
117
|
+
|
|
118
|
+
const body = await this.apiClient.get('/api/v2/pipelines')
|
|
119
|
+
const pipelines = body.data ?? []
|
|
120
|
+
if (pipelines.length > 1) {
|
|
121
|
+
throw new CliError(
|
|
122
|
+
`Account has ${pipelines.length} pipelines — pass --pipeline <id> ` +
|
|
123
|
+
`(${pipelines.map((p) => `${p.id}=${p.name}`).join(', ')})`,
|
|
124
|
+
{ exitCode: 64 },
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return pipelines[0]?.id
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Find the active revenue goal(s) via the v1 Goals API and read their
|
|
132
|
+
* progress. The find enum has no `revenue_forecast`, so query without a
|
|
133
|
+
* type and filter client-side. Prefer goals whose type normalizes to
|
|
134
|
+
* `revenue_forecast`; otherwise fall back to `sum` goals that are not a
|
|
135
|
+
* known non-revenue type (so a deals_won sum goal does not silently join
|
|
136
|
+
* the quota). A lone non-revenue sum goal is used as a last resort with a
|
|
137
|
+
* stderr note. Targets/progress are summed across matched goals, which must
|
|
138
|
+
* share a single currency.
|
|
139
|
+
* @param {string} period
|
|
140
|
+
* @param {Date} now
|
|
141
|
+
*/
|
|
142
|
+
async #fetchRevenueGoal(period, now) {
|
|
143
|
+
const periodStart = toGoalDate(parsePeriod(period, now))
|
|
144
|
+
const periodEnd = toGoalDate(now)
|
|
145
|
+
|
|
146
|
+
const found = await this.apiClient.get('/api/v1/goals/find', {
|
|
147
|
+
query: { 'period.start': periodStart, 'period.end': periodEnd },
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const allGoals = found.data?.goals ?? []
|
|
151
|
+
|
|
152
|
+
// True revenue goals: type name normalizes to revenue_forecast.
|
|
153
|
+
const revenueGoals = allGoals.filter(
|
|
154
|
+
(g) => normalizeGoalType(g.type?.name) === 'revenue_forecast',
|
|
155
|
+
)
|
|
156
|
+
// Fallback: value (`sum`) goals whose type is NOT a known non-revenue type.
|
|
157
|
+
// A deals_won sum goal must not silently join the revenue quota.
|
|
158
|
+
const fallbackGoals = allGoals.filter(
|
|
159
|
+
(g) =>
|
|
160
|
+
g.expected_outcome?.tracking_metric === 'sum' &&
|
|
161
|
+
normalizeGoalType(g.type?.name) !== 'revenue_forecast' &&
|
|
162
|
+
!NON_REVENUE_TYPES.has(normalizeGoalType(g.type?.name)),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
let goals = revenueGoals.length > 0 ? revenueGoals : fallbackGoals
|
|
166
|
+
|
|
167
|
+
// If nothing matched even loosely, fall back to any sum goal so a lone
|
|
168
|
+
// deals_won value goal can still serve as the quota — but flag what we used.
|
|
169
|
+
if (goals.length === 0) {
|
|
170
|
+
const sumGoals = allGoals.filter(
|
|
171
|
+
(g) => g.expected_outcome?.tracking_metric === 'sum',
|
|
172
|
+
)
|
|
173
|
+
if (sumGoals.length > 0) {
|
|
174
|
+
const types = [
|
|
175
|
+
...new Set(sumGoals.map((g) => normalizeGoalType(g.type?.name))),
|
|
176
|
+
].join(', ')
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`Note: no revenue_forecast goal found; using sum goal(s) of type ` +
|
|
179
|
+
`'${types}' as the quota.\n`,
|
|
180
|
+
)
|
|
181
|
+
goals = sumGoals
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (goals.length === 0) {
|
|
186
|
+
throw new CliError(
|
|
187
|
+
'No active revenue goal found — create one in Pipedrive or pass --target',
|
|
188
|
+
{ exitCode: 64 },
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Coverage cannot sum targets/progress across mixed currencies.
|
|
193
|
+
// A goal without a currency_id is its own bucket — mixing it with a
|
|
194
|
+
// real currency is just as unsumable as mixing two real ones.
|
|
195
|
+
const currencyIds = [
|
|
196
|
+
...new Set(goals.map((g) => g.expected_outcome?.currency_id ?? 'none')),
|
|
197
|
+
]
|
|
198
|
+
if (currencyIds.length > 1) {
|
|
199
|
+
throw new CliError(
|
|
200
|
+
`Goals use multiple currencies (ids: ${currencyIds.join(', ')}) — ` +
|
|
201
|
+
`coverage cannot mix them; pass --target to set a single quota.`,
|
|
202
|
+
{ exitCode: 64 },
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const goalTarget = goals.reduce(
|
|
207
|
+
(sum, g) => sum + (g.expected_outcome?.target ?? 0),
|
|
208
|
+
0,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
const results = await Promise.all(
|
|
212
|
+
goals.map((g) =>
|
|
213
|
+
this.apiClient.get(`/api/v1/goals/${g.id}/results`, {
|
|
214
|
+
query: { 'period.start': periodStart, 'period.end': periodEnd },
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
const progress = results.reduce(
|
|
219
|
+
(sum, r) => sum + (r.data?.progress ?? 0),
|
|
220
|
+
0,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return { goalTarget, progress }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @param {ReturnType<typeof computeCoverage>} coverage */
|
|
227
|
+
async #renderTable(coverage) {
|
|
228
|
+
const money = (n) => String(Math.round(n))
|
|
229
|
+
const ratio =
|
|
230
|
+
coverage.coverage == null ? 'covered' : `${coverage.coverage.toFixed(1)}x`
|
|
231
|
+
|
|
232
|
+
const weightedRatio =
|
|
233
|
+
coverage.weightedCoverage == null
|
|
234
|
+
? 'covered'
|
|
235
|
+
: `${coverage.weightedCoverage.toFixed(1)}x`
|
|
236
|
+
|
|
237
|
+
await this.outputResults(
|
|
238
|
+
[
|
|
239
|
+
{ metric: 'Open pipeline', value: money(coverage.openValue) },
|
|
240
|
+
{ metric: 'Weighted pipeline', value: money(coverage.weightedOpen) },
|
|
241
|
+
{ metric: 'Quota', value: money(coverage.goalTarget) },
|
|
242
|
+
{ metric: 'Progress', value: money(coverage.progress) },
|
|
243
|
+
{ metric: 'Remaining', value: money(coverage.remaining) },
|
|
244
|
+
{ metric: 'Coverage ratio', value: ratio },
|
|
245
|
+
{ metric: 'Weighted coverage', value: weightedRatio },
|
|
246
|
+
{ metric: 'Verdict', value: coverage.verdict },
|
|
247
|
+
],
|
|
248
|
+
{ metric: { header: 'Metric' }, value: { header: 'Value' } },
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core'
|
|
2
|
+
import BaseCommand from '../../base-command.js'
|
|
3
|
+
import { confirmAction } from '../../lib/confirm.js'
|
|
4
|
+
import { CliError, ApiError } from '../../lib/errors.js'
|
|
5
|
+
import { outputRecord } from '../../lib/entity-view.js'
|
|
6
|
+
|
|
7
|
+
export default class OrgMergeCommand extends BaseCommand {
|
|
8
|
+
static description =
|
|
9
|
+
'Merge one organization into another. WARNING: the positional <id> is ' +
|
|
10
|
+
'the LOSING record — Pipedrive deletes it. --into is the surviving ' +
|
|
11
|
+
'record whose data wins on conflict. All related data (deals, ' +
|
|
12
|
+
'activities, notes, files) is transferred to the survivor.'
|
|
13
|
+
|
|
14
|
+
static examples = [
|
|
15
|
+
'<%= config.bin %> org merge 123 --into 456',
|
|
16
|
+
'<%= config.bin %> org merge 123 --into 456 --yes',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
static args = {
|
|
20
|
+
id: Args.integer({
|
|
21
|
+
required: true,
|
|
22
|
+
description: 'ID of the organization to merge and DELETE (the loser)',
|
|
23
|
+
}),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static flags = {
|
|
27
|
+
...BaseCommand.baseFlags,
|
|
28
|
+
into: Flags.integer({
|
|
29
|
+
required: true,
|
|
30
|
+
description: 'ID of the surviving organization to keep (the winner)',
|
|
31
|
+
}),
|
|
32
|
+
yes: Flags.boolean({
|
|
33
|
+
char: 'y',
|
|
34
|
+
description: 'Skip the confirmation prompt',
|
|
35
|
+
default: false,
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async run() {
|
|
40
|
+
const { args, flags } = await this.parse(OrgMergeCommand)
|
|
41
|
+
|
|
42
|
+
if (args.id === flags.into) {
|
|
43
|
+
throw new CliError('Cannot merge an organization into itself', {
|
|
44
|
+
exitCode: 64,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Unless --yes, look up BOTH records first so the prompt can name them and
|
|
49
|
+
// a bad id hard-fails BEFORE the irreversible merge. With --yes we skip the
|
|
50
|
+
// lookups and the prompt entirely to save rate-limit budget.
|
|
51
|
+
if (!flags.yes) {
|
|
52
|
+
const loser = await this.apiClient.get(`/api/v2/organizations/${args.id}`)
|
|
53
|
+
const winner = await this.apiClient.get(
|
|
54
|
+
`/api/v2/organizations/${flags.into}`,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const ok = await confirmAction(
|
|
58
|
+
`Merge organization ${args.id} "${loser.data?.name}" into ` +
|
|
59
|
+
`${flags.into} "${winner.data?.name}"? ` +
|
|
60
|
+
`Organization ${args.id} "${loser.data?.name}" will be DELETED.`,
|
|
61
|
+
false,
|
|
62
|
+
{ default: false },
|
|
63
|
+
)
|
|
64
|
+
if (!ok) {
|
|
65
|
+
throw new CliError('Aborted', { exitCode: 1 })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The v1 merge response only carries { id }; re-fetch the full survivor
|
|
70
|
+
// from v2 so output matches `org get`.
|
|
71
|
+
const merge = await this.apiClient.put(
|
|
72
|
+
`/api/v1/organizations/${args.id}/merge`,
|
|
73
|
+
{ body: { merge_with_id: flags.into } },
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
this.logToStderr(`Merged organization ${args.id} into ${flags.into}`)
|
|
77
|
+
|
|
78
|
+
let record
|
|
79
|
+
try {
|
|
80
|
+
const body = await this.apiClient.get(
|
|
81
|
+
`/api/v2/organizations/${flags.into}`,
|
|
82
|
+
)
|
|
83
|
+
record = body.data
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Only API-level failures (eventual-consistency 404, transient 5xx)
|
|
86
|
+
// degrade gracefully — anything else is a real bug and must surface.
|
|
87
|
+
if (!(err instanceof ApiError)) throw err
|
|
88
|
+
// The merge already succeeded and is irreversible; an eventual-consistency
|
|
89
|
+
// 404 or transient 500 on the re-fetch must not look like a failure, or an
|
|
90
|
+
// agent would retry the destructive op. Warn and emit the minimal id.
|
|
91
|
+
this.logToStderr('Warning: merged, but could not load the survivor view')
|
|
92
|
+
record = { id: merge.data?.id ?? flags.into }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await outputRecord(this, record, 'org')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core'
|
|
2
|
+
import BaseCommand from '../../base-command.js'
|
|
3
|
+
import { confirmAction } from '../../lib/confirm.js'
|
|
4
|
+
import { CliError, ApiError } from '../../lib/errors.js'
|
|
5
|
+
import { outputRecord } from '../../lib/entity-view.js'
|
|
6
|
+
|
|
7
|
+
export default class PersonMergeCommand extends BaseCommand {
|
|
8
|
+
static description =
|
|
9
|
+
'Merge one person into another. WARNING: the positional <id> is the ' +
|
|
10
|
+
'LOSING record — Pipedrive deletes it. --into is the surviving record ' +
|
|
11
|
+
'whose data wins on conflict. All related data (deals, activities, ' +
|
|
12
|
+
'notes, files) is transferred to the survivor.'
|
|
13
|
+
|
|
14
|
+
static examples = [
|
|
15
|
+
'<%= config.bin %> person merge 123 --into 456',
|
|
16
|
+
'<%= config.bin %> person merge 123 --into 456 --yes',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
static args = {
|
|
20
|
+
id: Args.integer({
|
|
21
|
+
required: true,
|
|
22
|
+
description: 'ID of the person to merge and DELETE (the loser)',
|
|
23
|
+
}),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static flags = {
|
|
27
|
+
...BaseCommand.baseFlags,
|
|
28
|
+
into: Flags.integer({
|
|
29
|
+
required: true,
|
|
30
|
+
description: 'ID of the surviving person to keep (the winner)',
|
|
31
|
+
}),
|
|
32
|
+
yes: Flags.boolean({
|
|
33
|
+
char: 'y',
|
|
34
|
+
description: 'Skip the confirmation prompt',
|
|
35
|
+
default: false,
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async run() {
|
|
40
|
+
const { args, flags } = await this.parse(PersonMergeCommand)
|
|
41
|
+
|
|
42
|
+
if (args.id === flags.into) {
|
|
43
|
+
throw new CliError('Cannot merge a person into itself', { exitCode: 64 })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Unless --yes, look up BOTH records first so the prompt can name them and
|
|
47
|
+
// a bad id hard-fails BEFORE the irreversible merge. With --yes we skip the
|
|
48
|
+
// lookups and the prompt entirely to save rate-limit budget.
|
|
49
|
+
if (!flags.yes) {
|
|
50
|
+
const loser = await this.apiClient.get(`/api/v2/persons/${args.id}`)
|
|
51
|
+
const winner = await this.apiClient.get(`/api/v2/persons/${flags.into}`)
|
|
52
|
+
|
|
53
|
+
const ok = await confirmAction(
|
|
54
|
+
`Merge person ${args.id} "${loser.data?.name}" into ` +
|
|
55
|
+
`${flags.into} "${winner.data?.name}"? ` +
|
|
56
|
+
`Person ${args.id} "${loser.data?.name}" will be DELETED.`,
|
|
57
|
+
false,
|
|
58
|
+
{ default: false },
|
|
59
|
+
)
|
|
60
|
+
if (!ok) {
|
|
61
|
+
throw new CliError('Aborted', { exitCode: 1 })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The v1 merge response carries the raw v1 shape (top-level hash custom
|
|
66
|
+
// fields, email/phone arrays). Re-fetch the survivor from v2 so output
|
|
67
|
+
// matches `person get`.
|
|
68
|
+
const merge = await this.apiClient.put(`/api/v1/persons/${args.id}/merge`, {
|
|
69
|
+
body: { merge_with_id: flags.into },
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
this.logToStderr(`Merged person ${args.id} into ${flags.into}`)
|
|
73
|
+
|
|
74
|
+
let record
|
|
75
|
+
try {
|
|
76
|
+
const body = await this.apiClient.get(`/api/v2/persons/${flags.into}`)
|
|
77
|
+
record = body.data
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// Only API-level failures (eventual-consistency 404, transient 5xx)
|
|
80
|
+
// degrade gracefully — anything else is a real bug and must surface.
|
|
81
|
+
if (!(err instanceof ApiError)) throw err
|
|
82
|
+
// The merge already succeeded and is irreversible; an eventual-consistency
|
|
83
|
+
// 404 or transient 500 on the re-fetch must not look like a failure, or an
|
|
84
|
+
// agent would retry the destructive op. Warn and emit the minimal id.
|
|
85
|
+
this.logToStderr('Warning: merged, but could not load the survivor view')
|
|
86
|
+
record = { id: merge.data?.id ?? flags.into }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await outputRecord(this, record, 'person')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -1,9 +1,74 @@
|
|
|
1
1
|
import createDebug from 'debug'
|
|
2
2
|
import chalk from 'chalk'
|
|
3
|
+
import { getAlias } from '../lib/aliases.js'
|
|
3
4
|
|
|
4
5
|
const debug = createDebug('pd:command-not-found')
|
|
5
6
|
|
|
7
|
+
const MAX_ALIAS_DEPTH = 10
|
|
8
|
+
|
|
9
|
+
// Tracks alias names expanded within a single process. The hook re-enters
|
|
10
|
+
// itself through oclif's runCommand dispatcher (same module instance), so a
|
|
11
|
+
// module-level Set survives across those re-entrant invocations and lets us
|
|
12
|
+
// detect cycles before they exhaust the stack/heap. Cleared by the root
|
|
13
|
+
// invocation in `finally` so distinct alias runs in one process (e.g. tests,
|
|
14
|
+
// or a long-lived embedding) don't false-positive against each other.
|
|
15
|
+
const aliasChain = new Set()
|
|
16
|
+
|
|
6
17
|
export default async function commandNotFound(options) {
|
|
18
|
+
const alias = getAlias(options.id)
|
|
19
|
+
|
|
20
|
+
if (alias) {
|
|
21
|
+
// Runaway detection. Two distinct failure modes, reported distinctly:
|
|
22
|
+
// a repeated alias name is a true cycle; a long acyclic chain trips the
|
|
23
|
+
// depth cap (legal but almost certainly a mistake — and the cap is what
|
|
24
|
+
// keeps a missed cycle from exhausting the heap).
|
|
25
|
+
if (aliasChain.has(options.id)) {
|
|
26
|
+
const cycle = [...aliasChain, options.id].join(' -> ')
|
|
27
|
+
aliasChain.clear()
|
|
28
|
+
process.stderr.write(
|
|
29
|
+
`${chalk.red('Error:')} alias cycle detected: ${chalk.yellow(cycle)}\n`,
|
|
30
|
+
)
|
|
31
|
+
process.exit(64)
|
|
32
|
+
}
|
|
33
|
+
if (aliasChain.size >= MAX_ALIAS_DEPTH) {
|
|
34
|
+
const chain = [...aliasChain, options.id].join(' -> ')
|
|
35
|
+
aliasChain.clear()
|
|
36
|
+
process.stderr.write(
|
|
37
|
+
`${chalk.red('Error:')} alias expansion exceeded ${MAX_ALIAS_DEPTH} hops: ${chalk.yellow(chain)}\n`,
|
|
38
|
+
)
|
|
39
|
+
process.exit(64)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// This invocation owns cleanup only if it started a fresh chain.
|
|
43
|
+
const isRoot = aliasChain.size === 0
|
|
44
|
+
aliasChain.add(options.id)
|
|
45
|
+
|
|
46
|
+
debug('expanding alias %s -> %s', options.id, alias)
|
|
47
|
+
const aliasArgv = alias.split(/\s+/).filter(Boolean)
|
|
48
|
+
const fullArgv = [...aliasArgv, ...(options.argv ?? [])]
|
|
49
|
+
|
|
50
|
+
let commandId = fullArgv[0]
|
|
51
|
+
let restArgv = fullArgv.slice(1)
|
|
52
|
+
|
|
53
|
+
for (let i = 1; i < fullArgv.length; i++) {
|
|
54
|
+
const candidate = fullArgv.slice(0, i + 1).join(':')
|
|
55
|
+
if (options.config.findCommand(candidate)) {
|
|
56
|
+
commandId = candidate
|
|
57
|
+
restArgv = fullArgv.slice(i + 1)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await options.config.runCommand(commandId, restArgv)
|
|
63
|
+
process.exit(0)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
debug('alias execution failed: %s', err.message)
|
|
66
|
+
process.exit(err.exitCode ?? 1)
|
|
67
|
+
} finally {
|
|
68
|
+
if (isRoot) aliasChain.clear()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
7
72
|
debug('command not found: %s', options.id)
|
|
8
73
|
process.stderr.write(
|
|
9
74
|
`${chalk.red('Error:')} ${chalk.yellow(options.id)} is not a pdcli command.\n`,
|
|
@@ -11,5 +76,8 @@ export default async function commandNotFound(options) {
|
|
|
11
76
|
process.stderr.write(
|
|
12
77
|
`Run ${chalk.cyan('pdcli help')} for a list of available commands.\n`,
|
|
13
78
|
)
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
`Run ${chalk.cyan('pdcli alias list')} to see configured aliases.\n`,
|
|
81
|
+
)
|
|
14
82
|
process.exit(127)
|
|
15
83
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getConf } from './config.js'
|
|
2
|
+
|
|
3
|
+
export function getAliases() {
|
|
4
|
+
return getConf().get('aliases') ?? {}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} name
|
|
9
|
+
* @returns {string | undefined}
|
|
10
|
+
*/
|
|
11
|
+
export function getAlias(name) {
|
|
12
|
+
return getAliases()[name]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Write the whole `aliases` object back with a literal key, rather than a
|
|
17
|
+
* dotted-path write. conf splits `set('aliases.<name>', …)` on every '.', so a
|
|
18
|
+
* dotted-path write would corrupt any name containing a dot (store/read
|
|
19
|
+
* mismatch). Mutating the object and writing it whole keeps odd names flat.
|
|
20
|
+
* @param {string} name
|
|
21
|
+
* @param {string} command
|
|
22
|
+
*/
|
|
23
|
+
export function setAlias(name, command) {
|
|
24
|
+
const aliases = { ...getAliases(), [name]: command }
|
|
25
|
+
getConf().set('aliases', aliases)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} name
|
|
30
|
+
*/
|
|
31
|
+
export function unsetAlias(name) {
|
|
32
|
+
const aliases = { ...getAliases() }
|
|
33
|
+
delete aliases[name]
|
|
34
|
+
getConf().set('aliases', aliases)
|
|
35
|
+
}
|