From 31f66a7dd74df3cdf4bb52b32235bfc3f9783ba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 01:41:31 +0000 Subject: [PATCH 1/3] feat: add mixpanel and openstatus integrations and railway cloud connect OpenStatus and Mixpanel are live in the platform's integration catalog and their connect payloads are in the production OpenAPI spec, but neither was offered by the CLI picker. Railway cloud accounts were likewise connectable through the API's token path but missing from the cloud provider list. - openstatus: observability picker entry, single workspace API key (write access), reusing --api-key - mixpanel: new product-analytics category; region (us|eu|in), service account username/secret, and numeric project ID (the console's project auto-discovery route is hidden from the public API, so the CLI asks for the project ID directly and validates it parses as a positive integer) - railway: token-path connect mirroring Fly (the console's OAuth flow is console-only), with optional --railway-workspace to narrow the connect Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015DJ1B1PfT5wKMjZAkasFLq --- src/commands/cloud/connect.ts | 36 +++++- src/commands/integration/connect.ts | 144 +++++++++++++++++++++- test/integration-connect-category.test.ts | 15 ++- test/integration-connect-priority.test.ts | 2 +- 4 files changed, 184 insertions(+), 13 deletions(-) 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); }); From 54434ecc80e086d0e935d46a4da299e429d11c0e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:46:53 +0000 Subject: [PATCH 2/3] fix: unify mixpanel project-id validation and add provider tests Address the blocking review at dbad0d2: - parseMixpanelProjectId now accepts only decimal digits (/^\d+$/), >= 1 and <= Number.MAX_SAFE_INTEGER, so 1e3 / 0x10 / 0b101 / 1.0 / +1 are rejected instead of silently rewritten, and IDs past 2^53 are refused instead of rounded to a different project. The interactive prompt validates with this same function (the Grafana pattern), so it can no longer accept "0" and then die on the re-parse after the secret was pasted. - Export parseMixpanelProjectId, mixpanelServiceAccountsUrl, MIXPANEL_REGIONS and PROVIDER_OPTIONS; add buildRailwayConnectBody as an explicitly typed builder (no conditional spread, so a misspelled railwayWorkspaceId is TS2561). New unit tests cover the validator accept/reject table, the region enum, the per-region service-accounts URL, the railway body with and without a workspace, and the railway provider option; the category tests now assert the full openstatus and mixpanel option objects. - --railway-workspace "" is a usage error instead of being silently dropped, and the wizard now offers an optional interactive prompt for the Railway workspace ID (empty = connect all). - The Mixpanel username step keeps textStep's length > 0 emptiness guard so --service-account-username "" no longer goes on the wire, and its missing-flag error carries a hint. - The service-accounts URL is shown once (by the secretStep, which offers to open it) instead of twice. - Drop the dead "as unknown as ConnectBody" cast on the grafana body; grafana is in the generated union. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015DJ1B1PfT5wKMjZAkasFLq --- src/commands/cloud/connect.ts | 43 ++++++++++--- src/commands/integration/connect.ts | 49 +++++++++------ test/cloud-connect-railway.test.ts | 33 ++++++++++ test/integration-connect-category.test.ts | 12 ++++ test/integration-connect-mixpanel.test.ts | 74 +++++++++++++++++++++++ 5 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 test/cloud-connect-railway.test.ts create mode 100644 test/integration-connect-mixpanel.test.ts diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index beb4a49..008e188 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -52,7 +52,7 @@ 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)' }, @@ -271,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).'; @@ -646,10 +658,18 @@ 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. + // 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, @@ -659,7 +679,7 @@ async function connectProvider( { 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).', + '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', }, @@ -667,10 +687,19 @@ async function connectProvider( 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)' + ); + if (picked === BACK) return BACK; + railwayWorkspaceId = picked.trim() === '' ? undefined : picked.trim(); + return; + }, ]); if (!ok) return BACK; - const railwayWorkspaceId = getArgString(args, 'railwayWorkspace'); - body = { workspaceId, provider: 'railway', token, ...(railwayWorkspaceId ? { railwayWorkspaceId } : {}) }; + body = buildRailwayConnectBody(workspaceId, token, railwayWorkspaceId); } else if (provider === 'convex') { let token = ''; const ok = await runSteps([ diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 6ee6c3c..7efb855 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -131,23 +131,28 @@ function datadogConsoleUrl(site: string): string { // Same region list the console offers, strict because the API only accepts // these three data-residency values. -const MIXPANEL_REGIONS = [ +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; -function mixpanelServiceAccountsUrl(region: 'us' | 'eu' | 'in'): string { +export 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) { +// 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, @@ -157,6 +162,15 @@ function parseMixpanelProjectId(value: string, flag: string): number { return parsed; } +function validateMixpanelProjectId(value: string): string | undefined { + try { + parseMixpanelProjectId(value, '--project-id'); + return undefined; + } catch { + return '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'; @@ -787,17 +801,23 @@ async function connectWithCredentials( { strict: true } ), 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) { + 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); + 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.\n\nOpen Mixpanel service accounts:\n ' + - mixpanelServiceAccountsUrl(region), + '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'); @@ -834,7 +854,7 @@ async function connectWithCredentials( ); } 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'), + validate: validateMixpanelProjectId, }); if (value === BACK) return BACK; projectId = parseMixpanelProjectId(value, '--project-id'); @@ -890,12 +910,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([ 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 fb7ac51..7ec4853 100644 --- a/test/integration-connect-category.test.ts +++ b/test/integration-connect-category.test.ts @@ -33,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}`); diff --git a/test/integration-connect-mixpanel.test.ts b/test/integration-connect-mixpanel.test.ts new file mode 100644 index 0000000..a3af69b --- /dev/null +++ b/test/integration-connect-mixpanel.test.ts @@ -0,0 +1,74 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + MIXPANEL_REGIONS, + mixpanelServiceAccountsUrl, + parseMixpanelProjectId, +} 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', + '9007199254740992', + '9007199254740993', + '12345678901234567890', + ]; + for (const input of rejected) { + it(`rejects "${input}" with a 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/')); + } + }); + } +}); + +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'); + }); +}); From afeacd82eb50bc4efaaf380ef87188b32e95ceca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:41:02 +0000 Subject: [PATCH 3/3] fix: handle empty interactive answers and sharpen project-id errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Railway wizard's optional workspace prompt crashed on exactly the documented answer: an empty submit makes clack's text prompt resolve to undefined, so picked.trim() threw. Pass defaultValue: '' (plus a placeholder so the submitted frame doesn't render literal "undefined"), keeping empty = connect every workspace. The Mixpanel username prompt had the same undefined mechanism in its quiet form — the value was assigned, JSON.stringify dropped the field, and the connect went out without serviceAccountUsername against the spec's minLength 1. A validate now rejects empty and re-prompts. Project-ID rejections past Number.MAX_SAFE_INTEGER now name the real reason instead of calling 9007199254740993 "not a positive integer", on both the flag hint and the interactive validator; tests pin both messages. The Mixpanel region step's strictness moved into an exported parseMixpanelRegion (same message and hint as choiceStep strict), with unit tests pinning the us/eu/in accepts and the rejections, so dropping the strict parse can no longer survive the suite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015DJ1B1PfT5wKMjZAkasFLq --- src/commands/cloud/connect.ts | 8 ++- src/commands/integration/connect.ts | 73 ++++++++++++++++++----- test/integration-connect-mixpanel.test.ts | 48 +++++++++++++-- 3 files changed, 109 insertions(+), 20 deletions(-) diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 008e188..37688ab 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -691,7 +691,13 @@ async function connectProvider( 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)' + '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(); diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 7efb855..feec29f 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -137,11 +137,37 @@ export const MIXPANEL_REGIONS = [ { 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 @@ -156,7 +182,9 @@ export function parseMixpanelProjectId(value: string, flag: string): number { 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.' + 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; @@ -167,7 +195,9 @@ function validateMixpanelProjectId(value: string): string | undefined { parseMixpanelProjectId(value, '--project-id'); return undefined; } catch { - return 'Enter the numeric project ID (a positive integer)'; + 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)'; } } @@ -788,18 +818,27 @@ async function connectWithCredentials( 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 } - ), + // 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 @@ -820,7 +859,11 @@ async function connectWithCredentials( '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'); + 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; diff --git a/test/integration-connect-mixpanel.test.ts b/test/integration-connect-mixpanel.test.ts index a3af69b..2dab617 100644 --- a/test/integration-connect-mixpanel.test.ts +++ b/test/integration-connect-mixpanel.test.ts @@ -4,6 +4,7 @@ import { MIXPANEL_REGIONS, mixpanelServiceAccountsUrl, parseMixpanelProjectId, + parseMixpanelRegion, } from '../src/commands/integration/connect'; import { isCLIError, CLIError } from '../src/errors/base'; import { ExitCode } from '../src/errors/codes'; @@ -37,12 +38,9 @@ describe('parseMixpanelProjectId', () => { ' ', 'Infinity', 'NaN', - '9007199254740992', - '9007199254740993', - '12345678901234567890', ]; for (const input of rejected) { - it(`rejects "${input}" with a usage error`, () => { + it(`rejects "${input}" with the positive-integer usage error`, () => { try { parseMixpanelProjectId(input, '--project-id'); assert.fail('expected a CLIError'); @@ -51,6 +49,48 @@ describe('parseMixpanelProjectId', () => { 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'); } }); }