From 9e3e74f6796b485ad22c0094db5410221b8624e5 Mon Sep 17 00:00:00 2001 From: Rafa Audibert Date: Tue, 30 Jun 2026 12:42:44 -0300 Subject: [PATCH] feat(self-driving): provision an account when none exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When self-driving falls back to "no PostHog found", the integration-check screen now asks whether the user already has a PostHog account instead of just confirming setup: - "Yes — log me in" → integrate the SDK and authenticate via OAuth (unchanged default). - "No — create one for me" → collect email + cloud region, flip session.signup, and let authenticate() take the existing provisioning path (create the account, email a login link). No --signup/--email flags needed. Also treat signup as auto-consent in the AI opt-in gate: a provisioning access token omits the organization:read scope, so apiUser stays null and the gate would otherwise never clear (this also fixes the same latent hang in the base `wizard --signup` flow). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/self-driving.ts | 9 +- src/lib/programs/ai-opt-in-gate.ts | 24 ++- src/lib/wizard-session.ts | 14 +- src/ui/tui/__tests__/programs.test.ts | 24 +++ src/ui/tui/__tests__/store.test.ts | 25 +++ .../SelfDrivingIntegrationCheckScreen.tsx | 148 ++++++++++++++++-- src/ui/tui/store.ts | 24 +++ 7 files changed, 242 insertions(+), 26 deletions(-) diff --git a/src/commands/self-driving.ts b/src/commands/self-driving.ts index effa5cbf..5f38f40e 100644 --- a/src/commands/self-driving.ts +++ b/src/commands/self-driving.ts @@ -10,7 +10,7 @@ export const selfDrivingCommand: Command = { ...skillProgramOptions, integrate: { describe: - 'Integrate the PostHog SDK first, then set up Self-driving — skips the "do you already have PostHog?" question. Use when the project isn\'t set up yet.', + 'Integrate the PostHog SDK first, then set up Self-driving — skips the integration prompt and logs you in via OAuth (no "create an account?" question). Use when the project isn\'t set up yet but you already have a PostHog account.', type: 'boolean', default: false, }, @@ -23,9 +23,10 @@ export const selfDrivingCommand: Command = { // or a stalled `wizard_ask` with no bridge under --ci). if (argv.signup) { throw new Error( - '`self-driving` cannot run with --signup. It builds on an existing ' + - 'PostHog integration — run the base `wizard` to create your account ' + - 'and set up PostHog first, then run `wizard self-driving`.', + '`self-driving` cannot run with --signup. Just run `wizard ' + + 'self-driving`: when your project has no PostHog, it asks whether ' + + 'you already have an account and offers to create one for you ' + + '(no flag needed).', ); } if (argv.ci) { diff --git a/src/lib/programs/ai-opt-in-gate.ts b/src/lib/programs/ai-opt-in-gate.ts index c7484d89..d101a02a 100644 --- a/src/lib/programs/ai-opt-in-gate.ts +++ b/src/lib/programs/ai-opt-in-gate.ts @@ -18,9 +18,17 @@ * * The predicates mirror Max's strict reading of * `organization.is_ai_data_processing_approved`: only literal `true` - * proceeds; `null` / `undefined` / `false` all block. CI sessions skip - * the gate — `--ci` auto-consents to AI usage per the README, and the - * interactive kill screen would be unworkable headless. + * proceeds; `null` / `undefined` / `false` all block. CI and signup + * sessions skip the gate: + * - `--ci` auto-consents to AI usage per the README, and the + * interactive kill screen would be unworkable headless. + * - signup (account provisioning) auto-consents too: the provisioning + * access token deliberately omits the `organization:read` scope + * (`WIZARD_PROVISIONING_SCOPES` in constants.ts), so the org's + * approval can never be read back — `apiUser` stays null and the gate + * could never clear. Creating an account through the wizard to run the + * AI agent is itself the consent, mirroring how `shouldDisableAsk` + * already treats `ci || signup` as one non-interactive mode. */ import type { WizardSession } from '@lib/wizard-session'; @@ -53,9 +61,13 @@ export function withAiOptInGate(config: ProgramConfig): ProgramStep[] { // setCredentials and setApiUser there's a brief emitChange window // where apiUser is null, and we don't want to flash the gate then. show: (session) => - !session.ci && session.apiUser != null && !aiApproved(session), - isComplete: (session) => session.ci || aiApproved(session), - gate: (session) => session.ci || aiApproved(session), + !session.ci && + !session.signup && + session.apiUser != null && + !aiApproved(session), + isComplete: (session) => + session.ci || session.signup || aiApproved(session), + gate: (session) => session.ci || session.signup || aiApproved(session), }; return [ diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index ae9c277c..e46b6a78 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -271,11 +271,15 @@ export interface WizardSession { /** * Self-driving only: whether to integrate PostHog as part of this run. - * `null` until decided — the integration-check screen asks "do you already - * have PostHog?" and sets it (No → true, Yes → false). The `--integrate` - * flag pre-sets it to `true`, skipping the question. When `true`, the - * self-driving prompt has the agent set up the SDK before the Self-driving - * steps. Unused by other programs. + * `null` until decided. When detection finds no PostHog SDK, the + * integration-check screen sets this to `true` (Self-driving needs an SDK, + * so we always integrate in that case) — and, on the same screen, asks + * whether the user already has a PostHog account: "yes" leaves `signup` + * false (OAuth login); "no" flips `signup` and collects `email`/`region` + * so auth provisions a new account. The `--integrate` flag pre-sets this to + * `true`, skipping the screen entirely and defaulting to the OAuth login. + * When `true`, the self-driving prompt has the agent set up the SDK before + * the Self-driving steps. Unused by other programs. */ integrate: boolean | null; diff --git a/src/ui/tui/__tests__/programs.test.ts b/src/ui/tui/__tests__/programs.test.ts index dcf57a39..6030b70d 100644 --- a/src/ui/tui/__tests__/programs.test.ts +++ b/src/ui/tui/__tests__/programs.test.ts @@ -208,6 +208,30 @@ describe('PROGRAM_SEQUENCES', () => { expect(entry.show?.(session)).toBe(false); expect(entry.isComplete?.(session)).toBe(true); }); + + it('skips the gate in signup mode regardless of opt-in state', () => { + // A provisioned account's token omits `organization:read`, so the org's + // AI approval can never be read back. Creating an account through the + // wizard to run the agent is itself the consent — signup auto-consents + // like CI, so the gate must never block it. + const session = buildSession({}); + session.signup = true; + session.apiUser = orgWith(false); + const entry = getEntry(Program.SelfDriving, ScreenId.AiOptIn); + + expect(entry.show?.(session)).toBe(false); + expect(entry.isComplete?.(session)).toBe(true); + }); + + it('skips the gate in signup mode even when apiUser is null', () => { + const session = buildSession({}); + session.signup = true; + const entry = getEntry(Program.SelfDriving, ScreenId.AiOptIn); + + expect(session.apiUser).toBeNull(); + expect(entry.show?.(session)).toBe(false); + expect(entry.isComplete?.(session)).toBe(true); + }); }); describe('Wizard run predicate', () => { diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index 740f123c..6b18a201 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -1347,4 +1347,29 @@ describe('WizardStore', () => { expect(buildSession({ integrate: true }).integrate).toBe(true); }); }); + + describe('chooseProvisionAccount (self-driving "no account" branch)', () => { + it('flips signup and records email + region, and integrates', () => { + const store = createStore(Program.SelfDriving); + store.session = buildSession({}); + + store.chooseProvisionAccount('dev@example.com', 'eu'); + + expect(store.session.signup).toBe(true); + expect(store.session.email).toBe('dev@example.com'); + expect(store.session.region).toBe('eu'); + expect(store.session.integrate).toBe(true); + }); + + it('emits exactly one change event', () => { + const store = createStore(Program.SelfDriving); + store.session = buildSession({}); + const cb = vi.fn(); + store.subscribe(cb); + + store.chooseProvisionAccount('dev@example.com', 'us'); + + expect(cb).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx b/src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx index d8b8da4b..033f5156 100644 --- a/src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx +++ b/src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx @@ -1,15 +1,25 @@ /** * SelfDrivingIntegrationCheckScreen — shown only when detection found no - * PostHog in the project. It's a notice, not a question: Self-driving requires - * a PostHog SDK, so the single action sets `integrate = true` and the run - * integrates first. Skipped entirely when PostHog is already present (or under - * `--integrate`). + * PostHog in the project. Self-driving needs a PostHog SDK, so we always + * integrate first; but a project with no SDK is often a project with no PostHog + * account either, so this screen first asks whether the user already has one: + * + * - "Yes — log me in" → setIntegrate(true); auth runs the OAuth login. + * - "No — create one for me" → collect email + region, then + * chooseProvisionAccount(): auth provisions a new + * account (and emails a login link) instead. + * + * Both answers integrate the SDK; they only differ in how auth gets credentials. + * Skipped entirely when PostHog is already present (or under `--integrate`, + * which forces integration + the default OAuth login). */ -import { Box, Text } from 'ink'; -import { useSyncExternalStore } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { TextInput } from '@inkjs/ui'; +import { useState, useSyncExternalStore } from 'react'; import type { WizardStore } from '@ui/tui/store'; +import type { CloudRegion } from '@lib/wizard-session'; import { PickerMenu } from '@ui/tui/primitives/index'; import { Colors } from '@ui/tui/styles'; @@ -17,6 +27,11 @@ interface SelfDrivingIntegrationCheckScreenProps { store: WizardStore; } +/** Multi-step screen state: pick account status → email → region. */ +type Stage = 'ask' | 'email' | 'region'; + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + export const SelfDrivingIntegrationCheckScreen = ({ store, }: SelfDrivingIntegrationCheckScreenProps) => { @@ -25,6 +40,99 @@ export const SelfDrivingIntegrationCheckScreen = ({ () => store.getSnapshot(), ); + const [stage, setStage] = useState('ask'); + // Pre-fill from `--email` when it was passed; tracked across stages so the + // region step can hand both to the store in one commit. + const [email, setEmail] = useState(store.session.email ?? ''); + const [emailError, setEmailError] = useState(null); + + // Esc steps back toward the account question (the picker owns input on 'ask'). + useInput((_input, key) => { + if (key.escape && stage !== 'ask') { + setEmailError(null); + setStage(stage === 'region' ? 'email' : 'ask'); + } + }); + + if (stage === 'region') { + return ( + + + Where should we create your account? + + + + We'll create your PostHog account for {email} in this region. + + + + { + const region = ( + Array.isArray(value) ? value[0] : value + ) as CloudRegion; + store.chooseProvisionAccount(email.trim(), region); + }} + /> + + + + [Esc] back + + + + ); + } + + if (stage === 'email') { + return ( + + + Create your PostHog account + + + + We'll create your account and email you a login link. What + email should we use? + + + + { + const trimmed = value.trim(); + if (!EMAIL_RE.test(trimmed)) { + setEmailError('Please enter a valid email address.'); + return; + } + setEmail(trimmed); + setEmailError(null); + setStage('region'); + }} + /> + + {emailError && ( + + {emailError} + + )} + + + [Enter] continue{' · '} + [Esc] back + + + + ); + } + return ( @@ -33,16 +141,34 @@ export const SelfDrivingIntegrationCheckScreen = ({ - We didn't find an existing PostHog integration in your project. - Before you can self-drive, you'll need to integrate PostHog into - your project to capture events and generate signals. + We didn't find PostHog in your project, so we'll set it up + first. To do that we need to connect to PostHog — do you already have + an account? store.setIntegrate(true)} + options={[ + { + label: 'Yes — log me in', + value: 'login', + hint: 'opens PostHog to authorize', + }, + { + label: 'No — create one for me', + value: 'provision', + hint: 'we’ll email you a login link', + }, + ]} + onSelect={(value) => { + const choice = Array.isArray(value) ? value[0] : value; + if (choice === 'login') { + store.setIntegrate(true); + } else { + setStage('email'); + } + }} /> diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 822ef1fc..1795874a 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -22,6 +22,7 @@ import { type DiscoveredFeature, type PendingQuestion, type AskAnswers, + type CloudRegion, AdditionalFeature, McpOutcome, RunPhase, @@ -705,6 +706,29 @@ export class WizardStore { this.emitChange(); } + /** + * Self-driving "no PostHog account" branch of the integration check. The + * project has no SDK, so we always integrate (`integrate = true`); and since + * the user has no account, we flip `signup` and record the `email` / `region` + * collected on the screen so `authenticate` → `getOrAskForProjectData` takes + * the provisioning path (create account + email a login link) instead of + * OAuth. The "yes, I have an account" branch uses `setIntegrate(true)` and + * leaves `signup` false so auth runs the normal OAuth login. + */ + chooseProvisionAccount(email: string, region: CloudRegion): void { + this.$session.setKey('signup', true); + this.$session.setKey('email', email); + this.$session.setKey('region', region); + this.$session.setKey('integrate', true); + analytics.wizardCapture('self-driving integration check', { + self_driving_integrate: true, + self_driving_has_account: false, + provision_region: region, + ...sessionProperties(this.session), + }); + this.emitChange(); + } + /** * Self-driving handoff confirmed — the user acknowledged the post-integration * screen, so the Self-driving run can begin. Gate resolves via _checkGates().