diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index f66a6ef..37688ab 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -43,6 +43,7 @@ type Provider = | 'vercel' | 'fly' | 'render' + | 'railway' | 'planetscale' | 'supabase' | 'modal' @@ -51,12 +52,13 @@ type Provider = | 'turso' | 'kubernetes'; -const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }> = [ +export const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }> = [ { value: 'aws', label: 'AWS', hint: 'deploys a read-only CloudFormation stack (browser)' }, { value: 'cloudflare', label: 'Cloudflare', hint: 'read-only API token' }, { value: 'vercel', label: 'Vercel', hint: 'install the Vercel integration (browser)' }, { value: 'fly', label: 'Fly.io', hint: 'API token' }, { value: 'render', label: 'Render', hint: 'API key' }, + { value: 'railway', label: 'Railway', hint: 'workspace or account token' }, { value: 'planetscale', label: 'PlanetScale', hint: 'authorize in the browser, or a service token' }, { value: 'supabase', label: 'Supabase', hint: 'authorize in the browser' }, { value: 'modal', label: 'Modal', hint: 'token ID + secret' }, @@ -269,6 +271,18 @@ function printConnectSuccess(config: Config, result: ConnectResult): void { } } +// Explicitly typed literals (no conditional spread) so a misspelled +// railwayWorkspaceId is a TS2561 instead of a silently dropped narrowing. +export function buildRailwayConnectBody( + workspaceId: string, + token: string, + railwayWorkspaceId?: string +): Extract { + return railwayWorkspaceId === undefined + ? { workspaceId, provider: 'railway', token } + : { workspaceId, provider: 'railway', token, railwayWorkspaceId }; +} + const TURSO_ORGANIZATION_HINT = 'List your organizations with `turso org list`, or check the switcher in the Turso dashboard (https://app.turso.tech).'; @@ -643,6 +657,55 @@ async function connectProvider( ]); if (!ok) return BACK; body = { workspaceId, provider: 'render', apiKey }; + } else if (provider === 'railway') { + // The console connects Railway via OAuth; the CLI takes the token path + // the same API accepts, mirroring the Fly branch. + const railwayWorkspaceFlag = getArgString(args, 'railwayWorkspace'); + if (railwayWorkspaceFlag !== undefined && railwayWorkspaceFlag.trim().length === 0) { + throw new CLIError( + 'Invalid value for --railway-workspace: ""', + ExitCode.USAGE, + 'Pass the Railway workspace ID to connect only that workspace, or omit the flag to connect every workspace the token can reach.' + ); + } + let token = ''; + let railwayWorkspaceId = railwayWorkspaceFlag?.trim(); + const ok = await runSteps([ + secretStep( + config, + args, + 'token', + '--token', + { + message: 'Railway token', + instructions: + 'In Railway, open Account Settings > Tokens and create a token. Select your workspace to scope the token to it, or leave it unscoped for an account token that covers every workspace you can access. Railway tokens have no permission options. Polylane connects every workspace the token can reach; the next step (or --railway-workspace) narrows it to one.', + link: 'https://railway.com/account/tokens', + linkLabel: 'Create Railway token', + }, + (v) => { + token = v; + } + ), + async () => { + if (railwayWorkspaceFlag !== undefined || !isInteractive(config.nonInteractive)) return SKIPPED; + const picked = await promptTextOrBack( + { nonInteractive: config.nonInteractive }, + 'Railway workspace ID to connect (leave empty to connect every workspace the token can reach)', + // clack's text prompt resolves to undefined on an empty submit unless + // a defaultValue is given, and empty is the documented "connect every + // workspace" answer here — make it an actual empty string. The + // placeholder doubles as the submitted-frame rendering of that empty + // answer (clack falls back to it, printing "undefined" otherwise). + { defaultValue: '', placeholder: 'connect every workspace' } + ); + if (picked === BACK) return BACK; + railwayWorkspaceId = picked.trim() === '' ? undefined : picked.trim(); + return; + }, + ]); + if (!ok) return BACK; + body = buildRailwayConnectBody(workspaceId, token, railwayWorkspaceId); } else if (provider === 'convex') { let token = ''; const ok = await runSteps([ @@ -777,7 +840,7 @@ async function connectProvider( export const cloudConnectCommand: Command = { name: 'cloud connect', - description: 'Connect a cloud account (AWS, Cloudflare, Vercel, Fly.io, Render, PlanetScale, Supabase, Modal, Convex, ClickHouse, Turso, Kubernetes)', + description: 'Connect a cloud account (AWS, Cloudflare, Vercel, Fly.io, Render, Railway, PlanetScale, Supabase, Modal, Convex, ClickHouse, Turso, Kubernetes)', operationId: 'cloud_accounts.connect', options: [ { @@ -790,8 +853,9 @@ export const cloudConnectCommand: Command = { { flag: '--region ', description: 'AWS regions to scan, comma-separated (e.g. us-east-1,eu-west-1), or "all" for every enabled region', type: 'string' }, { flag: '--create-alarms', description: 'AWS: create monitoring alarms', type: 'boolean' }, { flag: '--subscribe-alarms', description: 'AWS: subscribe to existing CloudWatch alarms', type: 'boolean' }, - // Cloudflare / Fly / PlanetScale / Convex / Turso - { flag: '--token ', description: 'Cloudflare API token, Fly.io token, PlanetScale service token, Convex team access token, or Turso platform API token', type: 'string' }, + // Cloudflare / Fly / Railway / PlanetScale / Convex / Turso + { flag: '--token ', description: 'Cloudflare API token, Fly.io token, Railway token, PlanetScale service token, Convex team access token, or Turso platform API token', type: 'string' }, + { flag: '--railway-workspace ', description: 'Railway: connect only this Railway workspace ID (default: every workspace the token can reach)', type: 'string' }, // Retired in 0.2.16: Cloudflare now always connects read-only, which is // what anyone passing this flag was asking for. Accepted and ignored for // one release so existing scripts do not start exiting 2 on an unknown @@ -817,6 +881,7 @@ export const cloudConnectCommand: Command = { 'polylane cloud connect --provider aws --account 123456789012 --region us-east-1,eu-west-1 --subscribe-alarms', 'polylane cloud connect --provider aws --account 123456789012 --region all', 'polylane cloud connect --provider render --api-key ', + 'polylane cloud connect --provider railway --token ', 'polylane cloud connect --provider supabase', 'polylane cloud connect --provider planetscale --token-id --token --organization ', 'polylane cloud connect --provider modal --token-id ak-... --token-secret as-...', diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index e4e8b27..feec29f 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -46,7 +46,9 @@ type ConnectableType = | 'honeycomb' | 'axiom' | 'betterstack' + | 'openstatus' | 'grafana' + | 'mixpanel' | 'devin' | 'cursor' | 'factory' @@ -57,7 +59,7 @@ type ConnectableType = // Mirrors each type's subcategory in the integrations catalog // (`polylane integration catalog`), so callers like the install script can // narrow the picker to one family of integrations. -export const CONNECT_CATEGORIES = ['git', 'communication', 'observability', 'code-agent', 'issue-tracking', 'protocol'] as const; +export const CONNECT_CATEGORIES = ['git', 'communication', 'observability', 'product-analytics', 'code-agent', 'issue-tracking', 'protocol'] as const; type ConnectCategory = (typeof CONNECT_CATEGORIES)[number]; const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string; category: ConnectCategory }> = [ @@ -68,7 +70,9 @@ const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string; { value: 'honeycomb', label: 'Honeycomb', hint: 'configuration API key', category: 'observability' }, { value: 'axiom', label: 'Axiom', hint: 'API token', category: 'observability' }, { value: 'betterstack', label: 'Better Stack', hint: 'global, Uptime and Telemetry tokens', category: 'observability' }, + { value: 'openstatus', label: 'OpenStatus', hint: 'workspace API key', category: 'observability' }, { value: 'grafana', label: 'Grafana Cloud', hint: 'stack URL + service account token', category: 'observability' }, + { value: 'mixpanel', label: 'Mixpanel', hint: 'service account + project ID', category: 'product-analytics' }, { value: 'devin', label: 'Devin', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'cursor', label: 'Cursor', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'factory', label: 'Factory', hint: 'API key · coding agent', category: 'code-agent' }, @@ -125,6 +129,78 @@ function datadogConsoleUrl(site: string): string { return appPrefixed ? `https://app.${site}` : `https://${site}`; } +// Same region list the console offers, strict because the API only accepts +// these three data-residency values. +export const MIXPANEL_REGIONS = [ + { value: 'us', label: 'US (mixpanel.com)' }, + { value: 'eu', label: 'EU (eu.mixpanel.com)' }, + { value: 'in', label: 'India (in.mixpanel.com)' }, +] as const; + +export type MixpanelRegion = (typeof MIXPANEL_REGIONS)[number]['value']; + +// The API only accepts these three data-residency values, so a typo'd +// --region must error instead of going on the wire. The wizard's region step +// parses the flag with this, and the unit tests pin the rejection, so the +// strictness cannot be dropped unnoticed. +export function parseMixpanelRegion(value: string): MixpanelRegion { + const match = MIXPANEL_REGIONS.find((r) => r.value === value); + if (match === undefined) { + throw new CLIError( + `Invalid value for --region: "${value}"`, + ExitCode.USAGE, + `Use one of: ${MIXPANEL_REGIONS.map((r) => r.value).join(', ')}` + ); + } + return match.value; +} + +export function mixpanelServiceAccountsUrl(region: 'us' | 'eu' | 'in'): string { + const host = region === 'us' ? 'mixpanel.com' : `${region}.mixpanel.com`; + return `https://${host}/settings/org#serviceaccounts`; +} + +// A digits-only value past Number.MAX_SAFE_INTEGER is a positive integer, so +// rejecting it as "not a positive integer" would name the wrong reason — this +// distinguishes the too-large case so both rejection surfaces can say why. +function isTooLargeMixpanelProjectId(value: string): boolean { + const trimmed = typeof value === 'string' ? value.trim() : ''; + return /^\d+$/.test(trimmed) && Number(trimmed) > Number.MAX_SAFE_INTEGER; +} + +// The console discovers the accessible projects after validating the service +// account, but that route is console-only, so the CLI asks for the numeric +// project ID directly. One integration per project. Only decimal digits are +// accepted (no 1e3 / 0x10 / 1.0), the ID must be >= 1, and anything past +// Number.MAX_SAFE_INTEGER is refused instead of silently rounded to a +// different project. The interactive prompt validates with this same function +// so it can never accept a value the re-parse would throw on. +export function parseMixpanelProjectId(value: string, flag: string): number { + const trimmed = value.trim(); + const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN; + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new CLIError( + `Invalid value for ${flag}: "${value}"`, + ExitCode.USAGE, + isTooLargeMixpanelProjectId(value) + ? `The ID exceeds the maximum safe integer (${Number.MAX_SAFE_INTEGER}), so it would be silently rounded to a different project; it is refused instead. Copy the ID exactly from your Mixpanel project URL (mixpanel.com/project/).` + : 'Pass the numeric project ID from your Mixpanel project URL (mixpanel.com/project/) or from Project Settings > Overview.' + ); + } + return parsed; +} + +function validateMixpanelProjectId(value: string): string | undefined { + try { + parseMixpanelProjectId(value, '--project-id'); + return undefined; + } catch { + return isTooLargeMixpanelProjectId(value) + ? `That ID is too large: it exceeds the maximum safe integer (${Number.MAX_SAFE_INTEGER})` + : 'Enter the numeric project ID (a positive integer)'; + } +} + // 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'; @@ -713,6 +789,123 @@ async function connectWithCredentials( ]); if (!ok) return BACK; body = { type: 'betterstack', workspaceId, apiToken, uptimeApiToken, telemetryApiToken }; + } else if (type === 'openstatus') { + let apiKey = ''; + const ok = await runSteps([ + secretStep( + config, + args, + 'apiKey', + '--api-key', + { + message: 'OpenStatus API key', + instructions: + 'In your OpenStatus dashboard, open Settings > API Token and create a key. It needs write access so agents can manage monitors and publish status reports. The key is an opaque string with no prefix. Polylane validates it against your workspace and provisions a webhook notification channel so monitor failures, degradations and recoveries land as alerts.', + link: 'https://app.openstatus.dev/settings/general', + linkLabel: 'Open OpenStatus settings', + }, + (v) => { + apiKey = v; + } + ), + ]); + if (!ok) return BACK; + body = { type: 'openstatus', workspaceId, apiKey }; + } else if (type === 'mixpanel') { + const ctx = { nonInteractive: config.nonInteractive }; + let region: 'us' | 'eu' | 'in' = 'us'; + let serviceAccountUsername = ''; + let serviceAccountSecret = ''; + let projectId = 0; + const ok = await runSteps([ + // Shaped like choiceStep with { strict: true }, but the flag goes + // through the exported parseMixpanelRegion so the strictness is pinned + // by a unit test (the select prompt can only produce listed values). + async () => { + const fromFlag = getArgString(args, 'region'); + if (fromFlag !== undefined) { + region = parseMixpanelRegion(fromFlag); + return SKIPPED; + } + if (!isInteractive(config.nonInteractive)) { + throw new CLIError('Missing required flag: --region', ExitCode.USAGE); + } + const value = await promptSelectOrBack( + ctx, + 'Mixpanel data residency region: the one in your Mixpanel URL', + [...MIXPANEL_REGIONS] + ); + if (value === BACK) return BACK; + region = value; + return; + }, + async () => { + // Same emptiness guard as textStep: an empty --service-account-username + // falls through to the prompt (or the missing-flag error) instead of + // going on the wire against the spec's minLength: 1. + const fromFlag = getArgString(args, 'serviceAccountUsername'); + if (fromFlag !== undefined && fromFlag.length > 0) { + serviceAccountUsername = fromFlag; + return SKIPPED; + } + if (!isInteractive(config.nonInteractive)) { + throw new CLIError( + 'Missing required flag: --service-account-username', + ExitCode.USAGE, + `Create a service account in Mixpanel under Organization Settings > Service Accounts: ${mixpanelServiceAccountsUrl(region)}` + ); + } + note( + 'In Mixpanel, go to Organization Settings > Service Accounts and create a service account. Give it the Admin role on the project so agents can also create annotations; the Consumer role works for read-only queries. The secret is shown only once.', + 'Mixpanel service account' + ); + const value = await promptTextOrBack(ctx, 'Service account username', { + // The spec requires minLength 1; without a validate an empty submit + // resolves to undefined and JSON.stringify would drop the field. + validate: (v: string) => (v && v.trim().length > 0 ? undefined : 'Required'), + }); + if (value === BACK) return BACK; + serviceAccountUsername = value; + return; + }, + secretStep( + config, + args, + 'serviceAccountSecret', + '--service-account-secret', + () => ({ + message: 'Mixpanel service account secret', + instructions: 'Paste the secret that came with the service account username. It is shown only once, when the service account is created.', + link: mixpanelServiceAccountsUrl(region), + linkLabel: 'Open Mixpanel service accounts', + }), + (v) => { + serviceAccountSecret = v; + } + ), + async () => { + const fromFlag = getArgString(args, 'projectId'); + if (fromFlag !== undefined) { + projectId = parseMixpanelProjectId(fromFlag, '--project-id'); + return SKIPPED; + } + if (!isInteractive(config.nonInteractive)) { + throw new CLIError( + 'Missing required flag: --project-id', + ExitCode.USAGE, + 'The numeric project ID is in your Mixpanel project URL (mixpanel.com/project/) and in Project Settings > Overview.' + ); + } + const value = await promptTextOrBack(ctx, 'Mixpanel project ID (the number in your project URL: mixpanel.com/project/)', { + validate: validateMixpanelProjectId, + }); + if (value === BACK) return BACK; + projectId = parseMixpanelProjectId(value, '--project-id'); + return; + }, + ]); + if (!ok) return BACK; + body = { type: 'mixpanel', workspaceId, region, serviceAccountUsername, serviceAccountSecret, projectId }; } else if (type === 'grafana') { let stackUrl = ''; let serviceAccountToken = ''; @@ -760,12 +953,7 @@ async function connectWithCredentials( ), ]); if (!ok) return BACK; - // grafana is not in the generated connect union yet: the client is built - // from the live prod spec, which gains the variant only when the matching - // API deploy lands. Same trust boundary as the honeycomb request-body - // spread; an API build that predates grafana rejects the type with a 400 - // instead of connecting silently, so nothing needs a post-connect assertion. - body = { type: 'grafana', workspaceId, stackUrl, serviceAccountToken } as unknown as ConnectBody; + body = { type: 'grafana', workspaceId, stackUrl, serviceAccountToken }; } else if (type === 'linear') { let apiKey = ''; const ok = await runSteps([ @@ -875,7 +1063,7 @@ async function connectType( export const integrationConnectCommand: Command = { name: 'integration connect', - description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, Grafana Cloud, Devin, Cursor, Factory, Conductor, Linear, MCP)', + description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, OpenStatus, Grafana Cloud, Mixpanel, Devin, Cursor, Factory, Conductor, Linear, MCP)', operationId: 'integrations.connect', options: [ { @@ -889,8 +1077,8 @@ 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; detected from the token if omitted)', type: 'string' }, - { flag: '--api-key ', description: 'API key (Datadog / Honeycomb / Devin / Cursor / Factory / Conductor / Linear)', type: 'string' }, + { flag: '--region ', description: 'Honeycomb (us|eu), Axiom (us-east-1|eu-central-1; detected from the token if omitted) or Mixpanel (us|eu|in)', type: 'string' }, + { flag: '--api-key ', description: 'API key (Datadog / Honeycomb / OpenStatus / Devin / Cursor / Factory / Conductor / Linear)', 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' }, @@ -899,6 +1087,9 @@ export const integrationConnectCommand: Command = { { flag: '--service-account-token ', description: 'Service account token (Grafana only, glsa_...)', 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' }, + { flag: '--service-account-username ', description: 'Service account username (Mixpanel only)', type: 'string' }, + { flag: '--service-account-secret ', description: 'Service account secret (Mixpanel only)', type: 'string' }, + { flag: '--project-id ', description: 'Numeric project ID (Mixpanel only)', type: 'string' }, { flag: '--url ', description: 'MCP server URL', type: 'string' }, { flag: '--name ', description: 'MCP server display name', type: 'string' }, { flag: '--transport ', description: 'MCP transport: http | sse (default: http)', type: 'string' }, @@ -919,7 +1110,9 @@ export const integrationConnectCommand: Command = { 'polylane integration connect --type honeycomb --region us --api-key ... --management-api-key-id ... --management-api-key-secret ...', 'polylane integration connect --type axiom --api-token ...', 'polylane integration connect --type betterstack --api-token ... --uptime-api-token ... --telemetry-api-token ...', + 'polylane integration connect --type openstatus --api-key ...', 'polylane integration connect --type grafana --stack-url https://mystack.grafana.net --service-account-token glsa_...', + 'polylane integration connect --type mixpanel --region us --service-account-username ... --service-account-secret ... --project-id 1234567', 'polylane integration connect --type cursor --api-key crsr_...', 'polylane integration connect --type linear --api-key lin_api_...', 'polylane integration connect --type mcp --url https://mcp.example.com/sse --name "My MCP"', diff --git a/test/cloud-connect-railway.test.ts b/test/cloud-connect-railway.test.ts new file mode 100644 index 0000000..267400b --- /dev/null +++ b/test/cloud-connect-railway.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRailwayConnectBody, PROVIDER_OPTIONS } from '../src/commands/cloud/connect'; + +describe('buildRailwayConnectBody', () => { + it('builds workspace, provider and token only when no Railway workspace is given', () => { + const body = buildRailwayConnectBody('ws_1', 'rw-token'); + assert.deepEqual(body, { + workspaceId: 'ws_1', + provider: 'railway', + token: 'rw-token', + }); + assert.ok(!('railwayWorkspaceId' in body)); + }); + + it('carries the Railway workspace under exactly the railwayWorkspaceId key', () => { + const body = buildRailwayConnectBody('ws_1', 'rw-token', 'railway-ws-9'); + assert.deepEqual(body, { + workspaceId: 'ws_1', + provider: 'railway', + token: 'rw-token', + railwayWorkspaceId: 'railway-ws-9', + }); + assert.deepEqual(Object.keys(body).sort(), ['provider', 'railwayWorkspaceId', 'token', 'workspaceId']); + }); +}); + +describe('PROVIDER_OPTIONS', () => { + it('offers railway with its full label and hint', () => { + const railway = PROVIDER_OPTIONS.find((o) => o.value === 'railway'); + assert.deepEqual(railway, { value: 'railway', label: 'Railway', hint: 'workspace or account token' }); + }); +}); diff --git a/test/integration-connect-category.test.ts b/test/integration-connect-category.test.ts index 420e21d..7ec4853 100644 --- a/test/integration-connect-category.test.ts +++ b/test/integration-connect-category.test.ts @@ -10,12 +10,17 @@ import { isCLIError } from '../src/errors/base'; describe('typeOptionsForCategory', () => { it('returns every option when no category is given', () => { const all = typeOptionsForCategory(undefined); - assert.equal(all.length, 14); + assert.equal(all.length, 16); }); it('narrows to exactly the observability integrations', () => { const types = typeOptionsForCategory('observability').map((o) => o.value); - assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'grafana', 'honeycomb', 'sentry']); + assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'grafana', 'honeycomb', 'openstatus', 'sentry']); + }); + + it('narrows to exactly the product analytics integrations', () => { + const types = typeOptionsForCategory('product-analytics').map((o) => o.value); + assert.deepEqual(types, ['mixpanel']); }); it('narrows to exactly the code agents', () => { @@ -28,6 +33,18 @@ describe('typeOptionsForCategory', () => { assert.deepEqual(types, ['linear']); }); + it('describes the openstatus and mixpanel entries fully', () => { + const all = typeOptionsForCategory(undefined); + assert.deepEqual( + all.find((o) => o.value === 'openstatus'), + { value: 'openstatus', label: 'OpenStatus', hint: 'workspace API key', category: 'observability' } + ); + assert.deepEqual( + all.find((o) => o.value === 'mixpanel'), + { value: 'mixpanel', label: 'Mixpanel', hint: 'service account + project ID', category: 'product-analytics' } + ); + }); + it('covers every option with a known category', () => { for (const category of CONNECT_CATEGORIES) { assert.ok(typeOptionsForCategory(category).length > 0, `empty category: ${category}`); @@ -52,9 +69,9 @@ describe('typeOptionsForCategory', () => { describe('resolveTypeOptions', () => { it('lets --type win over the filter', () => { - assert.equal(resolveTypeOptions('observability', true).length, 14); - assert.equal(resolveTypeOptions('observability', false).length, 6); - assert.equal(resolveTypeOptions(undefined, false).length, 14); + assert.equal(resolveTypeOptions('observability', true).length, 16); + assert.equal(resolveTypeOptions('observability', false).length, 7); + assert.equal(resolveTypeOptions(undefined, false).length, 16); }); it('rejects an unknown category even when --type is present', () => { diff --git a/test/integration-connect-mixpanel.test.ts b/test/integration-connect-mixpanel.test.ts new file mode 100644 index 0000000..2dab617 --- /dev/null +++ b/test/integration-connect-mixpanel.test.ts @@ -0,0 +1,114 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + MIXPANEL_REGIONS, + mixpanelServiceAccountsUrl, + parseMixpanelProjectId, + parseMixpanelRegion, +} from '../src/commands/integration/connect'; +import { isCLIError, CLIError } from '../src/errors/base'; +import { ExitCode } from '../src/errors/codes'; + +describe('parseMixpanelProjectId', () => { + const accepted: Array<[string, number]> = [ + ['1', 1], + ['42', 42], + ['1000', 1000], + ['007', 7], + [' 42 ', 42], + ['9007199254740991', 9007199254740991], + ]; + for (const [input, expected] of accepted) { + it(`accepts "${input}" as ${expected}`, () => { + assert.equal(parseMixpanelProjectId(input, '--project-id'), expected); + }); + } + + const rejected = [ + '1e3', + '0x10', + '0b101', + '1.0', + '0', + '-5', + '+1', + '1.5', + '1_000', + '', + ' ', + 'Infinity', + 'NaN', + ]; + for (const input of rejected) { + it(`rejects "${input}" with the positive-integer usage error`, () => { + try { + parseMixpanelProjectId(input, '--project-id'); + assert.fail('expected a CLIError'); + } catch (err) { + assert.ok(isCLIError(err)); + assert.equal((err as CLIError).exitCode, ExitCode.USAGE); + assert.match((err as Error).message, /Invalid value for --project-id/); + assert.ok((err as CLIError).hint?.includes('mixpanel.com/project/')); + assert.match((err as CLIError).hint ?? '', /Pass the numeric project ID/); + assert.ok(!((err as CLIError).hint ?? '').includes('maximum safe integer')); + } + }); + } + + // These ARE positive integers, so the rejection must name the real reason + // (past Number.MAX_SAFE_INTEGER) instead of "a positive integer". + const tooLarge = ['9007199254740992', '9007199254740993', '12345678901234567890']; + for (const input of tooLarge) { + it(`rejects "${input}" naming the too-large reason`, () => { + try { + parseMixpanelProjectId(input, '--project-id'); + assert.fail('expected a CLIError'); + } catch (err) { + assert.ok(isCLIError(err)); + assert.equal((err as CLIError).exitCode, ExitCode.USAGE); + assert.match((err as Error).message, /Invalid value for --project-id/); + assert.ok((err as CLIError).hint?.includes('mixpanel.com/project/')); + assert.match((err as CLIError).hint ?? '', /maximum safe integer \(9007199254740991\)/); + } + }); + } +}); + +describe('parseMixpanelRegion', () => { + for (const region of ['us', 'eu', 'in'] as const) { + it(`accepts "${region}"`, () => { + assert.equal(parseMixpanelRegion(region), region); + }); + } + + for (const input of ['xx', 'europe', '', 'US', ' us ']) { + it(`rejects "${input}" with a usage error listing the regions`, () => { + try { + parseMixpanelRegion(input); + assert.fail('expected a CLIError'); + } catch (err) { + assert.ok(isCLIError(err)); + assert.equal((err as CLIError).exitCode, ExitCode.USAGE); + assert.match((err as Error).message, /Invalid value for --region/); + assert.equal((err as CLIError).hint, 'Use one of: us, eu, in'); + } + }); + } +}); + +describe('MIXPANEL_REGIONS', () => { + it('offers exactly the us, eu and in data-residency regions', () => { + assert.deepEqual( + MIXPANEL_REGIONS.map((r) => r.value), + ['us', 'eu', 'in'] + ); + }); +}); + +describe('mixpanelServiceAccountsUrl', () => { + it('uses the bare host for us and the region-prefixed host elsewhere', () => { + assert.equal(mixpanelServiceAccountsUrl('us'), 'https://mixpanel.com/settings/org#serviceaccounts'); + assert.equal(mixpanelServiceAccountsUrl('eu'), 'https://eu.mixpanel.com/settings/org#serviceaccounts'); + assert.equal(mixpanelServiceAccountsUrl('in'), 'https://in.mixpanel.com/settings/org#serviceaccounts'); + }); +});