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
6 changes: 6 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ vi.mock('../lib/programs/posthog-integration/index', () => ({
steps: [],
run: null,
},
integrationRunStep: {
id: 'run',
label: 'Integration',
screenId: 'run',
run: () => Promise.resolve(),
},
}));
vi.mock('../utils/environment', () => ({
isNonInteractiveEnvironment: () => false,
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/provision-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ vi.mock('../lib/programs/posthog-integration/index', () => ({
steps: [],
run: null,
},
integrationRunStep: {
id: 'run',
label: 'Integration',
screenId: 'run',
run: () => Promise.resolve(),
},
}));
vi.mock('../utils/environment', () => ({
isNonInteractiveEnvironment: () => false,
Expand Down
6 changes: 6 additions & 0 deletions src/commands/self-driving.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export const selfDrivingCommand: Command = {
description: selfDrivingConfig.description,
options: {
...skillProgramOptions,
integrate: {
Comment thread
sortafreel marked this conversation as resolved.
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.',
type: 'boolean',
default: false,
},
...(selfDrivingConfig.cliOptions ?? {}),
},
check: (argv) => {
Expand Down
61 changes: 61 additions & 0 deletions src/lib/agent/runner/shared/authenticate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Authenticate the wizard — once per invocation.
*
* Idempotent: when `session.credentials` is already set, this is a no-op. So a
* second agent run in the same invocation (e.g. self-driving runs the
* integration program as a phase, then the Self-driving run) reuses the first
* login instead of launching another OAuth — a second OAuth re-prompts and
* fails with a 400 (the first authorization code is already spent). The first
* call stores the full result on the session so any later bootstrap reads it
* back rather than fetching again.
*/

import type { WizardSession } from '@lib/wizard-session';
import type { ProgramId } from '@lib/programs/program-registry';
import { getOrAskForProjectData } from '@utils/setup-utils';
import { analytics, groupsFromUser } from '@utils/analytics';
import { getUI } from '@ui';
import { logToFile } from '@utils/debug';

export async function authenticate(
session: WizardSession,
programId: ProgramId,
): Promise<void> {
if (session.credentials) return;

logToFile('[agent-runner] starting OAuth');
const {
projectApiKey,
host,
accessToken,
projectId,
cloudRegion,
roleAtOrganization,
user,
project,
} = await getOrAskForProjectData({
signup: session.signup,
ci: session.ci,
apiKey: session.apiKey,
projectId: session.projectId,
email: session.email,
region: session.region,
baseUrl: session.baseUrl,
programId,
});

session.credentials = { accessToken, projectApiKey, host, projectId };
session.cloudRegion = cloudRegion;
session.apiProject = project;
session.roleAtOrganization = roleAtOrganization;
session.apiUser = user;

getUI().setCredentials(session.credentials);
getUI().setRoleAtOrganization(roleAtOrganization);
getUI().setApiUser(user);

// Identify the user (email, name) before flags are evaluated, so flags can
// target the individual user and not just $app_name.
if (user) analytics.identifyUser(user);
analytics.setGroups(groupsFromUser(user, host));
}
45 changes: 10 additions & 35 deletions src/lib/agent/runner/shared/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
*/

import type { WizardSession } from '@lib/wizard-session';
import { getOrAskForProjectData } from '@utils/setup-utils';
import { analytics, groupsFromUser } from '@utils/analytics';
import { analytics } from '@utils/analytics';
import { getUI } from '@ui';
import { authenticate } from './authenticate';
import { buildRunTags } from '@lib/agent/agent-interface';
import {
checkAllSettingsConflicts,
Expand Down Expand Up @@ -210,39 +210,14 @@ export async function bootstrapProgram(
skill_id: config.skillId ?? null,
});

// 4. OAuth
logToFile('[agent-runner] starting OAuth');
const {
projectApiKey,
host,
accessToken,
projectId,
cloudRegion,
roleAtOrganization,
user,
project,
} = await getOrAskForProjectData({
signup: session.signup,
ci: session.ci,
apiKey: session.apiKey,
projectId: session.projectId,
email: session.email,
region: session.region,
baseUrl: session.baseUrl,
programId: programConfig.id,
});

session.credentials = { accessToken, projectApiKey, host, projectId };
session.roleAtOrganization = roleAtOrganization;
session.apiUser = user;
getUI().setCredentials(session.credentials);
getUI().setRoleAtOrganization(roleAtOrganization);
getUI().setApiUser(user);

// Identify the user (email, name) before evaluating flags, so flags can target
// the individual user and not just $app_name.
if (user) analytics.identifyUser(user);
analytics.setGroups(groupsFromUser(user, host));
// 4. Authenticate — idempotent within a run (see authenticate()). A second
// agent run in the same invocation (self-driving's integration phase) reuses
// the first login; it does not launch another OAuth. authenticate() also
// identifies the user and sets analytics groups.
await authenticate(session, programConfig.id);
const { projectApiKey, host, accessToken, projectId } = session.credentials!;
const cloudRegion = session.cloudRegion!;
const project = session.apiProject;

// 4.5. AI opt-in enforcement. Parks here while AiOptInRequiredScreen is
// up if the org hasn't approved third-party AI — BEFORE the skill
Expand Down
112 changes: 109 additions & 3 deletions src/lib/programs/__tests__/self-driving-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
selfDrivingConfig,
SELF_DRIVING_ABORT_CASES,
} from '@lib/programs/self-driving/index';
import { detectPostHogPresent } from '@lib/programs/self-driving/detect';
import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools';
import { buildSession } from '@lib/wizard-session';
import type { Mock } from 'vitest';
Expand Down Expand Up @@ -97,10 +98,12 @@ describe('selfDrivingConfig', () => {
expect(typeof last === 'object' ? last.pause : undefined).toBe(5000);
});

it('gives wizard_ask a 30-min timeout for the browser-handoff steps', () => {
it('gives wizard_ask a 30-min timeout for the browser-handoff steps', async () => {
// `run` is resolved per-session so the prompt can carry the integrate flag.
const { run } = selfDrivingConfig;
const timeout = typeof run === 'object' ? run.askTimeoutMs : undefined;
expect(timeout).toBe(30 * 60 * 1000);
const resolved =
typeof run === 'function' ? await run(buildSession({})) : run;
expect(resolved?.askTimeoutMs).toBe(30 * 60 * 1000);
});

it('wires the self-driving-setup skill and CLI command', () => {
Expand All @@ -116,10 +119,113 @@ describe('selfDrivingConfig', () => {
expect(stepIds).toEqual([
'detect',
'intro',
'integration-check',
'health-check',
'auth',
'integrate-detect',
'integrate-run',
'self-driving-handoff',
'run',
'outro',
]);
});
});

describe('detectPostHogPresent', () => {
it('returns true when a manifest declares a PostHog package', () => {
const dir = makeTmpDir();
try {
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ dependencies: { 'posthog-node': '^4.0.0' } }),
);
expect(detectPostHogPresent(dir)).toBe(true);
} finally {
cleanup(dir);
}
});

it('returns false when no manifest mentions PostHog', () => {
const dir = makeTmpDir();
try {
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ dependencies: { express: '^4.0.0' } }),
);
expect(detectPostHogPresent(dir)).toBe(false);
} finally {
cleanup(dir);
}
});

it('does not match "posthog" glued inside a larger package name', () => {
// Only fires at a dependency boundary, so `posthog` glued inside another
// package name isn't a false positive. (A bare word in prose still matches.)
const dir = makeTmpDir();
try {
fs.writeFileSync(
path.join(dir, 'requirements.txt'),
'myposthogtool==1.0.0\n',
);
expect(detectPostHogPresent(dir)).toBe(false);
} finally {
cleanup(dir);
}
});

it('detects PostHog declared as a dependency across manifest formats', () => {
const cases: Array<[string, string]> = [
['package.json', '{"dependencies":{"posthog-node":"^4.0.0"}}'],
['requirements.txt', 'flask==3.0\nposthog==3.7.0\n'],
['Gemfile', "source 'https://rubygems.org'\ngem 'posthog'\n"],
['go.mod', 'module x\nrequire github.com/posthog/posthog-go v1.2.0\n'],
['pubspec.yaml', 'dependencies:\n posthog: ^4.0.0\n'],
];
for (const [name, contents] of cases) {
const dir = makeTmpDir();
try {
fs.writeFileSync(path.join(dir, name), contents);
expect(detectPostHogPresent(dir), name).toBe(true);
} finally {
cleanup(dir);
}
}
});

it('detects PostHog in a common sub-app dir (monorepo)', () => {
const dir = makeTmpDir();
try {
fs.mkdirSync(path.join(dir, 'frontend'));
fs.writeFileSync(
path.join(dir, 'frontend', 'package.json'),
'{"dependencies":{"posthog-js":"^1.0.0"}}',
);
expect(detectPostHogPresent(dir)).toBe(true);
} finally {
cleanup(dir);
}
});

it('does not scan arbitrary nested dirs (no recursive walk)', () => {
const dir = makeTmpDir();
try {
fs.mkdirSync(path.join(dir, 'services', 'api'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'services', 'api', 'package.json'),
'{"dependencies":{"posthog-node":"^4.0.0"}}',
);
expect(detectPostHogPresent(dir)).toBe(false);
} finally {
cleanup(dir);
}
});

it('returns false for an empty project', () => {
const dir = makeTmpDir();
try {
expect(detectPostHogPresent(dir)).toBe(false);
} finally {
cleanup(dir);
}
});
});
21 changes: 21 additions & 0 deletions src/lib/programs/__tests__/self-driving-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { buildSelfDrivingPrompt } from '@lib/programs/self-driving/prompt';
import type { PromptContext } from '@lib/agent/agent-runner';

const ctx: PromptContext = {
projectId: 123,
projectApiKey: 'phc_test',
host: 'https://us.posthog.com',
};

describe('buildSelfDrivingPrompt', () => {
it('covers only the Self-driving steps — integration is a separate phase', () => {
const prompt = buildSelfDrivingPrompt(ctx);
// No SDK-integration step in the prompt; that runs as the prelude program.
expect(prompt).not.toContain('STEP 0');
expect(prompt).not.toContain('Integrate the PostHog SDK');
expect(prompt).not.toContain('load_skill_menu');
// The Self-driving steps are present.
expect(prompt).toContain('STEP 1 — Check Self-driving access');
expect(prompt).toContain('Connect GitHub');
});
});
23 changes: 20 additions & 3 deletions src/lib/programs/posthog-integration/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { ProgramConfig } from '@lib/programs/program-step';
import type { ProgramRun } from '@lib/agent/agent-runner';
import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step';
import { runAgent, type ProgramRun } from '@lib/agent/agent-runner';
import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools';
import type { WizardSession } from '@lib/wizard-session';
import { OutroKind } from '@lib/wizard-session';
import { OutroKind, RunPhase } from '@lib/wizard-session';
import { AgentSignals } from '@lib/agent/agent-interface';
import {
DEFAULT_PACKAGE_INSTALLATION,
Expand Down Expand Up @@ -277,3 +277,20 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server
};

export { POSTHOG_INTEGRATION_PROGRAM } from './steps.js';

/**
* Self-contained run step that runs the integration agent. Other programs
* import this and splice it into their own step list to compose the
* integration's work as one of their run steps — self-driving sets up PostHog
* this way before its own run. The host program supplies `show`/`onRunPrep`/
* `targetDir`; this carries the run.
*/
export const integrationRunStep: ProgramStep = {
id: 'run',
label: 'Integration',
screenId: 'run',
run: (session) => runAgent(posthogIntegrationConfig, session),
Comment thread
sortafreel marked this conversation as resolved.
isComplete: (session) =>
session.runPhase === RunPhase.Completed ||
session.runPhase === RunPhase.Error,
};
24 changes: 24 additions & 0 deletions src/lib/programs/program-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,30 @@ export interface ProgramStep {
*/
screenId?: string;

/**
* For a run step (`screenId: 'run'`): runs this step's own agent. A program
* exports a self-contained run step and another imports it into its step list
* — e.g. posthog-integration exports a run step that runs its agent, and
* self-driving imports it before its own run step. Omit to run the host
* program's own agent (`config.run`).
*/
run?: (session: WizardSession) => Promise<void>;

/**
* For a run step: prepare a derived session before its agent runs — e.g.
* gather framework context for the chosen project. The session it receives is
* the run's own, so writes don't leak into later runs.
*/
onRunPrep?: (session: WizardSession) => Promise<void>;

/**
* For a run step: the working directory its agent runs in, resolved from the
* session (e.g. self-driving's integration runs in the picked monorepo
* sub-app, not the repo root). The runner scopes a derived session to this
* dir for that run only. Defaults to `session.installDir`.
*/
targetDir?: (session: WizardSession) => string;

/**
* Whether this step should be visible in the current program.
* If omitted, the step is always visible.
Expand Down
Loading
Loading