diff --git a/src/cli.ts b/src/cli.ts index fac6bc6..241cb53 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { API_URL_OPT, CONFIG_OPT, DEFAULT_NAMESPACE, + EXTRA_HEADERS, EXTRACTOR, FILE_PATTERNS, FORMAT_OPT, @@ -150,6 +151,7 @@ const preHandler = (config: Schema) => : config.projectId !== undefined ? Number(config.projectId) : undefined, + extraHeaders: opts.extraHeaders, }); cmd.setOptionValue('client', client); @@ -182,6 +184,7 @@ async function run() { program.addOption(CONFIG_OPT); program.addOption(API_URL_OPT.default(config.apiUrl ?? DEFAULT_API_URL)); program.addOption(API_KEY_OPT.default(config.apiKey)); + program.addOption(EXTRA_HEADERS); program.addOption(PROJECT_ID_OPT.default(config.projectId ?? -1)); program.addOption(PROJECT_BRANCH.default(config.branch)); program.addOption(FORMAT_OPT.default(config.format ?? 'JSON_TOLGEE')); diff --git a/src/client/ApiClient.ts b/src/client/ApiClient.ts index 1b23fbd..a0ca39e 100644 --- a/src/client/ApiClient.ts +++ b/src/client/ApiClient.ts @@ -55,6 +55,7 @@ export type ApiClientProps = { apiKey?: string; projectId?: number | undefined; autoThrow?: boolean; + extraHeaders?: Record; }; export function createApiClient({ @@ -62,12 +63,14 @@ export function createApiClient({ apiKey, projectId, autoThrow = false, + extraHeaders, }: ApiClientProps) { const computedProjectId = projectId ?? (apiKey ? projectIdFromKey(apiKey) : undefined); const apiClient = createClient({ baseUrl, headers: { + ...extraHeaders, 'user-agent': USER_AGENT, 'x-api-key': apiKey, }, @@ -110,7 +113,7 @@ export function createApiClient({ return getApiKeyInformation(apiClient, apiKey!); }, getSettings(): ApiClientProps { - return { baseUrl, apiKey, projectId, autoThrow }; + return { baseUrl, apiKey, projectId, autoThrow, extraHeaders }; }, }; } diff --git a/src/options.ts b/src/options.ts index 7708c33..7a98552 100644 --- a/src/options.ts +++ b/src/options.ts @@ -3,6 +3,7 @@ import { resolve } from 'path'; import { Option, InvalidArgumentError } from 'commander'; import { createTolgeeClient } from './client/TolgeeClient.js'; import { VerboseOption } from './extractor/index.js'; +import { parseExtraHeadersArg } from './utils/extraHeaders.js'; function parseProjectId(v: string) { const val = Number(v); @@ -39,6 +40,7 @@ export type BaseOptions = { patterns: string[] | undefined; strictNamespace: boolean | undefined; verbose: VerboseOption[] | boolean | undefined; + extraHeaders?: Record; }; export const API_KEY_OPT = new Option( @@ -141,3 +143,10 @@ export const VERBOSE = new Option( '-v, --verbose [rules...]', 'Enable verbose logging. If you want more info to be logged pass an option.' ).choices(['extractor']); + +export const EXTRA_HEADERS = new Option( + '--extra-headers ', + "Additional HTTP headers to send with every Tolgee API request. Accepts comma-separated Name=Value pairs (e.g. 'X-Foo=bar,X-Baz=qux'). Useful for traversing Cloudflare Access, WAFs, or other gateways." +) + .env('TOLGEE_EXTRA_HEADERS') + .argParser(parseExtraHeadersArg); diff --git a/src/utils/extraHeaders.ts b/src/utils/extraHeaders.ts new file mode 100644 index 0000000..f5b1ae6 --- /dev/null +++ b/src/utils/extraHeaders.ts @@ -0,0 +1,20 @@ +import { InvalidArgumentError } from 'commander'; + +export function parseExtraHeadersArg(value: string): Record { + const trimmed = value.trim(); + if (!trimmed) return {}; + + const out: Record = {}; + for (const pair of trimmed.split(',')) { + const segment = pair.trim(); + if (!segment) continue; + const idx = segment.indexOf('='); + if (idx <= 0) { + throw new InvalidArgumentError( + `Invalid extra header "${segment}". Use Name=Value.` + ); + } + out[segment.slice(0, idx).trim()] = segment.slice(idx + 1).trim(); + } + return out; +} diff --git a/test/unit/extraHeaders.test.ts b/test/unit/extraHeaders.test.ts new file mode 100644 index 0000000..0f2b304 --- /dev/null +++ b/test/unit/extraHeaders.test.ts @@ -0,0 +1,39 @@ +import { parseExtraHeadersArg } from '#cli/utils/extraHeaders.js'; + +describe('parseExtraHeadersArg', () => { + it('parses comma-separated Name=Value pairs', () => { + expect(parseExtraHeadersArg('X-Foo=bar, X-Baz=qux')).toEqual({ + 'X-Foo': 'bar', + 'X-Baz': 'qux', + }); + }); + + it('trims whitespace around names and values', () => { + expect(parseExtraHeadersArg(' X-Foo = bar ')).toEqual({ + 'X-Foo': 'bar', + }); + }); + + it('keeps "=" characters inside the value', () => { + expect(parseExtraHeadersArg('X-Token=abc=def=ghi')).toEqual({ + 'X-Token': 'abc=def=ghi', + }); + }); + + it('skips empty segments', () => { + expect(parseExtraHeadersArg('X-Foo=bar,,X-Baz=qux,')).toEqual({ + 'X-Foo': 'bar', + 'X-Baz': 'qux', + }); + }); + + it('returns empty for empty input', () => { + expect(parseExtraHeadersArg('')).toEqual({}); + expect(parseExtraHeadersArg(' ')).toEqual({}); + }); + + it('rejects malformed pairs', () => { + expect(() => parseExtraHeadersArg('not-a-header')).toThrow(); + expect(() => parseExtraHeadersArg('=missing-name')).toThrow(); + }); +});