Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 52 additions & 20 deletions src/commands/integration/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ConnectBody, { type: 'axiom' }>
): Promise<typeof BACK | Integration> {
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<AxiomRegion>(
{ 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',
Expand Down Expand Up @@ -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,
Expand All @@ -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 = '';
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -729,7 +761,7 @@ export const integrationConnectCommand: Command = {
type: 'string',
},
{ flag: '--site <site>', description: 'Datadog site (e.g. us5.datadoghq.com)', type: 'string' },
{ flag: '--region <region>', description: 'Honeycomb (us|eu) or Axiom (us-east-1|eu-central-1)', type: 'string' },
{ flag: '--region <region>', description: 'Honeycomb (us|eu) or Axiom (us-east-1|eu-central-1; detected from the token if omitted)', type: 'string' },
{ flag: '--api-key <key>', description: 'API key (Datadog / Honeycomb / Devin / Cursor / Factory / Conductor)', type: 'string' },
{ flag: '--app-key <key>', description: 'App key (Datadog only)', type: 'string' },
{ flag: '--management-api-key-id <id>', description: 'Management API key ID (Honeycomb)', type: 'string' },
Expand All @@ -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"',
Expand Down
44 changes: 33 additions & 11 deletions src/errors/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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);
}
}
7 changes: 7 additions & 0 deletions test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
66 changes: 66 additions & 0 deletions test/integration-connect-axiom.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): 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
);
});
});
Loading