Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ polylane workspace create --name "My Workspace" # creates + makes default
# 3. Connect your stack (discover what's available first)
polylane integration catalog
polylane integration connect --type <type> # see `polylane integration connect --help`
polylane integration connect --type posthog # choose a project; repeat to add another
polylane cloud connect --provider <provider> # see `polylane cloud connect --help`

# 4. Work
Expand Down
2 changes: 2 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ polylane service list # cloud infra discovered from

`integration connect` and `cloud connect` dispatch on `--type` / `--provider`. Some options open a browser for an install URL; others take API credentials directly. Use `--help` on each to see the required flags and optional `--no-browser`. Browser flows wait in the terminal until the connection appears (interactive TTY only); with `--output json` or in non-interactive runs they print the URL and exit, so poll `integration list` / `cloud list` to confirm.

`polylane integration connect --type posthog` opens Polylane in the browser. Choose one project on PostHog's consent screen. Repeat the command to add another project or renew consent for one already connected. The command waits for the connection in an interactive terminal. Use `--no-browser` to copy the URL.

Plans cap how many cloud accounts a workspace can connect; the numbers come from the API, never from the CLI. `cloud connect` checks first: with room it connects; at the limit it names the plan and the count (`Your Free plan includes 2 cloud accounts; 2 connected.`), offers the cheapest plan that raises it, and on yes opens Stripe Checkout and waits, then carries on with the connect once the plan lands. A no, a Stripe cancel, or no answer in time exits `4` with `Upgrade any time: polylane subscription upgrade` and leaves the connected accounts as they are. Non-interactive runs at the limit exit `4` with the same line as an error. The API answers a connect over the limit with `402` (exit `4`) either way.

```bash
Expand Down
25 changes: 14 additions & 11 deletions src/commands/integration/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type ConnectableType =
| 'grafana'
| 'logfire'
| 'mixpanel'
| 'posthog'
| 'devin'
| 'cursor'
| 'factory'
Expand Down Expand Up @@ -81,6 +82,7 @@ const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string;
{ value: 'grafana', label: 'Grafana Cloud', hint: 'stack URL + service account token', category: 'observability' },
{ value: 'logfire', label: 'Logfire', hint: 'project + organization keys', category: 'observability' },
{ value: 'mixpanel', label: 'Mixpanel', hint: 'service account + project ID', category: 'product-analytics' },
{ value: 'posthog', label: 'PostHog', hint: "choose one project on PostHog's consent screen", 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 @@ -647,7 +649,7 @@ async function connectWithCredentials(
api: PolylaneAPI,
args: Record<string, unknown>,
workspaceId: string,
type: Exclude<ConnectableType, 'github' | 'slack' | 'sentry' | 'mcp'>
type: Exclude<ConnectableType, 'github' | 'slack' | 'sentry' | 'posthog' | 'mcp'>
): Promise<typeof BACK | ConnectOutcome> {
let body: ConnectBody;
if (type === 'datadog') {
Expand Down Expand Up @@ -1125,15 +1127,15 @@ async function connectType(
): Promise<typeof BACK | ConnectOutcome> {
// --- Install-URL flows: the browser enters via the console's /cli/connect
// page (which ends the journey on its "go back to your terminal" page),
// while the CLI waits for the integration to appear. When the type is
// already connected, succeed without opening a browser — only an explicit
// --reconnect takes the wait-for-update path ---
if (type === 'github' || type === 'slack' || type === 'sentry') {
const labels = { github: 'the GitHub App', slack: 'the Slack app', sentry: 'the Sentry integration' } as const;
const names = { github: 'GitHub', slack: 'Slack', sentry: 'Sentry' } as const;
// while the CLI waits for the integration to appear. Singleton integrations
// short-circuit when already connected unless --reconnect is explicit.
// PostHog allows another project per consent, so it always opens the flow.
if (type === 'github' || type === 'slack' || type === 'sentry' || type === 'posthog') {
const labels = { github: 'the GitHub App', slack: 'the Slack app', sentry: 'the Sentry integration', posthog: 'the PostHog integration' } as const;
const names = { github: 'GitHub', slack: 'Slack', sentry: 'Sentry', posthog: 'PostHog' } as const;
const reconnect = getArgBoolean(args, 'reconnect') === true;
const baseline = config.dryRun ? null : await integrationBaseline(api, workspaceId, type);
if (baseline && !reconnect && baseline.existing.length > 0) {
if (baseline && !reconnect && type !== 'posthog' && baseline.existing.length > 0) {
const existing = baseline.existing[0]!;
printAlreadyConnected(config, names[type], existing);
if (type === 'slack') {
Expand Down Expand Up @@ -1181,7 +1183,7 @@ async function connectType(

export const integrationConnectCommand: Command = {
name: 'integration connect',
description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, OpenStatus, Grafana Cloud, Logfire, Mixpanel, Devin, Cursor, Factory, Conductor, Linear, MCP)',
description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, OpenStatus, Grafana Cloud, Logfire, Mixpanel, PostHog, Devin, Cursor, Factory, Conductor, Linear, MCP)',
operationId: 'integrations.connect',
options: [
{
Expand Down Expand Up @@ -1216,8 +1218,8 @@ export const integrationConnectCommand: Command = {
{ flag: '--extra-headers <json>', description: 'MCP extra headers as JSON object', type: 'string' },
{ flag: '--oauth', description: 'MCP: use OAuth flow (opens browser to authorize)', type: 'boolean' },
{ flag: '--scope <scope>', description: 'MCP OAuth scope', type: 'string' },
{ flag: '--no-browser', description: 'GitHub / Slack / Sentry / MCP OAuth: print the URL instead of opening it', type: 'boolean' },
{ flag: '--reconnect', description: 'GitHub / Slack / Sentry: run the connect flow even when the integration is already connected', type: 'boolean' },
{ flag: '--no-browser', description: 'GitHub / Slack / Sentry / PostHog / MCP OAuth: print the URL instead of opening it', type: 'boolean' },
{ flag: '--reconnect', description: 'GitHub / Slack / Sentry / PostHog: run the connect flow even when the integration is already connected', type: 'boolean' },
{
flag: '--pr-reviews',
description: 'GitHub: review pull requests for production impact on the repositories this connection brings in (the default), without the prompt',
Expand All @@ -1244,6 +1246,7 @@ export const integrationConnectCommand: Command = {
'polylane integration connect --type grafana --stack-url https://mystack.grafana.net --service-account-token glsa_...',
'polylane integration connect --type logfire --api-key pylf_... --organization-api-key pylf_...',
'polylane integration connect --type mixpanel --region us --service-account-username ... --service-account-secret ... --project-id 1234567',
'polylane integration connect --type posthog',
'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"',
Expand Down
8 changes: 4 additions & 4 deletions test/integration-connect-category.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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, 17);
assert.equal(all.length, 18);
});

it('narrows to exactly the observability integrations', () => {
Expand All @@ -20,7 +20,7 @@ describe('typeOptionsForCategory', () => {

it('narrows to exactly the product analytics integrations', () => {
const types = typeOptionsForCategory('product-analytics').map((o) => o.value);
assert.deepEqual(types, ['mixpanel']);
assert.deepEqual(types, ['mixpanel', 'posthog']);
});

it('narrows to exactly the code agents', () => {
Expand Down Expand Up @@ -78,9 +78,9 @@ describe('typeOptionsForCategory', () => {

describe('resolveTypeOptions', () => {
it('lets --type win over the filter', () => {
assert.equal(resolveTypeOptions('observability', true).length, 17);
assert.equal(resolveTypeOptions('observability', true).length, 18);
assert.equal(resolveTypeOptions('observability', false).length, 8);
assert.equal(resolveTypeOptions(undefined, false).length, 17);
assert.equal(resolveTypeOptions(undefined, false).length, 18);
});

it('rejects an unknown category even when --type is present', () => {
Expand Down
52 changes: 52 additions & 0 deletions test/integration-connect-posthog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { cliConnectUrl } from '../src/commands/helpers';
import { integrationConnectCommand, typeOptionsForCategory } from '../src/commands/integration/connect';
import { PolylaneAPI } from '../src/generated/client';
import type { Integration } from '../src/generated/types';
import { mockConfig } from './helpers/config';

describe('PostHog CLI connect', () => {
it('offers PostHog as a browser-based product analytics integration', () => {
const option = typeOptionsForCategory('product-analytics').find((item) => item.value === 'posthog');
assert.deepEqual(option, {
value: 'posthog',
label: 'PostHog',
hint: "choose one project on PostHog's consent screen",
category: 'product-analytics',
});
});

it('opens the selected workspace on the console connect page', () => {
const url = new URL(cliConnectUrl(mockConfig({ domain: 'api.baseberry.cc' }), 'posthog', 'ws_one&two'));
assert.equal(url.origin, 'https://console.baseberry.cc');
assert.equal(url.pathname, '/cli/connect');
assert.equal(url.searchParams.get('flow'), 'posthog');
assert.equal(url.searchParams.get('workspace'), 'ws_one&two');
});

for (const existing of [[], [{ id: 'int_first', name: 'First project', updated: 't1' }]]) {
it(`hands off to the browser with ${existing.length} existing PostHog project`, async (t) => {
const writes: string[] = [];
t.mock.method(PolylaneAPI.prototype, 'integrationsList', async (workspaceId, query) => {
assert.equal(workspaceId, 'ws_one');
assert.equal(query?.type, 'posthog');
return { items: existing as Integration[], count: existing.length };
});
t.mock.method(process.stdout, 'write', (chunk) => {
writes.push(String(chunk));
return true;
});
await integrationConnectCommand.execute(
mockConfig({ domain: 'api.baseberry.cc', workspaceId: 'ws_one', output: 'text', quiet: true }),
{},
{ type: 'posthog', noBrowser: true }
);
const url = new URL(writes.join('').trim());
assert.equal(url.origin, 'https://console.baseberry.cc');
assert.equal(url.pathname, '/cli/connect');
assert.equal(url.searchParams.get('flow'), 'posthog');
assert.equal(url.searchParams.get('workspace'), 'ws_one');
});
}
});
Loading