From d273b3c1462d4cdd34d80b18042891ef5ef52fd3 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 11 Jun 2026 14:14:22 +0200 Subject: [PATCH 1/4] fix: refresh OAuth token on a post-429 401; null-guard findField - client: the OAuth refresh was gated on attempts===1, so a 429 backoff consumed the slot and a token that expired during the storm never refreshed (unrecoverable 401 mid-batch). Gate on a dedicated `refreshed` flag and make the refresh round free (attempts--), so it neither double-refreshes nor eats the retry budget. - input: findField read d.field_name.toLowerCase() unguarded; add ?. to match lookup.js / upsert.js so a def with no field_name degrades instead of throwing. --- src/lib/client.js | 10 ++++++-- src/lib/input.js | 2 +- test/lib/client.test.js | 54 +++++++++++++++++++++++++++++++++++++++++ test/lib/input.test.js | 6 +++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/lib/client.js b/src/lib/client.js index fc7b5a6..55460f2 100644 --- a/src/lib/client.js +++ b/src/lib/client.js @@ -163,6 +163,7 @@ export function createClient({ const maxAttempts = retry ? 3 : 1 let attempts = 0 let sawRateLimit = false + let refreshed = false while (attempts < maxAttempts) { attempts++ @@ -214,10 +215,15 @@ export function createClient({ ) } - // OAuth access tokens expire (~1h) — refresh once and retry. - if (res.status === 401 && onRefresh && attempts === 1) { + // OAuth access tokens expire (~1h) — refresh once and retry. Gate on a + // dedicated flag, NOT attempts===1: a 429 backoff can consume the early + // attempts, so the expiring-token 401 may not arrive until later. The + // refresh round is free (attempts--) so it never eats the retry budget. + if (res.status === 401 && onRefresh && !refreshed) { debug('401, attempting OAuth token refresh') + refreshed = true token = await onRefresh() + attempts-- continue } diff --git a/src/lib/input.js b/src/lib/input.js index b3357ef..cb59c2e 100644 --- a/src/lib/input.js +++ b/src/lib/input.js @@ -78,7 +78,7 @@ function isHashKey(s) { function findField(defs, name) { const lower = name.toLowerCase() return defs.find( - (d) => d.field_name.toLowerCase() === lower || d.field_code === name, + (d) => d.field_name?.toLowerCase() === lower || d.field_code === name, ) } diff --git a/test/lib/client.test.js b/test/lib/client.test.js index 827ba84..20454d6 100644 --- a/test/lib/client.test.js +++ b/test/lib/client.test.js @@ -693,6 +693,60 @@ describe('OAuth mode', () => { }) expect(onRefresh).toHaveBeenCalledOnce() }) + + it('refreshes when the 401 lands after a 429 backoff (not just on attempt 1)', async () => { + const onRefresh = vi.fn().mockResolvedValue('refreshed-access') + const oauthClient = createClient({ + apiDomain: 'https://acme.pipedrive.com', + token: 'expired-access', + authMode: 'oauth', + onRefresh, + retry: true, + timeout: 5000, + }) + const scope = nock('https://acme.pipedrive.com') + .get('/api/v2/users/me') + .reply(429, '', { 'x-ratelimit-reset': '0' }) + .get('/api/v2/users/me') + .matchHeader('authorization', 'Bearer expired-access') + .reply(401, { success: false, error: 'expired' }) + .get('/api/v2/users/me') + .matchHeader('authorization', 'Bearer refreshed-access') + .reply(200, { success: true, data: { id: 7 } }) + + const result = await oauthClient.get('/api/v2/users/me') + expect(result.data.id).toBe(7) + expect(onRefresh).toHaveBeenCalledOnce() + expect(scope.isDone()).toBe(true) + }) + + it('does not let the refresh round consume the retry budget', async () => { + const onRefresh = vi.fn().mockResolvedValue('refreshed-access') + const oauthClient = createClient({ + apiDomain: 'https://acme.pipedrive.com', + token: 'expired-access', + authMode: 'oauth', + onRefresh, + retry: true, + timeout: 5000, + }) + const scope = nock('https://acme.pipedrive.com') + .get('/api/v2/users/me') + .reply(429, '', { 'x-ratelimit-reset': '0' }) + .get('/api/v2/users/me') + .reply(429, '', { 'x-ratelimit-reset': '0' }) + .get('/api/v2/users/me') + .matchHeader('authorization', 'Bearer expired-access') + .reply(401, { success: false, error: 'expired' }) + .get('/api/v2/users/me') + .matchHeader('authorization', 'Bearer refreshed-access') + .reply(200, { success: true, data: { id: 9 } }) + + const result = await oauthClient.get('/api/v2/users/me') + expect(result.data.id).toBe(9) + expect(onRefresh).toHaveBeenCalledOnce() + expect(scope.isDone()).toBe(true) + }) }) describe('binary download', () => { diff --git a/test/lib/input.test.js b/test/lib/input.test.js index c704d0d..c81cc27 100644 --- a/test/lib/input.test.js +++ b/test/lib/input.test.js @@ -35,6 +35,12 @@ const DEFS = [ ] describe('buildWriteBody', () => { + it('tolerates a field def with no field_name when matching by hash code', () => { + const defs = [{ field_code: HASH, field_type: 'varchar' }] // no field_name + const body = buildWriteBody({ fields: [`${HASH}=X-9`], defs }) + expect(body.custom_fields).toEqual({ [HASH]: 'X-9' }) + }) + it('keeps defined typed values and drops undefined ones', () => { const body = buildWriteBody({ typed: { title: 'New deal', value: 100, stage_id: undefined }, From 1a8773f9adf5994ea0ffc6e8a00daf54061d1711 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 11 Jun 2026 14:14:33 +0200 Subject: [PATCH 2/4] feat(errors)!: usage errors exit 64; piped errors emit JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two breaking changes to the machine-facing contract, landed before the 1.0 freeze: - oclif parse/usage errors (unknown flag, missing arg, bad enum) exited 70 (EX_SOFTWARE, an internal bug) — they now exit 64 (EX_USAGE) per spec §9. Detected by oclif.exit===2 with no exitCode of ours; genuine internal throws still exit 70. - Error output now mirrors success output: explicit --output, else profile default, else JSON when piped / human in a TTY. Previously errors stayed human-text unless JSON was explicitly requested, so a piped consumer got JSON on success and unparseable text on failure. Now stderr carries the {error, message, exitCode, ...} envelope whenever success would be JSON. BREAKING CHANGE: piped invocations now emit JSON error payloads on stderr (was human text); oclif usage errors exit 64 instead of 70. Tests now model an interactive TTY by default (test/setup.js); piped/machine behavior is opted into per-test. This keeps the existing rejects.toThrow(msg) error assertions valid under the new piped-JSON behavior. --- src/lib/errors.js | 24 ++++--- test/base-command.test.js | 143 ++++++++++++++++++++++++++++++++------ test/setup.js | 9 +++ vitest.config.js | 1 + 4 files changed, 147 insertions(+), 30 deletions(-) create mode 100644 test/setup.js diff --git a/src/lib/errors.js b/src/lib/errors.js index 791ac33..e85fba1 100644 --- a/src/lib/errors.js +++ b/src/lib/errors.js @@ -84,19 +84,25 @@ export class ApiError extends CliError { * @param {import('@oclif/core').Command} cmd */ export function handleError(err, cmd) { - const exitCode = err.exitCode ?? 70 + // oclif parse/usage errors (unknown flag, missing arg, bad enum) carry + // `oclif.exit === 2` and no `exitCode` of ours — they're a USAGE problem + // (64), not an internal CLI bug (70). Everything else: our exitCode, else 70. + const isUsageError = err.exitCode == null && err.oclif?.exit === 2 + const exitCode = isUsageError ? 64 : (err.exitCode ?? 70) const flags = cmd.flags ?? {} - // JSON errors when the user asked for JSON — via the flag or the - // profile's default_output. (The piped-TTY fallback intentionally does - // NOT apply here; errors stay human unless JSON was requested.) - const format = - flags.output ?? - (typeof cmd.storedDefaultOutput === 'function' + // Error output format MIRRORS success output (resolveFormat): explicit flag, + // else the profile default, else JSON when piped / human ('table') in a TTY. + // A machine consumer that gets JSON on success must get JSON on failure too; + // only an interactive 'table' context stays human. + const stored = + typeof cmd.storedDefaultOutput === 'function' ? cmd.storedDefaultOutput() - : undefined) + : undefined + const format = + flags.output ?? stored ?? (process.stdout.isTTY ? 'table' : 'json') - if (format === 'json') { + if (format !== 'table') { const payload = { error: err.constructor.name, message: err.message, diff --git a/test/base-command.test.js b/test/base-command.test.js index a4fed47..1e93efb 100644 --- a/test/base-command.test.js +++ b/test/base-command.test.js @@ -115,7 +115,13 @@ describe('BaseCommand', () => { it('propagates AuthRequiredError when credentials are missing', async () => { mockResolveCredentials.mockRejectedValue(new AuthRequiredError()) - await expect(captureLogs(ApiCmd)).rejects.toThrow('Not authenticated') + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true // interactive → human error carries the message + try { + await expect(captureLogs(ApiCmd)).rejects.toThrow('Not authenticated') + } finally { + process.stdout.isTTY = origIsTTY + } }) it('defaults to json output when not TTY', async () => { @@ -159,9 +165,15 @@ describe('BaseCommand', () => { .get('/api/v2/users/me') .reply(422, { success: false, error: 'Validation failed' }) - await expect(captureLogs(ApiCmd)).rejects.toThrow( - 'Pipedrive API 422: Validation failed', - ) + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true // interactive → human error carries the message + try { + await expect(captureLogs(ApiCmd)).rejects.toThrow( + 'Pipedrive API 422: Validation failed', + ) + } finally { + process.stdout.isTTY = origIsTTY + } }) it('sends a pdcli User-Agent on API requests', async () => { @@ -185,9 +197,15 @@ describe('BaseCommand', () => { .get('/api/v2/users/me') .reply(429, '', { 'x-ratelimit-reset': '9' }) - await expect(captureLogs(ApiCmd, ['--no-retry'])).rejects.toThrow( - /Rate limited/, - ) + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true // interactive → human error carries the message + try { + await expect(captureLogs(ApiCmd, ['--no-retry'])).rejects.toThrow( + /Rate limited/, + ) + } finally { + process.stdout.isTTY = origIsTTY + } }) it('defines a --resolve-fields global flag for non-table formats', () => { @@ -410,9 +428,14 @@ describe('resolveFormat with default_output', () => { activeProfile: 'default', default_output: 'bogus', }) - const stdout = await captureLogs(FormatCmd, []) - // vitest runs piped (non-TTY), so the fallback is json - expect(stdout).toContain('format:json') + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = false // piped → the fallback is json + try { + const stdout = await captureLogs(FormatCmd, []) + expect(stdout).toContain('format:json') + } finally { + process.stdout.isTTY = origIsTTY + } }) it('emits JSON errors when default_output is json (no --output flag)', async () => { @@ -448,12 +471,52 @@ describe('resolveFormat with default_output', () => { // Before the fix this died with "Cannot read properties of undefined // (reading 'profile')" because handleError consulted // storedDefaultOutput() while this.flags was still undefined. - await expect(ParseCmd.run(['--no-such-flag'])).rejects.toThrow( - /Nonexistent flag/, - ) + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true // interactive → human error carries the message + try { + await expect(ParseCmd.run(['--no-such-flag'])).rejects.toThrow( + /Nonexistent flag/, + ) + } finally { + process.stdout.isTTY = origIsTTY + } }) - it('keeps human-form errors when no flag and no stored default', async () => { + it('maps oclif parse errors to exit 64 (usage), not 70 (internal)', async () => { + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + class ParseCmd extends BaseCommand { + static skipAuth = true + async run() {} + } + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true // human path + const err = await ParseCmd.run(['--no-such-flag']).catch((e) => e) + process.stdout.isTTY = origIsTTY + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('emits a JSON parse error (exit 64) when piped', async () => { + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + class ParseCmd extends BaseCommand { + static skipAuth = true + async run() {} + } + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = false // piped + const writes = [] + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) + await ParseCmd.run(['--no-such-flag']).catch(() => {}) + spy.mockRestore() + process.stdout.isTTY = origIsTTY + const payload = JSON.parse(writes.join('')) + expect(payload.exitCode).toBe(64) + expect(payload.message).toMatch(/nonexistent flag/i) + }) + + it('emits JSON errors when piped, even with no flag or stored default', async () => { mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) class FailCmd extends BaseCommand { static skipAuth = true @@ -461,16 +524,54 @@ describe('resolveFormat with default_output', () => { throw new AuthRequiredError() } } + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = false // piped → machine consumer const writes = [] - const spy = vi - .spyOn(process.stderr, 'write') - .mockImplementation((chunk) => { - writes.push(String(chunk)) - return true - }) - // piped (non-TTY) but no explicit intent → human error, not JSON + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) + await expect(FailCmd.run([])).rejects.toThrow() + spy.mockRestore() + process.stdout.isTTY = origIsTTY + const payload = JSON.parse(writes.join('')) + expect(payload.error).toBe('AuthRequiredError') + expect(payload.exitCode).toBe(77) + }) + + it('keeps human (non-JSON) errors in a TTY with no flag or stored default', async () => { + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + class FailCmd extends BaseCommand { + static skipAuth = true + async run() { + throw new AuthRequiredError() + } + } + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true // interactive terminal + const writes = [] + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) await expect(FailCmd.run([])).rejects.toThrow(/auth login/i) spy.mockRestore() + process.stdout.isTTY = origIsTTY expect(() => JSON.parse(writes.join(''))).toThrow() }) + + it('keeps a genuine internal error at exit 70', async () => { + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + class BoomCmd extends BaseCommand { + static skipAuth = true + async run() { + throw new Error('unexpected boom') + } + } + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true + const err = await BoomCmd.run([]).catch((e) => e) + process.stdout.isTTY = origIsTTY + expect(err.exitCode ?? err.oclif?.exit).toBe(70) + }) }) diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..b855bc4 --- /dev/null +++ b/test/setup.js @@ -0,0 +1,9 @@ +// Tests model an INTERACTIVE terminal by default: human-readable output and +// human-readable (thrown-with-message) errors. Tests that exercise piped / +// machine behavior opt in explicitly by setting `process.stdout.isTTY = false` +// (success → JSON) — they already do. This keeps the large body of +// `rejects.toThrow(/message/)` error-path assertions valid now that piped +// errors emit JSON instead of a human line (v0.17 contract). +beforeEach(() => { + process.stdout.isTTY = true +}) diff --git a/vitest.config.js b/vitest.config.js index 21b18bb..e726b42 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -4,6 +4,7 @@ export default defineConfig({ test: { globals: true, include: ['test/**/*.test.js'], + setupFiles: ['test/setup.js'], coverage: { include: ['src/**/*.js'], exclude: ['src/hooks/**'], From c7753fefcad3e4264fa61115d99d106aec23c2fb Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 11 Jun 2026 14:29:58 +0200 Subject: [PATCH 3/4] fix: contrarian-round findings for v0.17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base-command: storedDefaultOutput() now swallows a throwing config read (returns undefined). handleError consults it while reporting another failure, so a corrupt/unreadable config must never crash error reporting itself. - errors: clarify that any non-table format (json/yaml/csv or piped) emits the JSON error envelope — there is no yaml/csv error serializer; only an interactive table context stays human. Added tests for yaml/csv defaults. - client: lock the retry=false + OAuth 401 path — a refresh still happens once and retries with the fresh token even under --no-retry (the refresh round is free; it is re-auth, not a load retry). - test setup: restore process.stdout.isTTY after each test to prevent leakage. Dismissed the suggested attempts=maxAttempts change: it would reintroduce the pre-v0.17 bug where retry=false + 401 refreshed but never retried (threw 69). --- src/base-command.js | 9 ++++++++- src/lib/errors.js | 10 ++++++---- test/base-command.test.js | 36 ++++++++++++++++++++++++++++++++++++ test/lib/client.test.js | 24 ++++++++++++++++++++++++ test/setup.js | 6 ++++++ 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/base-command.js b/src/base-command.js index 2287d58..2f521f9 100644 --- a/src/base-command.js +++ b/src/base-command.js @@ -130,7 +130,14 @@ export default class BaseCommand extends Command { * when `this.flags` is still undefined). */ storedDefaultOutput() { - const stored = loadConfig(this.flags?.profile).default_output + let stored + try { + stored = loadConfig(this.flags?.profile).default_output + } catch { + // The error handler consults this while reporting another failure — a + // broken/unreadable config must never crash error reporting itself. + return undefined + } return ['table', 'json', 'yaml', 'csv'].includes(stored) ? stored : undefined diff --git a/src/lib/errors.js b/src/lib/errors.js index e85fba1..3da27e7 100644 --- a/src/lib/errors.js +++ b/src/lib/errors.js @@ -91,10 +91,12 @@ export function handleError(err, cmd) { const exitCode = isUsageError ? 64 : (err.exitCode ?? 70) const flags = cmd.flags ?? {} - // Error output format MIRRORS success output (resolveFormat): explicit flag, - // else the profile default, else JSON when piped / human ('table') in a TTY. - // A machine consumer that gets JSON on success must get JSON on failure too; - // only an interactive 'table' context stays human. + // Resolve the format like success output (resolveFormat): explicit flag, + // else the profile default, else JSON when piped / 'table' in a TTY. Any + // non-table format (json/yaml/csv, or piped) gets the JSON error envelope — + // there is no yaml/csv error serializer, and a machine consumer that gets + // JSON on success must get a parseable error on failure. Only an interactive + // 'table' context stays human. const stored = typeof cmd.storedDefaultOutput === 'function' ? cmd.storedDefaultOutput() diff --git a/test/base-command.test.js b/test/base-command.test.js index 1e93efb..f4bd432 100644 --- a/test/base-command.test.js +++ b/test/base-command.test.js @@ -574,4 +574,40 @@ describe('resolveFormat with default_output', () => { process.stdout.isTTY = origIsTTY expect(err.exitCode ?? err.oclif?.exit).toBe(70) }) + + it.each(['yaml', 'csv'])( + 'emits a JSON error envelope (not %s) for a non-table default_output', + async (fmt) => { + mockLoadConfig.mockReturnValue({ + activeProfile: 'default', + default_output: fmt, + }) + class FailCmd extends BaseCommand { + static skipAuth = true + async run() { + throw new AuthRequiredError() + } + } + const writes = [] + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => { + writes.push(String(c)) + return true + }) + await expect(FailCmd.run([])).rejects.toThrow() + spy.mockRestore() + const payload = JSON.parse(writes.join('')) + expect(payload.error).toBe('AuthRequiredError') + expect(payload.exitCode).toBe(77) + }, + ) + + it('storedDefaultOutput swallows a throwing config read (error reporting must not crash)', () => { + mockLoadConfig.mockImplementation(() => { + throw new Error('corrupt config') + }) + // Called by handleError while reporting another failure — must degrade to + // undefined (→ TTY/pipe fallback), never throw a secondary error. + const result = BaseCommand.prototype.storedDefaultOutput.call({ flags: {} }) + expect(result).toBeUndefined() + }) }) diff --git a/test/lib/client.test.js b/test/lib/client.test.js index 20454d6..8cb3dbe 100644 --- a/test/lib/client.test.js +++ b/test/lib/client.test.js @@ -720,6 +720,30 @@ describe('OAuth mode', () => { expect(scope.isDone()).toBe(true) }) + it('still refreshes once on a 401 even with retry disabled (--no-retry)', async () => { + const onRefresh = vi.fn().mockResolvedValue('refreshed-access') + const oauthClient = createClient({ + apiDomain: 'https://acme.pipedrive.com', + token: 'expired-access', + authMode: 'oauth', + onRefresh, + retry: false, + timeout: 5000, + }) + const scope = nock('https://acme.pipedrive.com') + .get('/api/v2/users/me') + .matchHeader('authorization', 'Bearer expired-access') + .reply(401, { success: false, error: 'expired' }) + .get('/api/v2/users/me') + .matchHeader('authorization', 'Bearer refreshed-access') + .reply(200, { success: true, data: { id: 42 } }) + + const result = await oauthClient.get('/api/v2/users/me') + expect(result.data.id).toBe(42) + expect(onRefresh).toHaveBeenCalledOnce() + expect(scope.isDone()).toBe(true) + }) + it('does not let the refresh round consume the retry budget', async () => { const onRefresh = vi.fn().mockResolvedValue('refreshed-access') const oauthClient = createClient({ diff --git a/test/setup.js b/test/setup.js index b855bc4..2c6fccf 100644 --- a/test/setup.js +++ b/test/setup.js @@ -7,3 +7,9 @@ beforeEach(() => { process.stdout.isTTY = true }) + +// Restore to the original (undefined) between tests so a test that flips to +// piped without restoring can't leak its setting into the next file/test. +afterEach(() => { + process.stdout.isTTY = undefined +}) From dfdfe90e4475441d6d16b2c425fc665935cdf211 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 11 Jun 2026 14:31:29 +0200 Subject: [PATCH 4/4] docs: cover v0.17 contract hardening; release 0.17.0 CHANGELOG 0.17.0 with the two BREAKING notes (usage errors exit 64; piped errors emit JSON) + the OAuth/null-guard/error-reporting fixes. agents page: corrected exit-code table (usage/bad-flags = 64, not 70) and the error-JSON note now covers piped + yaml/csv defaults, not just --output json. Version bump to 0.17.0; regenerated command reference + cli-stats. --- CHANGELOG.md | 32 +++++++++++++++++++ docs/commands.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- .../src/content/docs/reference/commands.mdx | 2 +- website/src/content/docs/start/agents.mdx | 9 ++++-- website/src/data/cli-stats.json | 2 +- 7 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5490b89..1b366ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ All notable changes to `pdcli` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). +## [0.17.0] - 2026-06-11 + +Contract-hardening release — the last changes to the machine-facing surface +(exit codes + error output) before the 1.0 stability freeze. + +### Changed + +- **BREAKING — usage errors now exit 64, not 70.** Malformed invocations + (unknown flag, missing argument, invalid flag value) are oclif parse errors; + they previously fell through to exit 70 (`EX_SOFTWARE`, "internal bug") and + now correctly exit 64 (`EX_USAGE`) per the sysexits ladder. Exit 70 is now + reserved for genuinely unexpected internal failures. Scripts branching on the + exit code should treat 64 as "fix your command line". +- **BREAKING — piped errors emit JSON.** Error output now follows the same + format resolution as success output: human (`table`) in a TTY, but a JSON + envelope (`{ error, message, exitCode, … }`) on stderr whenever output is a + machine format — `--output json|yaml|csv`, a non-`table` profile + `default_output`, **or when stdout is piped**. Previously a piped run emitted + JSON on success but human text on failure; a non-interactive consumer now + always gets a parseable error. Pass `--output table` to force human errors. + +### Fixed + +- OAuth: a token that expired _during_ a 429 backoff is now refreshed. The + refresh was gated on the first attempt, so a rate-limit retry consumed the + window and the later 401 failed unrecoverably; the refresh is now a single + free round independent of the retry budget (works under `--no-retry` too). +- CSV/`--field`: a field definition missing `field_name` no longer throws when + matching by hash code (null-guarded, matching the other resolvers). +- Error reporting no longer crashes if reading the profile's `default_output` + throws (e.g. a corrupt config) while formatting another error. + ## [0.16.0] - 2026-06-11 ### Added diff --git a/docs/commands.md b/docs/commands.md index 7d533f2..a9d59c3 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.16.0 (145 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.17.0 (145 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`. ## Top-level diff --git a/package-lock.json b/package-lock.json index bcf3953..bb4271e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wavyx/pdcli", - "version": "0.16.0", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wavyx/pdcli", - "version": "0.16.0", + "version": "0.17.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "8.5.0", diff --git a/package.json b/package.json index 90054cb..4d33bd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wavyx/pdcli", - "version": "0.16.0", + "version": "0.17.0", "publishConfig": { "access": "public" }, diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index 57ec8b8..d6b4684 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 145 commands in `pdcli` v0.16.0. Every command also +All 145 commands in `pdcli` v0.17.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. diff --git a/website/src/content/docs/start/agents.mdx b/website/src/content/docs/start/agents.mdx index 97187ee..6cc3716 100644 --- a/website/src/content/docs/start/agents.mdx +++ b/website/src/content/docs/start/agents.mdx @@ -44,15 +44,18 @@ script can react to the failure class without parsing text: | ---- | ------------------------------------------------------------------------------------------------- | | 0 | Success | | 1 | Generic error | -| 64 | Invalid input data (e.g. a CSV missing its name column) — note: malformed flags/arguments exit 70 | +| 64 | Usage / bad flags or arguments (unknown flag, missing arg, invalid value, missing CSV column) | | 65 | Bad input data (API 400 / 422) | | 69 | Service unavailable (API 5xx, or unreachable) | -| 70 | Internal software error (unexpected) | +| 70 | Internal software error (unexpected — a genuine bug, not a usage mistake) | | 75 | Rate limited (API 429) — retry later | | 77 | Not authenticated / forbidden (API 401 / 403) | | 78 | Configuration error (e.g. API 402, missing domain) | -With `--output json`, errors also print a JSON object on **stderr**: +Errors print a JSON object on **stderr** whenever output is JSON — with +`--output json`, a `json` profile default, **or when stdout is piped** (the same +TTY→JSON rule as success output), so a non-interactive consumer always gets a +parseable failure: ```json { diff --git a/website/src/data/cli-stats.json b/website/src/data/cli-stats.json index c868b3c..e9f6253 100644 --- a/website/src/data/cli-stats.json +++ b/website/src/data/cli-stats.json @@ -1,5 +1,5 @@ { - "version": "0.16.0", + "version": "0.17.0", "commands": 145, "topics": 26, "formats": 4