@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.
@@ -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
+ }
@@ -35,6 +35,6 @@ export default class OrgListCommand extends BaseCommand {
35
35
  this.apiClient.pageV2('/api/v2/organizations', query),
36
36
  limit,
37
37
  )
38
- await this.outputResults(items, columns)
38
+ await this.outputResults(items, columns, { entity: 'org' })
39
39
  }
40
40
  }
@@ -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
+ }
@@ -44,6 +44,6 @@ export default class PersonListCommand extends BaseCommand {
44
44
  this.apiClient.pageV2('/api/v2/persons', query),
45
45
  limit,
46
46
  )
47
- await this.outputResults(items, columns)
47
+ await this.outputResults(items, columns, { entity: 'person' })
48
48
  }
49
49
  }
@@ -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
+ }
@@ -40,6 +40,6 @@ export default class ProductListCommand extends BaseCommand {
40
40
  this.apiClient.pageV2('/api/v2/products', query),
41
41
  limit,
42
42
  )
43
- await this.outputResults(items, columns)
43
+ await this.outputResults(items, columns, { entity: 'product' })
44
44
  }
45
45
  }
@@ -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,115 @@
1
+ import fs from 'node:fs'
2
+ import { getConf } from './config.js'
3
+ import { CliError } from './errors.js'
4
+
5
+ const LOCK_STALE_MS = 5000
6
+ const LOCK_RETRIES = 8
7
+ const LOCK_RETRY_MS = 50
8
+
9
+ /** Synchronous sleep for the short lock-retry loop (no event-loop yield needed). */
10
+ function sleepSync(ms) {
11
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
12
+ }
13
+
14
+ /**
15
+ * Release the lock. Never throws: the mutation already persisted, so a
16
+ * failed release must not convert success into a reported failure (a
17
+ * leftover dir goes stale in 5s and is reaped by the next writer).
18
+ */
19
+ function releaseLock(lockDir) {
20
+ try {
21
+ fs.rmdirSync(lockDir)
22
+ } catch {
23
+ /* benign: stale-broken concurrently, or unremovable — reaped later */
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Advisory lock around alias mutations: a lock DIRECTORY next to the conf
29
+ * file (mkdir is atomic on every platform we support). Protects concurrent
30
+ * pdcli processes from clobbering each other's read-modify-write of the
31
+ * aliases object — advisory only; other writers aren't covered. A lock left
32
+ * behind by a crashed process goes stale after 5s and is broken.
33
+ * @param {() => void} fn the mutation to run while holding the lock
34
+ */
35
+ function withAliasLock(fn) {
36
+ const lockDir = `${getConf().path}.aliases.lock`
37
+
38
+ for (let attempt = 0; attempt <= LOCK_RETRIES; attempt++) {
39
+ try {
40
+ fs.mkdirSync(lockDir)
41
+ try {
42
+ fn()
43
+ return
44
+ } finally {
45
+ releaseLock(lockDir)
46
+ }
47
+ } catch (err) {
48
+ if (err?.code !== 'EEXIST') {
49
+ // Not contention — the config dir itself is unusable. Name the real
50
+ // problem instead of leaking the lock implementation detail.
51
+ throw new CliError(
52
+ `Cannot update aliases: the config directory is not writable (${err?.code ?? err?.message})`,
53
+ { exitCode: 78 },
54
+ )
55
+ }
56
+ // Held by someone else — break it if stale, otherwise wait and retry.
57
+ let stale
58
+ try {
59
+ stale = Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS
60
+ } catch {
61
+ continue // lock vanished between mkdir and stat — retry immediately
62
+ }
63
+ if (stale) {
64
+ fs.rmdirSync(lockDir) // a real failure here must surface, not retry
65
+ continue
66
+ }
67
+ if (attempt < LOCK_RETRIES) sleepSync(LOCK_RETRY_MS)
68
+ }
69
+ }
70
+
71
+ throw new CliError(
72
+ 'another pdcli process is updating aliases — retry in a moment',
73
+ { exitCode: 75 },
74
+ )
75
+ }
76
+
77
+ export function getAliases() {
78
+ return getConf().get('aliases') ?? {}
79
+ }
80
+
81
+ /**
82
+ * @param {string} name
83
+ * @returns {string | undefined}
84
+ */
85
+ export function getAlias(name) {
86
+ return getAliases()[name]
87
+ }
88
+
89
+ /**
90
+ * Write the whole `aliases` object back with a literal key, rather than a
91
+ * dotted-path write. conf splits `set('aliases.<name>', …)` on every '.', so a
92
+ * dotted-path write would corrupt any name containing a dot (store/read
93
+ * mismatch). Mutating the object and writing it whole keeps odd names flat.
94
+ * The read-modify-write runs under an advisory lock so concurrent pdcli
95
+ * processes can't clobber each other.
96
+ * @param {string} name
97
+ * @param {string} command
98
+ */
99
+ export function setAlias(name, command) {
100
+ withAliasLock(() => {
101
+ const aliases = { ...getAliases(), [name]: command }
102
+ getConf().set('aliases', aliases)
103
+ })
104
+ }
105
+
106
+ /**
107
+ * @param {string} name
108
+ */
109
+ export function unsetAlias(name) {
110
+ withAliasLock(() => {
111
+ const aliases = { ...getAliases() }
112
+ delete aliases[name]
113
+ getConf().set('aliases', aliases)
114
+ })
115
+ }