diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 14dd6c5..70b9b84 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -19,6 +19,7 @@ import { type WizardStep, } from '../helpers'; import type { Integration } from '../../generated/types'; +import { isApiError } from '../../errors/api'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { openBrowser } from '../../utils/browser'; @@ -136,6 +137,44 @@ function datadogConsoleUrl(site: string): string { return appPrefixed ? `https://app.${site}` : `https://${site}`; } +// The backend detects the Axiom edge deployment region from the API token and +// answers 422 when it cannot; --region is only an explicit override. +type AxiomRegion = 'us-east-1' | 'eu-central-1'; + +const AXIOM_REGIONS: Array<{ value: AxiomRegion; label: string }> = [ + { value: 'us-east-1', label: 'US East 1' }, + { value: 'eu-central-1', label: 'EU Central 1' }, +]; + +const AXIOM_REGION_HINT = 'In Axiom: Settings > General > Edge deployment (https://app.axiom.co/settings/general)'; + +export async function connectAxiom( + config: Config, + api: PolylaneAPI, + body: Extract +): Promise { + try { + return await api.integrationsConnect(body); + } catch (err) { + if (!isApiError(err) || err.status !== 422 || body.region !== undefined) throw err; + if (!isInteractive(config.nonInteractive)) { + throw new CLIError( + err.message, + ExitCode.USAGE, + `Pass --region us-east-1 or --region eu-central-1.\n${AXIOM_REGION_HINT}` + ); + } + note(`${err.message}\n${AXIOM_REGION_HINT}`, 'Axiom region'); + const picked = await promptSelectOrBack( + { nonInteractive: config.nonInteractive }, + 'Axiom edge deployment region', + AXIOM_REGIONS + ); + if (picked === BACK) return BACK; + return api.integrationsConnect({ ...body, region: picked }); + } +} + const CODE_AGENTS = { devin: { name: 'Devin', @@ -555,24 +594,16 @@ async function connectWithCredentials( ...honeycombManagementKeyFields(managementApiKeyId, managementApiKeySecret), }; } else if (type === 'axiom') { - let region: 'us-east-1' | 'eu-central-1' = 'us-east-1'; + const region = getArgString(args, 'region'); + if (region !== undefined && !AXIOM_REGIONS.some((o) => o.value === region)) { + throw new CLIError( + `Invalid value for --region: "${region}"`, + ExitCode.USAGE, + `Use one of: ${AXIOM_REGIONS.map((o) => o.value).join(', ')}` + ); + } let apiToken = ''; const ok = await runSteps([ - choiceStep<'us-east-1' | 'eu-central-1'>( - config, - args, - 'region', - '--region', - 'Axiom edge deployment region: see your organization settings (https://app.axiom.co/settings/org)', - [ - { value: 'us-east-1', label: 'US East 1' }, - { value: 'eu-central-1', label: 'EU Central 1' }, - ], - (v) => { - region = v; - }, - { strict: true } - ), secretStep( config, args, @@ -591,7 +622,7 @@ async function connectWithCredentials( ), ]); if (!ok) return BACK; - body = { type: 'axiom', workspaceId, region, apiToken }; + body = { type: 'axiom', workspaceId, apiToken, ...(region !== undefined ? { region: region as AxiomRegion } : {}) }; } else if (type === 'betterstack') { let apiToken = ''; let uptimeApiToken = ''; @@ -672,7 +703,8 @@ async function connectWithCredentials( body = { type, workspaceId, apiKey }; } - const integration = await api.integrationsConnect(body); + const integration = body.type === 'axiom' ? await connectAxiom(config, api, body) : await api.integrationsConnect(body); + if (integration === BACK) return BACK; if (type === 'honeycomb') assertHoneycombManagementKeyStored(integration); printConnectSuccess(config, integration, TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type); if (isCodeAgentType(integration.type) && !config.quiet && config.output !== 'json') { @@ -729,7 +761,7 @@ export const integrationConnectCommand: Command = { type: 'string', }, { flag: '--site ', description: 'Datadog site (e.g. us5.datadoghq.com)', type: 'string' }, - { flag: '--region ', description: 'Honeycomb (us|eu) or Axiom (us-east-1|eu-central-1)', type: 'string' }, + { flag: '--region ', description: 'Honeycomb (us|eu) or Axiom (us-east-1|eu-central-1; detected from the token if omitted)', type: 'string' }, { flag: '--api-key ', description: 'API key (Datadog / Honeycomb / Devin / Cursor / Factory / Conductor)', type: 'string' }, { flag: '--app-key ', description: 'App key (Datadog only)', type: 'string' }, { flag: '--management-api-key-id ', description: 'Management API key ID (Honeycomb)', type: 'string' }, @@ -755,7 +787,7 @@ export const integrationConnectCommand: Command = { 'polylane integration connect --category code-agent', 'polylane integration connect --type datadog --site us5.datadoghq.com --api-key ... --app-key ...', 'polylane integration connect --type honeycomb --region us --api-key ... --management-api-key-id ... --management-api-key-secret ...', - 'polylane integration connect --type axiom --region us-east-1 --api-token ...', + 'polylane integration connect --type axiom --api-token ...', 'polylane integration connect --type betterstack --api-token ... --uptime-api-token ... --telemetry-api-token ...', 'polylane integration connect --type cursor --api-key crsr_...', 'polylane integration connect --type mcp --url https://mcp.example.com/sse --name "My MCP"', diff --git a/src/errors/api.ts b/src/errors/api.ts index 523e4d9..0c2c019 100644 --- a/src/errors/api.ts +++ b/src/errors/api.ts @@ -6,43 +6,64 @@ export interface ApiErrorPayload { detail?: string; } -export function mapApiError(status: number, error: ApiErrorPayload | null): CLIError { +export class ApiError extends CLIError { + readonly status: number; + + constructor(status: number, message: string, exitCode: ExitCode = ExitCode.GENERAL, hint?: string) { + super(message, exitCode, hint); + this.name = 'ApiError'; + this.status = status; + } +} + +export function isApiError(err: unknown): err is ApiError { + return err instanceof ApiError; +} + +export function mapApiError(status: number, error: ApiErrorPayload | null): ApiError { const detail = error?.detail; const message = error?.message ?? `The request did not succeed (${status})`; switch (status) { case 400: - return new CLIError(detail || 'Bad request', ExitCode.USAGE); + return new ApiError(status, detail || 'Bad request', ExitCode.USAGE); case 401: - return new CLIError( + return new ApiError( + status, detail || 'Not signed in.', ExitCode.AUTH, 'Run `polylane auth login`' ); case 403: - return new CLIError( + return new ApiError( + status, detail || 'Permission denied', ExitCode.AUTH, 'Check your API key scopes or workspace permissions' ); case 404: - return new CLIError(detail || 'Resource not found', ExitCode.GENERAL); + return new ApiError(status, detail || 'Resource not found', ExitCode.GENERAL); case 409: - return new CLIError(detail || 'Conflict', ExitCode.GENERAL); + return new ApiError(status, detail || 'Conflict', ExitCode.GENERAL); case 418: - return new CLIError( + return new ApiError( + status, detail || 'Feature not available', ExitCode.GENERAL, 'This feature may be disabled on your plan' ); + case 422: + return new ApiError(status, detail || 'Unprocessable content', ExitCode.USAGE); case 426: - return new CLIError( + return new ApiError( + status, detail || 'Plan upgrade required', ExitCode.QUOTA, 'Upgrade your workspace plan to use this feature' ); case 429: - return new CLIError( + return new ApiError( + status, detail || 'Rate limited', ExitCode.QUOTA, 'Wait a moment and retry' @@ -51,12 +72,13 @@ export function mapApiError(status: number, error: ApiErrorPayload | null): CLIE case 502: case 503: case 504: - return new CLIError( + return new ApiError( + status, detail || 'Server error', ExitCode.GENERAL, 'Try again later' ); default: - return new CLIError(detail || message, ExitCode.GENERAL); + return new ApiError(status, detail || message, ExitCode.GENERAL); } } diff --git a/test/errors.test.ts b/test/errors.test.ts index 602116a..861c738 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -46,6 +46,13 @@ describe('mapApiError', () => { assert.equal(err.message, 'workspace xyz missing'); }); + it('maps 422 -> USAGE with the status preserved', () => { + const err = mapApiError(422, { message: 'Unprocessable content', detail: 'pass region' }); + assert.equal(err.exitCode, ExitCode.USAGE); + assert.equal(err.message, 'pass region'); + assert.equal(err.status, 422); + }); + it('maps 426 -> QUOTA', () => { const err = mapApiError(426, { message: 'Upgrade Required' }); assert.equal(err.exitCode, ExitCode.QUOTA); diff --git a/test/integration-connect-axiom.test.ts b/test/integration-connect-axiom.test.ts new file mode 100644 index 0000000..918ad8a --- /dev/null +++ b/test/integration-connect-axiom.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { connectAxiom } from '../src/commands/integration/connect'; +import { ApiError } from '../src/errors/api'; +import { CLIError } from '../src/errors/base'; +import { ExitCode } from '../src/errors/codes'; +import type { Config } from '../src/config/schema'; +import type { PolylaneAPI } from '../src/generated/client'; +import type { Integration } from '../src/generated/types'; + +const config = { nonInteractive: true } as Config; +const body = { type: 'axiom', workspaceId: 'ws_1', apiToken: 'xaat-token' } as const; + +function mockApi(connect: (body: unknown) => Promise): PolylaneAPI { + return { integrationsConnect: connect } as unknown as PolylaneAPI; +} + +describe('connectAxiom', () => { + it('sends the body without region and returns the integration', async () => { + const seen: unknown[] = []; + const integration = { id: 'int_1', type: 'axiom' } as Integration; + const api = mockApi(async (b) => { + seen.push(b); + return integration; + }); + assert.equal(await connectAxiom(config, api, body), integration); + assert.deepEqual(seen, [body]); + }); + + it('turns a 422 into a usage error with a --region hint when not interactive', async () => { + const api = mockApi(async () => { + throw new ApiError(422, 'Could not detect the region from the token. Pass region.', ExitCode.USAGE); + }); + await assert.rejects( + () => connectAxiom(config, api, body), + (err: unknown) => + err instanceof CLIError && + err.exitCode === ExitCode.USAGE && + err.message.includes('Could not detect the region') && + (err.hint?.includes('--region us-east-1') ?? false) && + (err.hint?.includes('Settings > General > Edge deployment') ?? false) + ); + }); + + it('rethrows a 422 when a region was already sent', async () => { + const original = new ApiError(422, 'Invalid region', ExitCode.USAGE); + const api = mockApi(async () => { + throw original; + }); + await assert.rejects( + () => connectAxiom(config, api, { ...body, region: 'us-east-1' }), + (err: unknown) => err === original + ); + }); + + it('rethrows non-422 errors untouched', async () => { + const original = new ApiError(401, 'Not signed in.', ExitCode.AUTH); + const api = mockApi(async () => { + throw original; + }); + await assert.rejects( + () => connectAxiom(config, api, body), + (err: unknown) => err === original + ); + }); +});