diff --git a/src/cli.ts b/src/cli.ts index 342bf88..415f1a5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { createRequire } from 'module'; import { setGlobalOptions, setGlobalUser } from './lib/output.js'; +import { ExitCode } from './lib/exitCodes.js'; import { loadConfig } from './lib/config.js'; import { registerGitCommands } from './services/git/commands.js'; import { registerJiraCommands } from './services/jira/commands.js'; @@ -63,5 +64,5 @@ Services: program.parseAsync(process.argv).catch((err: unknown) => { process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); + process.exit(ExitCode.GENERAL_ERROR); }); diff --git a/src/lib/exitCodes.ts b/src/lib/exitCodes.ts new file mode 100644 index 0000000..306c5e6 --- /dev/null +++ b/src/lib/exitCodes.ts @@ -0,0 +1,16 @@ +export const ExitCode = { + SUCCESS: 0, + GENERAL_ERROR: 1, + USAGE_ERROR: 2, + NETWORK_ERROR: 69, // EX_UNAVAILABLE from sysexits.h + AUTH_ERROR: 77, // EX_NOPERM from sysexits.h + CONFIG_ERROR: 78, // EX_CONFIG from sysexits.h +} as const; + +export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; + +export function exitCodeFromStatus(httpStatus: number): ExitCode { + if (httpStatus === 401 || httpStatus === 403) return ExitCode.AUTH_ERROR; + if (httpStatus === 0) return ExitCode.NETWORK_ERROR; + return ExitCode.GENERAL_ERROR; +} diff --git a/src/lib/http.ts b/src/lib/http.ts index c7c72da..bec5c50 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -1,5 +1,6 @@ import type { ResolvedConfig } from '../types/config.js'; import { PncliError } from './errors.js'; +import { ExitCode } from './exitCodes.js'; import { log } from './output.js'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -73,8 +74,28 @@ async function request( try { const body = await response.text(); const parsed = JSON.parse(body); - if (parsed.message) message = parsed.message; - else if (parsed.errors?.[0]?.message) message = parsed.errors[0].message; + const parts: string[] = []; + // Jira: errorMessages is string[] + if (Array.isArray(parsed.errorMessages)) { + parts.push(...(parsed.errorMessages as string[]).filter(Boolean)); + } + // errors as object map (Jira: Record) + if (parsed.errors && typeof parsed.errors === 'object' && !Array.isArray(parsed.errors)) { + for (const [field, msg] of Object.entries(parsed.errors as Record)) { + parts.push(`${field}: ${msg}`); + } + } + // errors as array of objects with message field (other APIs) + if (Array.isArray(parsed.errors)) { + for (const e of parsed.errors as Array<{ message?: string }>) { + if (e?.message) parts.push(e.message); + } + } + // Generic APIs: { message: "..." } + if (parts.length === 0 && parsed.message) { + parts.push(String(parsed.message)); + } + if (parts.length > 0) message = parts.join('; '); } catch { // ignore parse errors } @@ -106,7 +127,8 @@ export class HttpClient { return { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json', - 'Accept': 'application/json' + 'Accept': 'application/json', + 'Connection': 'close' }; } @@ -116,7 +138,8 @@ export class HttpClient { return { 'Authorization': `Bearer ${pat}`, 'Content-Type': 'application/json', - 'Accept': 'application/json' + 'Accept': 'application/json', + 'Connection': 'close' }; } @@ -139,7 +162,7 @@ export class HttpClient { const safeHeaders = { ...headers, Authorization: '[REDACTED]' }; process.stderr.write(`DRY RUN: ${init.method} ${url}\nHeaders: ${JSON.stringify(safeHeaders, null, 2)}\n`); if (opts.body) process.stderr.write(`Body: ${JSON.stringify(opts.body, null, 2)}\n`); - process.exit(0); + process.exit(ExitCode.SUCCESS); } return request(url, init, opts.timeoutMs ?? 30000); @@ -164,7 +187,7 @@ export class HttpClient { const safeHeaders = { ...headers, Authorization: '[REDACTED]' }; process.stderr.write(`DRY RUN: ${init.method} ${url}\nHeaders: ${JSON.stringify(safeHeaders, null, 2)}\n`); if (opts.body) process.stderr.write(`Body: ${JSON.stringify(opts.body, null, 2)}\n`); - process.exit(0); + process.exit(ExitCode.SUCCESS); } return request(url, init, opts.timeoutMs ?? 30000); diff --git a/src/lib/output.ts b/src/lib/output.ts index e13999f..b81c2f5 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import type { Meta, SuccessEnvelope, ErrorEnvelope, ErrorDetail } from '../types/common.js'; import { PncliError } from './errors.js'; +import { ExitCode, exitCodeFromStatus } from './exitCodes.js'; let globalOptions = { pretty: false, verbose: false }; let globalUser: { email: string | undefined; userId: string | undefined } = { email: undefined, userId: undefined }; @@ -62,7 +63,8 @@ export function fail( (globalOptions.pretty ? JSON.stringify(envelope, null, 2) : JSON.stringify(envelope)) + '\n' ); - process.exit(1); + const exitCode = err instanceof PncliError ? exitCodeFromStatus(err.status) : ExitCode.GENERAL_ERROR; + process.exit(exitCode); } export function log(message: string): void { diff --git a/src/services/config/commands.ts b/src/services/config/commands.ts index beecf71..a32d0e2 100644 --- a/src/services/config/commands.ts +++ b/src/services/config/commands.ts @@ -9,6 +9,7 @@ import { getGlobalConfigPath } from '../../lib/config.js'; import { success, fail, warn } from '../../lib/output.js'; +import { ExitCode } from '../../lib/exitCodes.js'; import fs from 'fs'; export function registerConfigCommands(program: Command): void { @@ -30,7 +31,7 @@ export function registerConfigCommands(program: Command): void { // Handle prompt cancellation (Ctrl+C) gracefully if (err instanceof Error && err.message.includes('User force closed')) { process.stderr.write('\nSetup cancelled.\n'); - process.exit(1); + process.exit(ExitCode.GENERAL_ERROR); } fail(err, 'config', 'init', start); } @@ -127,7 +128,7 @@ async function initGlobalConfig(start: number): Promise { if (!confirmed) { process.stderr.write('Aborted.\n'); - process.exit(0); + process.exit(ExitCode.SUCCESS); } writeGlobalConfig({ @@ -185,7 +186,7 @@ async function initRepoConfig(start: number): Promise { if (!confirmed) { process.stderr.write('Aborted.\n'); - process.exit(0); + process.exit(ExitCode.SUCCESS); } // Warn if .pncli.json already exists @@ -196,7 +197,7 @@ async function initRepoConfig(start: number): Promise { }); if (!overwrite) { process.stderr.write('Aborted.\n'); - process.exit(0); + process.exit(ExitCode.SUCCESS); } }