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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions src/commands/cloud/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Provider =
| 'vercel'
| 'fly'
| 'render'
| 'railway'
| 'planetscale'
| 'supabase'
| 'modal'
Expand All @@ -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' },
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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: [
{
Expand All @@ -478,8 +506,9 @@ export const cloudConnectCommand: Command = {
{ flag: '--region <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 <token>', description: 'Cloudflare API token, Fly.io token, or PlanetScale service token', type: 'string' },
// Cloudflare / Fly / Railway / PlanetScale
{ flag: '--token <token>', description: 'Cloudflare API token, Fly.io token, Railway token, or PlanetScale service token', type: 'string' },
{ flag: '--railway-workspace <id>', 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
Expand All @@ -501,6 +530,7 @@ export const cloudConnectCommand: Command = {
'polylane cloud connect --provider cloudflare --token <token>',
'polylane cloud connect --provider aws --account 123456789012 --region us-east-1 --subscribe-alarms',
'polylane cloud connect --provider render --api-key <key>',
'polylane cloud connect --provider railway --token <token>',
'polylane cloud connect --provider supabase',
'polylane cloud connect --provider planetscale --token-id <id> --token <token> --organization <org>',
'polylane cloud connect --provider modal --token-id ak-... --token-secret as-...',
Expand Down
144 changes: 140 additions & 4 deletions src/commands/integration/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
promptConfirmOrBack,
promptSelectOrBack,
promptPasswordOrBack,
promptTextOrBack,
} from '../../utils/prompt';

type ConnectBody = Parameters<PolylaneAPI['integrationsConnect']>[0];
Expand All @@ -42,6 +43,8 @@ type ConnectableType =
| 'honeycomb'
| 'axiom'
| 'betterstack'
| 'openstatus'
| 'mixpanel'
| 'devin'
| 'cursor'
| 'factory'
Expand All @@ -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 }> = [
Expand All @@ -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' },
Expand Down Expand Up @@ -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/<id>) or from Project Settings > Overview.'
);
}
return parsed;
}

const CODE_AGENTS = {
devin: {
name: 'Devin',
Expand Down Expand Up @@ -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/<id>) and in Project Settings > Overview.'
);
}
const value = await promptTextOrBack(ctx, 'Mixpanel project ID (the number in your project URL: mixpanel.com/project/<id>)', {
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 = '';
Expand Down Expand Up @@ -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: [
{
Expand All @@ -619,12 +750,15 @@ export const integrationConnectCommand: Command = {
type: 'string',
},
{ flag: '--site <site>', description: 'Datadog site (e.g. us5.datadoghq.com)', type: 'string' },
{ flag: '--region <region>', description: 'Honeycomb (us|eu) or Axiom (us-east-1|eu-central-1)', type: 'string' },
{ flag: '--api-key <key>', description: 'API key (Datadog / Honeycomb / Devin / Cursor / Factory / Conductor)', type: 'string' },
{ flag: '--region <region>', description: 'Honeycomb (us|eu), Axiom (us-east-1|eu-central-1) or Mixpanel (us|eu|in)', type: 'string' },
{ flag: '--api-key <key>', description: 'API key (Datadog / Honeycomb / OpenStatus / Devin / Cursor / Factory / Conductor)', type: 'string' },
{ flag: '--app-key <key>', description: 'App key (Datadog only)', type: 'string' },
{ flag: '--api-token <token>', description: 'API token (Axiom / Better Stack global token)', type: 'string' },
{ flag: '--uptime-api-token <token>', description: 'Uptime API token (Better Stack only)', type: 'string' },
{ flag: '--telemetry-api-token <token>', description: 'Telemetry API token (Better Stack only)', type: 'string' },
{ flag: '--service-account-username <username>', description: 'Service account username (Mixpanel only)', type: 'string' },
{ flag: '--service-account-secret <secret>', description: 'Service account secret (Mixpanel only)', type: 'string' },
{ flag: '--project-id <id>', description: 'Numeric project ID (Mixpanel only)', type: 'string' },
{ flag: '--url <url>', description: 'MCP server URL', type: 'string' },
{ flag: '--name <name>', description: 'MCP server display name', type: 'string' },
{ flag: '--transport <t>', description: 'MCP transport: http | sse (default: http)', type: 'string' },
Expand All @@ -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',
Expand Down
15 changes: 10 additions & 5 deletions test/integration-connect-category.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion test/integration-connect-priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading