diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 27a953a..14dd6c5 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -16,6 +16,7 @@ import { choiceStep, secretStep, SKIPPED, + type WizardStep, } from '../helpers'; import type { Integration } from '../../generated/types'; import { CLIError } from '../../errors/base'; @@ -30,6 +31,7 @@ import { promptConfirmOrBack, promptSelectOrBack, promptPasswordOrBack, + promptTextOrBack, } from '../../utils/prompt'; type ConnectBody = Parameters[0]; @@ -354,6 +356,75 @@ async function connectMcp( return 'connected'; } +const MANAGEMENT_KEY_PAIR_HINT = 'Pass both --management-api-key-id and --management-api-key-secret.'; + +// The API stores the Management API key only as a pair, and both halves are +// required — a missing (or whitespace-only) half is a usage error before any +// request is sent. Both halves are trimmed here so the flag and prompt paths +// behave identically on pasted values. +export function honeycombManagementKeyFields( + managementApiKeyId: string, + managementApiKeySecret: string +): { managementApiKeyId: string; managementApiKeySecret: string } { + const id = managementApiKeyId.trim(); + const secret = managementApiKeySecret.trim(); + if (!id || !secret) { + const missing = !id ? '--management-api-key-id' : '--management-api-key-secret'; + throw new CLIError(`Missing required flag: ${missing}`, ExitCode.USAGE, MANAGEMENT_KEY_PAIR_HINT); + } + return { managementApiKeyId: id, managementApiKeySecret: secret }; +} + +// Both management-key steps share the same shape: a flag short-circuits, a +// non-interactive run without the flag is a usage error, and only the prompt +// differs. +function managementKeyStep( + config: Config, + args: Record, + key: 'managementApiKeyId' | 'managementApiKeySecret', + flag: string, + prompt: () => Promise, + set: (value: string) => void +): WizardStep { + return async () => { + const fromFlag = getArgString(args, key); + if (fromFlag !== undefined) { + set(fromFlag); + return SKIPPED; + } + if (!isInteractive(config.nonInteractive)) { + throw new CLIError(`Missing required flag: ${flag}`, ExitCode.USAGE, MANAGEMENT_KEY_PAIR_HINT); + } + const value = await prompt(); + if (value === BACK) return BACK; + set(value); + return; + }; +} + +// An API build that predates the management-key fields strips them from the +// connect body and stores the integration without them. The response echoes +// the stored metadata (the key ID survives redaction), so a missing ID there +// means the key was silently dropped — that must fail loudly, not read as a +// successful connect. The generated metadata type gains managementApiKeyId +// only when the matching API deploy lands, so the field is read through a +// structural cast, same trust boundary as the request-body spread. +export function assertHoneycombManagementKeyStored( + integration: Pick +): void { + const metadata = integration.metadata as { managementApiKeyId?: unknown } | null | undefined; + if (metadata != null && typeof metadata.managementApiKeyId === 'string' && metadata.managementApiKeyId !== '') { + return; + } + throw new CLIError( + 'The Polylane API does not support the Honeycomb Management API key yet — the key was not stored', + ExitCode.GENERAL, + 'Honeycomb was connected without query access. Disconnect it, then retry once the API is updated:\n' + + `polylane integration disconnect ${integration.id} --yes` + + (integration._html_url ? `\nor reconnect from the console: ${integration._html_url}` : '') + ); +} + // --- Credential-based connects: each wizard step can go back to the previous // one, and backing out of the first returns BACK to re-open type selection --- async function connectWithCredentials( @@ -410,6 +481,8 @@ async function connectWithCredentials( } else if (type === 'honeycomb') { let region: 'us' | 'eu' = 'us'; let apiKey = ''; + let managementApiKeyId = ''; + let managementApiKeySecret = ''; const ok = await runSteps([ choiceStep<'us' | 'eu'>( config, @@ -442,9 +515,45 @@ async function connectWithCredentials( apiKey = v; } ), + managementKeyStep( + config, + args, + 'managementApiKeyId', + '--management-api-key-id', + () => { + note('Create one in your Honeycomb team settings under API Keys.', 'Management API key'); + return promptTextOrBack({ nonInteractive: config.nonInteractive }, 'Management API key ID', { + validate: (v: string) => (v.trim() ? undefined : 'Required'), + }); + }, + (v) => { + managementApiKeyId = v; + } + ), + managementKeyStep( + config, + args, + 'managementApiKeySecret', + '--management-api-key-secret', + () => promptPasswordOrBack({ nonInteractive: config.nonInteractive }, 'Management API key secret'), + (v) => { + managementApiKeySecret = v; + } + ), ]); if (!ok) return BACK; - body = { type: 'honeycomb', workspaceId, region, apiKey }; + // Spread instead of literal fields: the client is generated from the live + // prod spec, which gains these two fields only when the matching API + // deploy lands. The spread keeps typecheck green on both sides; the old + // API strips unknown keys (caught after connect by + // assertHoneycombManagementKeyStored), the new one validates them. + body = { + type: 'honeycomb', + workspaceId, + region, + apiKey, + ...honeycombManagementKeyFields(managementApiKeyId, managementApiKeySecret), + }; } else if (type === 'axiom') { let region: 'us-east-1' | 'eu-central-1' = 'us-east-1'; let apiToken = ''; @@ -564,6 +673,7 @@ async function connectWithCredentials( } const integration = await api.integrationsConnect(body); + 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') { process.stderr.write(`Connecting ${CODE_AGENTS[integration.type].name} makes it the default autofix executor.\n`); @@ -622,6 +732,8 @@ export const integrationConnectCommand: Command = { { flag: '--region ', description: 'Honeycomb (us|eu) or Axiom (us-east-1|eu-central-1)', 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' }, + { flag: '--management-api-key-secret ', description: 'Management API key secret (Honeycomb)', type: 'string' }, { flag: '--api-token ', description: 'API token (Axiom / Better Stack global token)', type: 'string' }, { flag: '--uptime-api-token ', description: 'Uptime API token (Better Stack only)', type: 'string' }, { flag: '--telemetry-api-token ', description: 'Telemetry API token (Better Stack only)', type: 'string' }, @@ -642,7 +754,7 @@ export const integrationConnectCommand: Command = { 'polylane integration connect --category observability', '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 ...', + '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 betterstack --api-token ... --uptime-api-token ... --telemetry-api-token ...', 'polylane integration connect --type cursor --api-key crsr_...', diff --git a/test/integration-connect-honeycomb.test.ts b/test/integration-connect-honeycomb.test.ts new file mode 100644 index 0000000..c83a683 --- /dev/null +++ b/test/integration-connect-honeycomb.test.ts @@ -0,0 +1,114 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { honeycombManagementKeyFields, assertHoneycombManagementKeyStored } from '../src/commands/integration/connect'; +import { CLIError } from '../src/errors/base'; +import type { Integration } from '../src/generated/types'; + +describe('honeycombManagementKeyFields', () => { + it('returns both fields when both are set', () => { + assert.deepEqual(honeycombManagementKeyFields('hcxik_id', 'secret'), { + managementApiKeyId: 'hcxik_id', + managementApiKeySecret: 'secret', + }); + }); + + it('trims surrounding whitespace from both fields', () => { + assert.deepEqual(honeycombManagementKeyFields(' hcxik_id\n', '\tsecret '), { + managementApiKeyId: 'hcxik_id', + managementApiKeySecret: 'secret', + }); + }); + + it('throws when both are empty', () => { + assert.throws( + () => honeycombManagementKeyFields('', ''), + (err: unknown) => err instanceof CLIError && err.message.includes('--management-api-key-id') + ); + }); + + it('treats whitespace-only values as missing', () => { + assert.throws( + () => honeycombManagementKeyFields(' ', 'secret'), + (err: unknown) => err instanceof CLIError && err.message.includes('--management-api-key-id') + ); + assert.throws( + () => honeycombManagementKeyFields('hcxik_id', ' \n'), + (err: unknown) => err instanceof CLIError && err.message.includes('--management-api-key-secret') + ); + }); + + it('throws when only the ID is set', () => { + assert.throws( + () => honeycombManagementKeyFields('hcxik_id', ''), + (err: unknown) => err instanceof CLIError && err.message.includes('--management-api-key-secret') + ); + }); + + it('throws when only the secret is set', () => { + assert.throws( + () => honeycombManagementKeyFields('', 'secret'), + (err: unknown) => err instanceof CLIError && err.message.includes('--management-api-key-id') + ); + }); +}); + +type ConnectedIntegration = Pick; + +function honeycombIntegration(extra: Record, htmlUrl?: string): ConnectedIntegration { + return { + id: 'integration_test1', + metadata: { + type: 'honeycomb', + region: 'us', + apiKey: '', + teamSlug: 'team', + environmentSlug: 'env', + ...extra, + }, + ...(htmlUrl ? { _html_url: htmlUrl } : {}), + }; +} + +describe('assertHoneycombManagementKeyStored', () => { + it('passes when the response metadata carries the management key ID', () => { + assert.doesNotThrow(() => + assertHoneycombManagementKeyStored(honeycombIntegration({ managementApiKeyId: 'hcxik_id' })) + ); + }); + + it('throws when the API dropped the management key fields', () => { + assert.throws( + () => assertHoneycombManagementKeyStored(honeycombIntegration({})), + (err: unknown) => + err instanceof CLIError && + err.message === + 'The Polylane API does not support the Honeycomb Management API key yet — the key was not stored' && + err.hint !== undefined && + err.hint.includes('polylane integration disconnect integration_test1 --yes') + ); + }); + + it('throws when the stored management key ID is empty', () => { + assert.throws( + () => assertHoneycombManagementKeyStored(honeycombIntegration({ managementApiKeyId: '' })), + (err: unknown) => err instanceof CLIError + ); + }); + + it('throws when the response has no metadata', () => { + assert.throws( + () => assertHoneycombManagementKeyStored({ id: 'integration_test1', metadata: null }), + (err: unknown) => err instanceof CLIError + ); + }); + + it('includes the console link in the hint when the response carries one', () => { + assert.throws( + () => assertHoneycombManagementKeyStored(honeycombIntegration({}, 'https://console.example/integration_test1')), + (err: unknown) => + err instanceof CLIError && + err.hint !== undefined && + err.hint.includes('or reconnect from the console: https://console.example/integration_test1') + ); + }); +});