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
9 changes: 5 additions & 4 deletions src/commands/self-driving.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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) {
Expand Down
24 changes: 18 additions & 6 deletions src/lib/programs/ai-opt-in-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 [
Expand Down
14 changes: 9 additions & 5 deletions src/lib/wizard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
24 changes: 24 additions & 0 deletions src/ui/tui/__tests__/programs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
25 changes: 25 additions & 0 deletions src/ui/tui/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
148 changes: 137 additions & 11 deletions src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
/**
* 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';

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) => {
Expand All @@ -25,6 +40,99 @@ export const SelfDrivingIntegrationCheckScreen = ({
() => store.getSnapshot(),
);

const [stage, setStage] = useState<Stage>('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<string | null>(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 (
<Box flexDirection="column" flexGrow={1}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we split this UI into it's own component? Flow is confusing to read. This UI shows up after you select log me in vs sign up right?

<Text bold color={Colors.accent}>
Where should we create your account?
</Text>
<Box marginTop={1} flexDirection="column">
<Text dimColor>
We&apos;ll create your PostHog account for {email} in this region.
</Text>
</Box>
<Box marginTop={1}>
<PickerMenu
message="Pick your cloud region"
options={[
{ label: 'US', value: 'us', hint: 'us.posthog.com' },
{ label: 'EU', value: 'eu', hint: 'eu.posthog.com' },
]}
onSelect={(value) => {
const region = (
Array.isArray(value) ? value[0] : value
) as CloudRegion;
store.chooseProvisionAccount(email.trim(), region);
}}
/>
</Box>
<Box marginTop={1}>
<Text dimColor>
<Text color={Colors.accent}>[Esc]</Text> back
</Text>
</Box>
</Box>
);
}

if (stage === 'email') {
return (
<Box flexDirection="column" flexGrow={1}>
<Text bold color={Colors.accent}>
Create your PostHog account
</Text>
<Box marginTop={1} flexDirection="column">
<Text dimColor>
We&apos;ll create your account and email you a login link. What
email should we use?
</Text>
</Box>
<Box marginTop={1} width="100%">
<TextInput
placeholder="you@company.com"
defaultValue={email}
onChange={setEmail}
onSubmit={(value) => {
const trimmed = value.trim();
if (!EMAIL_RE.test(trimmed)) {
setEmailError('Please enter a valid email address.');
return;
}
setEmail(trimmed);
setEmailError(null);
setStage('region');
}}
/>
</Box>
{emailError && (
<Box marginTop={1}>
<Text color="yellow">{emailError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text dimColor>
<Text color={Colors.accent}>[Enter]</Text> continue{' · '}
<Text color={Colors.accent}>[Esc]</Text> back
</Text>
</Box>
</Box>
);
}

return (
<Box flexDirection="column" flexGrow={1}>
<Text bold color={Colors.accent}>
Expand All @@ -33,16 +141,34 @@ export const SelfDrivingIntegrationCheckScreen = ({

<Box marginTop={1} flexDirection="column">
<Text dimColor>
We didn&apos;t find an existing PostHog integration in your project.
Before you can self-drive, you&apos;ll need to integrate PostHog into
your project to capture events and generate signals.
We didn&apos;t find PostHog in your project, so we&apos;ll set it up
first. To do that we need to connect to PostHog — do you already have
an account?
</Text>
</Box>

<Box marginTop={1}>
<PickerMenu
options={[{ label: 'Set up PostHog [Enter]', value: 'yes' }]}
onSelect={() => 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');
}
}}
/>
</Box>
</Box>
Expand Down
24 changes: 24 additions & 0 deletions src/ui/tui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type DiscoveredFeature,
type PendingQuestion,
type AskAnswers,
type CloudRegion,
AdditionalFeature,
McpOutcome,
RunPhase,
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this method name. I understand it's used by the provision path, but this is business logic about how that provisioning works.

This business logic should live with the provisioning code and that provisioning code should update the store from there.

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().
Expand Down
Loading