diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 244227f..b05f1c0 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -37,6 +37,7 @@ type Provider = | 'vercel' | 'fly' | 'render' + | 'railway' | 'planetscale' | 'supabase' | 'modal' @@ -48,6 +49,7 @@ const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }> { 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' }, @@ -400,6 +402,32 @@ async function connectProvider( ]); if (!ok) return BACK; body = { workspaceId, provider: 'render', apiKey }; + } else if (provider === 'railway') { + // The console connects Railway via OAuth, but that flow is console-only + // (hidden generate route + console callback), so the CLI takes the token + // path the same API accepts. + let token = ''; + 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 (narrow it with --railway-workspace).', + link: 'https://railway.com/account/tokens', + linkLabel: 'Create Railway token', + }, + (v) => { + token = v; + } + ), + ]); + if (!ok) return BACK; + const railwayWorkspaceId = getArgString(args, 'railwayWorkspace'); + body = { workspaceId, provider: 'railway', token, ...(railwayWorkspaceId ? { railwayWorkspaceId } : {}) }; } else { // modal let tokenId = ''; @@ -465,7 +493,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, Kubernetes)', + description: 'Connect a cloud account (AWS, Cloudflare, Vercel, Fly.io, Render, Railway, PlanetScale, Supabase, Modal, Kubernetes)', operationId: 'cloud_accounts.connect', options: [ { @@ -478,8 +506,9 @@ export const cloudConnectCommand: Command = { { flag: '--region ', description: 'AWS region (e.g. us-east-1)', 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 - { flag: '--token ', description: 'Cloudflare API token, Fly.io token, or PlanetScale service token', type: 'string' }, + // Cloudflare / Fly / Railway / PlanetScale + { flag: '--token ', description: 'Cloudflare API token, Fly.io token, Railway token, or PlanetScale service 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 @@ -501,6 +530,7 @@ export const cloudConnectCommand: Command = { 'polylane cloud connect --provider cloudflare --token ', 'polylane cloud connect --provider aws --account 123456789012 --region us-east-1 --subscribe-alarms', '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 27a953a..f2ebe51 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -30,6 +30,7 @@ import { promptConfirmOrBack, promptSelectOrBack, promptPasswordOrBack, + promptTextOrBack, } from '../../utils/prompt'; type ConnectBody = Parameters[0]; @@ -42,6 +43,8 @@ type ConnectableType = | 'honeycomb' | 'axiom' | 'betterstack' + | 'openstatus' + | 'mixpanel' | 'devin' | 'cursor' | 'factory' @@ -51,7 +54,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', 'protocol'] as const; +export const CONNECT_CATEGORIES = ['git', 'communication', 'observability', 'product-analytics', 'code-agent', 'protocol'] as const; type ConnectCategory = (typeof CONNECT_CATEGORIES)[number]; const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string; category: ConnectCategory }> = [ @@ -62,6 +65,8 @@ 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: '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' }, @@ -134,6 +139,34 @@ 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. +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; + +function mixpanelServiceAccountsUrl(region: 'us' | 'eu' | 'in'): string { + const host = region === 'us' ? 'mixpanel.com' : `${region}.mixpanel.com`; + return `https://${host}/settings/org#serviceaccounts`; +} + +// 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. +function parseMixpanelProjectId(value: string, flag: string): number { + const parsed = Number(value.trim()); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new CLIError( + `Invalid value for ${flag}: "${value}"`, + ExitCode.USAGE, + 'Pass the numeric project ID from your Mixpanel project URL (mixpanel.com/project/) or from Project Settings > Overview.' + ); + } + return parsed; +} + const CODE_AGENTS = { devin: { name: 'Devin', @@ -539,6 +572,104 @@ 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([ + choiceStep<'us' | 'eu' | 'in'>( + config, + args, + 'region', + '--region', + 'Mixpanel data residency region: the one in your Mixpanel URL', + [...MIXPANEL_REGIONS], + (v) => { + region = v; + }, + { strict: true } + ), + async () => { + const fromFlag = getArgString(args, 'serviceAccountUsername'); + if (fromFlag !== undefined) { + serviceAccountUsername = fromFlag; + return SKIPPED; + } + if (!isInteractive(config.nonInteractive)) { + throw new CLIError('Missing required flag: --service-account-username', ExitCode.USAGE); + } + 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.\n\nOpen Mixpanel service accounts:\n ' + + mixpanelServiceAccountsUrl(region), + 'Mixpanel service account' + ); + const value = await promptTextOrBack(ctx, 'Service account username'); + 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: (v) => (/^\d+$/.test(v.trim()) ? undefined : 'Enter the numeric project ID'), + }); + if (value === BACK) return BACK; + projectId = parseMixpanelProjectId(value, '--project-id'); + return; + }, + ]); + if (!ok) return BACK; + body = { type: 'mixpanel', workspaceId, region, serviceAccountUsername, serviceAccountSecret, projectId }; } else { const agent = CODE_AGENTS[type]; let apiKey = ''; @@ -605,7 +736,7 @@ async function connectType( export const integrationConnectCommand: Command = { name: 'integration connect', - description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, Devin, Cursor, Factory, Conductor, MCP)', + description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, OpenStatus, Mixpanel, Devin, Cursor, Factory, Conductor, MCP)', operationId: 'integrations.connect', options: [ { @@ -619,12 +750,15 @@ 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: '--api-key ', description: 'API key (Datadog / Honeycomb / Devin / Cursor / Factory / Conductor)', type: 'string' }, + { flag: '--region ', description: 'Honeycomb (us|eu), Axiom (us-east-1|eu-central-1) or Mixpanel (us|eu|in)', type: 'string' }, + { flag: '--api-key ', description: 'API key (Datadog / Honeycomb / OpenStatus / Devin / Cursor / Factory / Conductor)', type: 'string' }, { flag: '--app-key ', description: 'App key (Datadog only)', 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' }, + { 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' }, @@ -645,6 +779,8 @@ export const integrationConnectCommand: Command = { 'polylane integration connect --type honeycomb --region us --api-key ...', '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 openstatus --api-key ...', + '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 mcp --url https://mcp.example.com/sse --name "My MCP"', 'polylane integration connect --type mcp --url https://mcp.example.com/sse --name "My MCP" --oauth', diff --git a/test/integration-connect-category.test.ts b/test/integration-connect-category.test.ts index 7616622..2c8d75d 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, 12); + assert.equal(all.length, 14); }); it('narrows to exactly the observability integrations', () => { const types = typeOptionsForCategory('observability').map((o) => o.value); - assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'honeycomb', 'sentry']); + assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', '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', () => { @@ -47,9 +52,9 @@ describe('typeOptionsForCategory', () => { describe('resolveTypeOptions', () => { it('lets --type win over the filter', () => { - assert.equal(resolveTypeOptions('observability', true).length, 12); - assert.equal(resolveTypeOptions('observability', false).length, 5); - assert.equal(resolveTypeOptions(undefined, false).length, 12); + assert.equal(resolveTypeOptions('observability', true).length, 14); + assert.equal(resolveTypeOptions('observability', false).length, 6); + assert.equal(resolveTypeOptions(undefined, false).length, 14); }); it('rejects an unknown category even when --type is present', () => { diff --git a/test/integration-connect-priority.test.ts b/test/integration-connect-priority.test.ts index 2bc316b..c80c3f9 100644 --- a/test/integration-connect-priority.test.ts +++ b/test/integration-connect-priority.test.ts @@ -24,7 +24,7 @@ describe('prioritizeCodeAgent', () => { it('keeps grouping intact', () => { const { options } = prioritizeCodeAgent(typeOptionsForCategory(undefined), 'cursor'); const categories = options.map((o) => o.category); - assert.deepEqual([...new Set(categories)], ['git', 'communication', 'observability', 'code-agent', 'protocol']); + assert.deepEqual([...new Set(categories)], ['git', 'communication', 'observability', 'product-analytics', 'code-agent', 'protocol']); assert.equal(options.length, typeOptionsForCategory(undefined).length); });