diff --git a/README.md b/README.md index 40390bf..ddb6820 100644 --- a/README.md +++ b/README.md @@ -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 # see `polylane integration connect --help` +polylane integration connect --type posthog # choose a project; repeat to add another polylane cloud connect --provider # see `polylane cloud connect --help` # 4. Work diff --git a/skill/SKILL.md b/skill/SKILL.md index b8219ce..a34fe05 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -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 diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index bfc8332..198a650 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -51,6 +51,7 @@ type ConnectableType = | 'grafana' | 'logfire' | 'mixpanel' + | 'posthog' | 'devin' | 'cursor' | 'factory' @@ -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' }, @@ -647,7 +649,7 @@ async function connectWithCredentials( api: PolylaneAPI, args: Record, workspaceId: string, - type: Exclude + type: Exclude ): Promise { let body: ConnectBody; if (type === 'datadog') { @@ -1125,15 +1127,15 @@ async function connectType( ): Promise { // --- 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') { @@ -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: [ { @@ -1216,8 +1218,8 @@ export const integrationConnectCommand: Command = { { flag: '--extra-headers ', description: 'MCP extra headers as JSON object', type: 'string' }, { flag: '--oauth', description: 'MCP: use OAuth flow (opens browser to authorize)', type: 'boolean' }, { flag: '--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', @@ -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"', diff --git a/test/integration-connect-category.test.ts b/test/integration-connect-category.test.ts index f0c7464..2da2c36 100644 --- a/test/integration-connect-category.test.ts +++ b/test/integration-connect-category.test.ts @@ -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', () => { @@ -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', () => { @@ -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', () => { diff --git a/test/integration-connect-posthog.test.ts b/test/integration-connect-posthog.test.ts new file mode 100644 index 0000000..e780e67 --- /dev/null +++ b/test/integration-connect-posthog.test.ts @@ -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'); + }); + } +});