diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b42f8dc17..8f82efe04 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -49,3 +49,6 @@ jobs: - name: Test the PR evaluator run: pnpm test:evaluator + + - name: Test the headless wizard harness + run: pnpm test:wizard-program diff --git a/README.md b/README.md index eb5068590..d4527b568 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ services/ ├── wizard-ci/ # Automated wizard runs with PR creation ├── wizard-run/ # Interactive wizard runner ├── mcp-stub/ # Stub PostHog MCP server for warehouse e2e runs +├── wizard-program/ # Headless runProgram / runAgent against a wizard checkout ├── wizard-commands.ts # Registry of wizard commands (integration, revenue, …) └── github/ # GitHub/git utilities ``` @@ -298,6 +299,36 @@ You can activate `wizard-ci.yml` in a few ways: 2. **Schedule** - Runs on cron 3. **Dispatch** - Webhook call via `repository_dispatch` with event type `wizard-ci-trigger` +## Headless wizard runs + +`services/wizard-program/` runs one wizard program through `runProgram`, or one +agent through `runAgent`, in this process with no TUI. It imports them from the +wizard checkout in `WIZARD_REPO`. + +```bash +# Resolve the wizard modules and exit. Reads no credentials, makes no request. +WIZARD_REPO=~/development/wizard pnpm wizard-program --check +WIZARD_REPO=~/development/wizard pnpm wizard-agent --check + +# One program on an app copy. PROGRAM picks it and defaults to posthog-integration. +WIZARD_REPO=… APP_DIR=/tmp/app-copy PROJECT_ID=… POSTHOG_KEY_FILE=… \ + pnpm wizard-program + +# One agent run on a local `quack` skill, in its own empty directory. PROGRAM +# sets the program id the gateway token is minted under, default posthog-integration. +WIZARD_REPO=… PROJECT_ID=… POSTHOG_KEY_FILE=… pnpm wizard-agent +``` + +- The scripts start tsx with `--tsconfig "$WIZARD_REPO/tsconfig.json"`, so the + wizard's path aliases (`@programs`, `@agent`, `@shared/*`) resolve against + that checkout. Set `WIZARD_REPO` in the shell: the scripts read it before tsx + starts, so `.env` cannot set it. It is separate from `WIZARD_PATH`. +- Runs without `--check` are live and credentialed. `POSTHOG_PERSONAL_API_KEY` + works in place of `POSTHOG_KEY_FILE`. The wizard's runner mints its own + gateway token from that key. +- Point `APP_DIR` at a copy, never at a fixture in `apps/`. The run edits it. +- Set `E2E_RESULT_JSON` to a path to get the result as JSON. + --- ## Running with a proxy diff --git a/package.json b/package.json index 1d7d4a00c..121a884d4 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,12 @@ "yara-scan": "tsx services/yara-scan/index.ts", "mcp-stub": "tsx services/mcp-stub/cli.ts", "mcp-stub:record": "tsx services/mcp-stub/record.ts", + "wizard-program": "tsx --tsconfig \"${WIZARD_REPO:?set WIZARD_REPO to a wizard checkout}/tsconfig.json\" services/wizard-program/run-program.ts", + "wizard-agent": "tsx --tsconfig \"${WIZARD_REPO:?set WIZARD_REPO to a wizard checkout}/tsconfig.json\" services/wizard-program/run-agent.ts", "test:evaluator": "tsx --test services/pr-evaluator/evaluator.test.ts", "test:mcp-stub": "tsx --test services/mcp-stub/mcp-stub.test.ts", - "test:warehouse-checks": "tsx --test services/wizard-ci/warehouse-checks.test.ts" + "test:warehouse-checks": "tsx --test services/wizard-ci/warehouse-checks.test.ts", + "test:wizard-program": "tsx --test services/wizard-program/harness.test.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.73", diff --git a/services/wizard-program/harness.test.ts b/services/wizard-program/harness.test.ts new file mode 100644 index 000000000..b2f4a74f5 --- /dev/null +++ b/services/wizard-program/harness.test.ts @@ -0,0 +1,56 @@ +/** + * Pins the env contract both headless wizard routes share, so a route never + * starts a live run with an input the other would reject. + * + * pnpm test:wizard-program + */ + +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { it, mock } from "node:test"; + +import { readE2eEnv, readPersonalApiKey } from "./harness.js"; + +const appDir = mkdtempSync(join(tmpdir(), "wizard-program-")); +const complete = { + APP_DIR: appDir, + POSTHOG_PERSONAL_API_KEY: "phx_inline", + PROJECT_ID: "228144", +}; + +it("prefers the inline key and falls back to the key file when it is blank", () => { + const readFile = mock.fn((_file: string) => " phx_file \n"); + assert.equal(readPersonalApiKey(complete, readFile), "phx_inline"); + assert.equal( + readPersonalApiKey({ POSTHOG_PERSONAL_API_KEY: " ", POSTHOG_KEY_FILE: "/keys/phx" }, readFile), + "phx_file", + ); + assert.deepEqual( + readFile.mock.calls.map((call) => call.arguments), + [["/keys/phx"]], + ); +}); + +it("reads a complete env", () => { + assert.deepEqual(readE2eEnv(complete), { + appDir, + apiKey: "phx_inline", + projectId: 228144, + }); +}); + +it("lets the agent route run without an app directory", () => { + assert.equal(readE2eEnv({ ...complete, APP_DIR: "" }, { needsAppDir: false }).appDir, ""); +}); + +for (const [name, override] of [ + ["APP_DIR", { APP_DIR: join(appDir, "missing") }], + ["POSTHOG_PERSONAL_API_KEY", { POSTHOG_PERSONAL_API_KEY: "" }], + ["PROJECT_ID", { PROJECT_ID: "0" }], +] as const) { + it(`names a missing ${name}`, () => { + assert.throws(() => readE2eEnv({ ...complete, ...override }), new RegExp(name)); + }); +} diff --git a/services/wizard-program/harness.ts b/services/wizard-program/harness.ts new file mode 100644 index 000000000..1a3f19dc3 --- /dev/null +++ b/services/wizard-program/harness.ts @@ -0,0 +1,240 @@ +/** + * Shared inputs and outputs for the headless wizard routes, `run-program.ts` + * and `run-agent.ts`. Each runs one wizard surface in this process. + * + * The wizard is not a dependency of this repo. Its source comes from the + * checkout in `WIZARD_REPO`, through the wizard's own path aliases (`@programs`, + * `@agent`, `@shared/*`). The package scripts start tsx with + * `--tsconfig "$WIZARD_REPO/tsconfig.json"`, so tsx resolves those aliases + * against that checkout, here and inside every wizard file. + * + * Every route reads the same env: a PostHog personal key from + * `POSTHOG_PERSONAL_API_KEY` or `POSTHOG_KEY_FILE`, and a project from + * `PROJECT_ID`. The wizard's runner mints its own gateway token from that key. + * The program route also takes `APP_DIR`. The agent route makes its own empty + * directory. `--check` exits once the wizard modules load, before any of that + * env is read and before any request. + */ + +import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** `--check`: load the wizard modules a run needs, print where they resolved, exit. */ +export const CHECK = process.argv.includes("--check"); + +export type E2eEnv = { + /** Empty when the route makes its own working directory. */ + appDir: string; + apiKey: string; + projectId: number; +}; + +/** The progress events the wizard's `AgentProgress` carries, typed here by hand. */ +export type AgentProgress = + | { kind: "log"; message: string } + | { kind: "status"; message: string } + | { kind: "stage"; stage: string } + | { kind: "tasks"; tasks: { status: string; content: string }[] } + | { kind: "url"; which: string; url: string } + | { kind: "authError" } + | { + kind: + | "lifecycle" + | "spinner" + | "usage" + | "finalCost" + | "handoff" + | "completion" + | "activity"; + }; + +/** The wizard's `@shared` modules the credential step calls. */ +export type SharedModules = { + api: { + fetchProjectData( + apiKey: string, + projectId: number, + baseUrl: string, + ): Promise<{ api_token: string }>; + fetchUserData(apiKey: string, baseUrl: string): Promise; + }; + hosts: { + HostResolution: { + fromAccessToken( + apiKey: string, + options: { region: string }, + ): Promise<{ appHost: string }>; + }; + }; +}; + +export type E2eCredentials = { + posthog: { + accessToken: string; + projectApiKey: string; + host: unknown; + projectId: number; + }; + project: unknown; + apiUser: unknown; +}; + +/** Where each wizard module resolved, for `--check`. */ +const loaded: { specifier: string; file: string }[] = []; + +/** The wizard checkout the package script handed to tsx. */ +export function wizardRepo(): string { + const repo = process.env.WIZARD_REPO?.trim(); + if (!repo || !existsSync(join(repo, "tsconfig.json"))) + throw new Error("Set WIZARD_REPO to a wizard checkout with a tsconfig.json"); + return realpathSync(resolve(repo)); +} + +/** `file` relative to the wizard checkout, or null when it sits outside it. */ +function inWizardRepo(file: string): string | null { + const inRepo = relative(wizardRepo(), realpathSync(file)); + return inRepo.startsWith("..") || isAbsolute(inRepo) ? null : inRepo; +} + +/** + * Import one wizard module through its alias. Fails unless the alias resolves + * inside `WIZARD_REPO` and the module exports every name in `names`. + */ +export async function importWizard( + specifier: string, + names: readonly (keyof T & string)[], +): Promise { + let url: string; + try { + url = import.meta.resolve(specifier); + } catch { + throw new Error( + `${specifier} did not resolve. Start through the package script, which runs tsx with --tsconfig "$WIZARD_REPO/tsconfig.json".`, + ); + } + const file = inWizardRepo(fileURLToPath(url)); + if (!file) throw new Error(`${specifier} resolved to ${url}, outside WIZARD_REPO ${wizardRepo()}`); + loaded.push({ specifier, file }); + const module = (await import(url)) as Record; + const missing = names.filter((name) => module[name] === undefined); + if (missing.length > 0) + throw new Error(`${specifier} in ${wizardRepo()} exports no ${missing.join(", ")}`); + return module as T; +} + +/** Load a package from the wizard's own dependencies, not the workbench's. */ +export function requireWizardDependency(name: string): T { + const wizardRequire = createRequire(join(wizardRepo(), "package.json")); + const file = wizardRequire.resolve(name); + loaded.push({ specifier: name, file: inWizardRepo(file) ?? file }); + return wizardRequire(name) as T; +} + +/** Load the `@shared` modules `resolveE2eCredentials` calls. */ +export async function importSharedModules(): Promise { + return { + api: await importWizard("@shared/api", [ + "fetchProjectData", + "fetchUserData", + ]), + hosts: await importWizard( + "@shared/host-resolution", + ["HostResolution"], + ), + }; +} + +/** Print where every wizard module resolved and exit, before any env read or request. */ +export function exitAfterCheck(route: string): never { + console.log(`${route}: wizard modules resolve from ${wizardRepo()}`); + for (const { specifier, file } of loaded) console.log(` ${specifier} -> ${file}`); + process.exit(0); +} + +/** A blank variable counts as unset, so the key file is the fallback. */ +export function readPersonalApiKey( + env: NodeJS.ProcessEnv, + readFile: (file: string) => string = (file) => readFileSync(file, "utf8"), +): string { + const inline = env.POSTHOG_PERSONAL_API_KEY?.trim(); + if (inline) return inline; + const file = env.POSTHOG_KEY_FILE?.trim(); + return file ? readFile(file).trim() : ""; +} + +/** Throws one message listing every missing input, before any run starts. */ +export function readE2eEnv( + env: NodeJS.ProcessEnv, + { needsAppDir = true }: { needsAppDir?: boolean } = {}, +): E2eEnv { + const missing: string[] = []; + const appDir = env.APP_DIR?.trim() ?? ""; + if (needsAppDir && (!appDir || !existsSync(appDir))) + missing.push("APP_DIR: an existing app copy, never the fixture in apps/"); + let apiKey = ""; + try { + apiKey = readPersonalApiKey(env); + } catch { + // An unreadable key file is reported as a missing key below. + } + if (!apiKey) missing.push("POSTHOG_PERSONAL_API_KEY or a readable POSTHOG_KEY_FILE"); + const projectId = Number(env.PROJECT_ID); + if (!Number.isInteger(projectId) || projectId <= 0) + missing.push("PROJECT_ID: a positive project id"); + if (missing.length > 0) throw new Error(`Missing e2e inputs:\n- ${missing.join("\n- ")}`); + return { appDir, apiKey, projectId }; +} + +/** + * Resolve PostHog credentials from the personal key through the wizard's + * `@shared` modules only, so the agent route loads nothing from programs, the + * TUI or the CLI. + */ +export async function resolveE2eCredentials( + e2e: E2eEnv, + shared: SharedModules, +): Promise { + const host = await shared.hosts.HostResolution.fromAccessToken(e2e.apiKey, { + region: "us", + }); + const project = await shared.api.fetchProjectData(e2e.apiKey, e2e.projectId, host.appHost); + const apiUser = await shared.api.fetchUserData(e2e.apiKey, host.appHost).catch(() => null); + return { + posthog: { + accessToken: e2e.apiKey, + projectApiKey: project.api_token, + host, + projectId: e2e.projectId, + }, + project, + apiUser, + }; +} + +/** Write the route's result to `E2E_RESULT_JSON`, when set, for a caller to assert on. */ +export function writeE2eResult(result: Record): void { + const file = process.env.E2E_RESULT_JSON; + if (file) writeFileSync(file, JSON.stringify(result, null, 2)); +} + +/** One line per progress event a person would want in a CI log. */ +export function formatProgress(event: AgentProgress): string | null { + switch (event.kind) { + case "log": + return event.message; + case "status": + return `status: ${event.message}`; + case "stage": + return `stage: ${event.stage}`; + case "tasks": + return `tasks: ${event.tasks.map((task) => `${task.status} ${task.content}`).join(", ")}`; + case "url": + return `${event.which}: ${event.url}`; + case "authError": + return "gateway rejected the inference token"; + default: + return null; + } +} diff --git a/services/wizard-program/run-agent.ts b/services/wizard-program/run-agent.ts new file mode 100644 index 000000000..2d8506e85 --- /dev/null +++ b/services/wizard-program/run-agent.ts @@ -0,0 +1,183 @@ +/** + * `pnpm wizard-agent` — one real agent run through the wizard's `runAgent` on + * a skill that has nothing to do with PostHog programs. A local skills server + * hands the agent a `quack` skill, the agent writes `quack/quack.txt` into an + * empty directory, and this script checks the file. No programs, TUI, store or + * context-mill are involved. + * + * WIZARD_REPO= PROJECT_ID=… POSTHOG_KEY_FILE=… \ + * [PROGRAM=posthog-integration] [E2E_RESULT_JSON=result.json] pnpm wizard-agent + * + * WIZARD_REPO= pnpm wizard-agent --check + */ + +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CHECK, + exitAfterCheck, + formatProgress, + importSharedModules, + importWizard, + readE2eEnv, + requireWizardDependency, + resolveE2eCredentials, + writeE2eResult, + type AgentProgress, +} from "./harness.js"; + +/** The slice of `@agent` this route calls. */ +type Agent = { + runAgent( + config: Record, + input: Record, + options: { onProgress: (event: AgentProgress) => void }, + ): Promise<{ outcome: string; failure?: { message: string } }>; + RunOutcome: { Success: string }; +}; +type Constants = { + Sequence: { linear: string }; + Harness: { anthropic: string }; + HAIKU_MODEL: string; +}; +type Fflate = { zipSync(files: Record): Uint8Array }; + +// The runner mints the gateway token under this id, and the gateway may refuse +// an id it does not know. Agentic detection passes its caller's real program +// id, so this defaults to a real one too. +const PROGRAM_ID = process.env.PROGRAM || "posthog-integration"; +const SKILL_ID = "quack"; +const QUACK_FILE = join("quack", "quack.txt"); +const SKILL_MD = `--- +name: quack +description: Write the word quack into quack/quack.txt. +--- + +# Quack + +1. Create a directory named \`quack\` in the current working directory. +2. Write a file \`quack/quack.txt\` whose entire content is the word \`quack\`. +3. Change nothing else. Do not install packages or call any PostHog tool. +`; + +/** Serve a one-skill menu and its archive on a loopback port. */ +async function serveQuackSkill(fflate: Fflate): Promise<{ url: string; close: () => void }> { + const archive = Buffer.from(fflate.zipSync({ "SKILL.md": new TextEncoder().encode(SKILL_MD) })); + let base = ""; + const server = createServer((req, res) => { + if (req.url === "/skill-menu.json") { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + categories: { + e2e: [{ id: SKILL_ID, name: "Quack", downloadUrl: `${base}/quack.zip` }], + }, + }), + ); + } else if (req.url === "/quack.zip") { + res.setHeader("content-type", "application/zip"); + res.end(archive); + } else { + res.statusCode = 404; + res.end(); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address() as { port: number }; + base = `http://127.0.0.1:${address.port}`; + return { url: base, close: () => server.close() }; +} + +async function main(): Promise { + const { runAgent, RunOutcome } = await importWizard("@agent", ["runAgent", "RunOutcome"]); + const { Sequence, Harness, HAIKU_MODEL } = await importWizard("@shared/constants", [ + "Sequence", + "Harness", + "HAIKU_MODEL", + ]); + const shared = await importSharedModules(); + const fflate = requireWizardDependency("fflate"); + if (CHECK) exitAfterCheck("wizard-agent"); + + const e2e = readE2eEnv(process.env, { needsAppDir: false }); + const workDir = mkdtempSync(join(tmpdir(), "wizard-e2e-agent-")); + const credentials = await resolveE2eCredentials(e2e, shared); + const skills = await serveQuackSkill(fflate); + + const config = { + programId: PROGRAM_ID, + run: { + integrationLabel: SKILL_ID, + skillId: SKILL_ID, + spinnerMessage: "Quacking", + successMessage: "Quacked", + estimatedDurationMinutes: 1, + reportFile: "quack-report.md", + docsUrl: "https://posthog.com/docs", + }, + composed: false, + // Bound the way agentic detection binds its direct runAgent call: linear + // Haiku on the Anthropic harness. + binding: { sequence: Sequence.linear, harness: Harness.anthropic, model: HAIKU_MODEL }, + // Only the orchestrator reads it; this run is linear. + switchboard: { program: PROGRAM_ID, composed: false, flags: {}, flagPayloads: {} }, + skillsBaseUrl: skills.url, + wizardFlags: {}, + wizardFlagPayloads: {}, + wizardMetadata: {}, + }; + + try { + const result = await runAgent( + config, + { + installDir: workDir, + credentials: credentials.posthog, + project: credentials.project, + apiUser: credentials.apiUser, + skillId: SKILL_ID, + flags: { + ci: true, + signup: false, + debug: false, + e2eAsk: false, + localMcp: false, + captureAio: false, + benchmark: false, + yaraReport: false, + }, + host: {}, + }, + { + onProgress: (event) => { + const line = formatProgress(event); + if (line) console.log(line); + }, + }, + ); + + const quackPath = join(workDir, QUACK_FILE); + const quack = existsSync(quackPath) ? readFileSync(quackPath, "utf8").trim() : null; + const passed = result.outcome === RunOutcome.Success && quack === "quack"; + writeE2eResult({ + route: "agent", + skillId: SKILL_ID, + workDir, + outcome: result.outcome, + failure: result.outcome === RunOutcome.Success ? null : (result.failure?.message ?? null), + quack, + passed, + }); + console.log(`${SKILL_ID}: ${result.outcome}, ${QUACK_FILE} = ${quack}`); + if (!passed) process.exitCode = 1; + } finally { + skills.close(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/services/wizard-program/run-program.ts b/services/wizard-program/run-program.ts new file mode 100644 index 000000000..84c08e4cb --- /dev/null +++ b/services/wizard-program/run-program.ts @@ -0,0 +1,208 @@ +/** + * `pnpm wizard-program` — one real program run through the wizard's + * `runProgram`, with no TUI. This process is the host: it builds the run + * definition and program settings from the program's `ProgramConfig`, the way + * the wizard's legacy adapter does, and supplies the credentials. + * + * WIZARD_REPO= APP_DIR= PROJECT_ID=… \ + * POSTHOG_KEY_FILE=… [PROGRAM=posthog-integration] \ + * [E2E_RESULT_JSON=result.json] pnpm wizard-program + * + * WIZARD_REPO= pnpm wizard-program --check + */ + +import { + CHECK, + exitAfterCheck, + formatProgress, + importSharedModules, + importWizard, + readE2eEnv, + resolveE2eCredentials, + writeE2eResult, + type AgentProgress, +} from "./harness.js"; + +/** The slice of `ProgramRunOutcome` this route reads. */ +type ProgramRunOutcome = { + outcome: string; + failure?: { message: string }; + settledRuns: { result: { snapshot?: { tasks: unknown[] } } }[]; +}; + +type ProgramProgress = + { kind: "run"; event: AgentProgress } | { kind: "program" }; + +type FrameworkConfig = { metadata: { docsUrl: string } }; + +/** The slice of `WizardSession` this route reads and writes. */ +type Session = { + installDir: string; + integration: string | null; + frameworkConfig: FrameworkConfig | null; +}; + +/** The UI effects a program's `run(session, host)` may call. */ +type ProgramRunHost = { + getFrameworkContext(key: string): unknown; + setFrameworkContext(key: string, value: unknown): void; + warn(message: string): void; +}; + +/** The slice of `ProgramConfig` a run is built from. */ +type ProgramConfig = { + steps: unknown[]; + run?: object | ((session: Session, host: ProgramRunHost) => Promise); + requiresAi?: boolean; + agentFlow?: string; + allowedTools?: readonly string[]; + disallowedTools?: readonly string[]; + excludedTaskTypes?: unknown; +}; + +type Programs = { + runProgram( + programId: string, + input: Record, + options: { onProgress: (progress: ProgramProgress) => void }, + ): Promise; + getProgramConfig(programId: string): ProgramConfig | undefined; +}; +type ProgramSteps = { + postAuthGateSteps(steps: unknown[]): { id: string }[]; +}; +type Sessions = { + buildSession(args: { installDir: string; ci: boolean }): Session; +}; +type Ui = { + setUI(ui: unknown): void; + getUI(): { + getFrameworkContext(key: string): unknown; + setFrameworkContext(key: string, value: unknown): void; + log: { warn(message: string): void }; + }; +}; +type HeadlessUi = { HeadlessUI: new (store: unknown) => unknown }; +type Store = { WizardStore: new (programId: string) => { session: Session } }; +type Registry = { FRAMEWORK_REGISTRY: Record }; +type Detection = { + detectFramework(installDir: string): Promise; +}; + +async function main(): Promise { + const { runProgram, getProgramConfig } = await importWizard( + "@programs", + ["runProgram", "getProgramConfig"], + ); + const { postAuthGateSteps } = await importWizard( + "@programs/program-step", + ["postAuthGateSteps"], + ); + const { buildSession } = await importWizard("@lib/wizard-session", [ + "buildSession", + ]); + const { setUI, getUI } = await importWizard("@ui", ["setUI", "getUI"]); + const { HeadlessUI } = await importWizard("@ui/headless-ui", [ + "HeadlessUI", + ]); + const { WizardStore } = await importWizard("@ui/tui/store", [ + "WizardStore", + ]); + const { FRAMEWORK_REGISTRY } = await importWizard( + "@programs/registry", + ["FRAMEWORK_REGISTRY"], + ); + const { detectFramework } = await importWizard( + "@programs/detection/framework", + ["detectFramework"], + ); + const shared = await importSharedModules(); + if (CHECK) exitAfterCheck("wizard-program"); + + const e2e = readE2eEnv(process.env); + const programId = process.env.PROGRAM || "posthog-integration"; + const programConfig = getProgramConfig(programId); + if (!programConfig?.run) + throw new Error(`${programId} is not a registered program with a run`); + + // A program's `run(session)` can call `getUI()`. Install the headless UI + // the `--ci` runner installs, over a store that holds this session. + const session = buildSession({ installDir: e2e.appDir, ci: true }); + const store = new WizardStore(programId); + store.session = session; + setUI(new HeadlessUI(store)); + + // posthog-integration's `run(session)` reads the framework off the session. + // A `--ci` run fills it in `ciPreRun`, which also logs in and can scan the + // repo with an agent, so this route detects the framework alone. + if (programId === "posthog-integration") { + const integration = await detectFramework(e2e.appDir); + if (!integration) + throw new Error(`No supported framework detected in ${e2e.appDir}`); + session.integration = integration; + session.frameworkConfig = FRAMEWORK_REGISTRY[integration]; + } + + const credentials = await resolveE2eCredentials(e2e, shared); + // The legacy adapter's run host: each effect reaches `getUI()` at call time. + const host: ProgramRunHost = { + getFrameworkContext: (key) => getUI().getFrameworkContext(key), + setFrameworkContext: (key, value) => getUI().setFrameworkContext(key, value), + warn: (message) => getUI().log.warn(message), + }; + const run = + typeof programConfig.run === "function" + ? await programConfig.run(session, host) + : programConfig.run; + + // No hooks or seed tasks: postRun uploads env vars to a hosting provider, + // the outro builders feed a screen, and a CI session seeds no tasks. + const outcome = await runProgram( + programId, + { + installDir: session.installDir, + run, + program: { + requiresAi: programConfig.requiresAi, + agentFlow: programConfig.agentFlow, + allowedTools: programConfig.allowedTools, + disallowedTools: programConfig.disallowedTools, + excludedTaskTypes: programConfig.excludedTaskTypes, + postAuthGates: postAuthGateSteps(programConfig.steps).map( + (step) => step.id, + ), + }, + credentials, + integration: session.integration, + frameworkDocsUrl: session.frameworkConfig?.metadata.docsUrl, + flags: { ci: true }, + }, + { + onProgress: (progress) => { + // Program-data snapshots carry state, not a line to print. + if (progress.kind !== "run") return; + const line = formatProgress(progress.event); + if (line) console.log(line); + }, + }, + ); + + writeE2eResult({ + route: "programs", + programId, + integration: session.integration, + outcome: outcome.outcome, + failure: outcome.failure?.message ?? null, + settledRuns: outcome.settledRuns.length, + tasks: outcome.settledRuns.flatMap( + (run) => run.result.snapshot?.tasks ?? [], + ), + }); + console.log(`${programId}: ${outcome.outcome}`); + if (outcome.outcome !== "success") process.exitCode = 1; +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +});