diff --git a/src/commands/posthog/setup.test.ts b/src/commands/posthog/setup.test.ts new file mode 100644 index 0000000..66ce973 --- /dev/null +++ b/src/commands/posthog/setup.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; +import type { SpawnSyncReturns } from 'node:child_process'; + +// Hoisted mocks — vi.mock factories are hoisted above ordinary top-level +// statements, so any const they reference must be hoisted via vi.hoisted. +const { spawnSyncMock } = vi.hoisted(() => ({ spawnSyncMock: vi.fn() })); +vi.mock('node:child_process', () => ({ + spawnSync: (...args: unknown[]) => spawnSyncMock(...args), +})); + +const apiMock = vi.hoisted(() => ({ + startPosthogCliFlow: vi.fn(), + pollPosthogConnection: vi.fn(), + fetchPosthogConnection: vi.fn(), +})); +vi.mock('../../lib/api/posthog.js', () => apiMock); + +const configMock = vi.hoisted(() => ({ + getProjectConfig: vi.fn(() => ({ project_id: 'p1', project_name: 'Test Project' })), + getAccessToken: vi.fn(() => 'tok'), +})); +vi.mock('../../lib/config.js', () => configMock); + +vi.mock('../../lib/prompts.js', () => ({ isInteractive: false })); + +// `open` is loaded dynamically inside runConnectFlow; mock the module so the +// real browser launch doesn't fire during tests. +vi.mock('open', () => ({ default: vi.fn() })); + +// Silence interactive UI noise from clack — tests assert on mocks, not stdout. +vi.mock('@clack/prompts', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + intro: vi.fn(), + outro: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })), + }; +}); + +const outputMock = vi.hoisted(() => ({ + outputJson: vi.fn(), + outputInfo: vi.fn(), + outputSuccess: vi.fn(), +})); +vi.mock('../../lib/output.js', () => outputMock); + +// Imports must come AFTER the vi.mock calls because Vitest hoists the mocks +// but ESM module evaluation order still matters. +import { registerPosthogSetupCommand } from './setup.js'; + +function spawnOk(): SpawnSyncReturns { + return { pid: 1, output: ['', '', ''], stdout: '', stderr: '', status: 0, signal: null }; +} + +function spawnExit(code: number): SpawnSyncReturns { + return { pid: 1, output: ['', '', ''], stdout: '', stderr: '', status: code, signal: null }; +} + +function spawnSignal(signal: NodeJS.Signals): SpawnSyncReturns { + // When killed by signal, Node sets status: null and exposes the signal name. + return { + pid: 1, + output: ['', '', ''], + stdout: '', + stderr: '', + status: null as unknown as number, + signal, + }; +} + +function spawnSpawnError(err: Error): SpawnSyncReturns { + return { + pid: 0, + output: ['', '', ''], + stdout: '', + stderr: '', + status: null as unknown as number, + signal: null, + error: err, + }; +} + +interface RunResult { + exitCode?: number; +} + +// Set up a Command tree with the global --json / --api-url flags the real +// program defines, then run `posthog setup` against it. Override process.exit +// so handleError doesn't kill the test process; capture the first exit code. +async function runSetup(argv: string[]): Promise { + const program = new Command(); + program.option('--json').option('--api-url ').option('-y, --yes'); + const posthog = program.command('posthog'); + registerPosthogSetupCommand(posthog); + + const origExit = process.exit; + const result: RunResult = {}; + (process.exit as unknown) = (code?: number) => { + if (result.exitCode === undefined) result.exitCode = code; + throw new Error('__exit__'); + }; + try { + await program.parseAsync(['node', 'test', 'posthog', 'setup', ...argv]).catch((err) => { + if (err instanceof Error && err.message === '__exit__') return; + throw err; + }); + } finally { + process.exit = origExit; + } + return result; +} + +beforeEach(() => { + spawnSyncMock.mockReset(); + apiMock.startPosthogCliFlow.mockReset(); + apiMock.pollPosthogConnection.mockReset(); + apiMock.fetchPosthogConnection.mockReset(); + outputMock.outputJson.mockReset(); + outputMock.outputInfo.mockReset(); + outputMock.outputSuccess.mockReset(); + configMock.getProjectConfig.mockReturnValue({ project_id: 'p1', project_name: 'Test Project' }); + configMock.getAccessToken.mockReturnValue('tok'); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('posthog setup', () => { + describe('ensureDashboardConnection', () => { + it('fast path: cli-start says connected → verifies via /connection, skips polling', async () => { + apiMock.startPosthogCliFlow.mockResolvedValue({ type: 'connected' }); + apiMock.fetchPosthogConnection.mockResolvedValue({ + kind: 'connected', + connection: { apiKey: 'phc_', host: 'h', posthogProjectId: '1' }, + }); + spawnSyncMock.mockReturnValue(spawnOk()); + + await runSetup(['--skip-browser']); + + expect(apiMock.startPosthogCliFlow).toHaveBeenCalledOnce(); + expect(apiMock.fetchPosthogConnection).toHaveBeenCalledOnce(); + expect(apiMock.pollPosthogConnection).not.toHaveBeenCalled(); + }); + + it('OAuth path: cli-start returns authorizeUrl → polls until connected', async () => { + apiMock.startPosthogCliFlow.mockResolvedValue({ + type: 'authorize', + authorizeUrl: 'https://example.com/auth', + }); + apiMock.pollPosthogConnection.mockResolvedValue({ + apiKey: 'phc_', + host: 'h', + posthogProjectId: '1', + }); + spawnSyncMock.mockReturnValue(spawnOk()); + + await runSetup(['--skip-browser']); + + expect(apiMock.pollPosthogConnection).toHaveBeenCalledOnce(); + expect(apiMock.fetchPosthogConnection).not.toHaveBeenCalled(); + }); + + it('fast-path data-drift: cli-start says connected but /connection says no → exits, wizard never spawns', async () => { + apiMock.startPosthogCliFlow.mockResolvedValue({ type: 'connected' }); + apiMock.fetchPosthogConnection.mockResolvedValue({ kind: 'not-connected' }); + + const r = await runSetup(['--skip-browser']); + + expect(r.exitCode).toBeGreaterThan(0); + expect(spawnSyncMock).not.toHaveBeenCalled(); + }); + }); + + describe('wizard step', () => { + beforeEach(() => { + apiMock.startPosthogCliFlow.mockResolvedValue({ type: 'connected' }); + apiMock.fetchPosthogConnection.mockResolvedValue({ + kind: 'connected', + connection: { apiKey: 'phc_', host: 'h', posthogProjectId: '1' }, + }); + }); + + it('spawn error (ENOENT) → exits non-zero', async () => { + const enoent = Object.assign(new Error('spawn npx ENOENT'), { code: 'ENOENT' }); + spawnSyncMock.mockReturnValue(spawnSpawnError(enoent)); + + const r = await runSetup(['--skip-browser']); + + expect(spawnSyncMock).toHaveBeenCalledOnce(); + expect(r.exitCode).toBeGreaterThan(0); + }); + + it('non-zero exit → exits non-zero', async () => { + spawnSyncMock.mockReturnValue(spawnExit(1)); + + const r = await runSetup(['--skip-browser']); + + expect(r.exitCode).toBeGreaterThan(0); + }); + + it('SIGINT (exit 130) → clean exit, no error thrown', async () => { + spawnSyncMock.mockReturnValue(spawnExit(130)); + + const r = await runSetup(['--skip-browser']); + + // Cancellation is graceful — runSetup returns normally, no handleError + // path, so process.exit was never called by the CLI. + expect(r.exitCode).toBeUndefined(); + }); + + it('SIGINT signal (status=null, signal=SIGINT) → clean exit', async () => { + spawnSyncMock.mockReturnValue(spawnSignal('SIGINT')); + + const r = await runSetup(['--skip-browser']); + + expect(r.exitCode).toBeUndefined(); + }); + + it('uses platform-aware npx binary', async () => { + spawnSyncMock.mockReturnValue(spawnOk()); + + await runSetup(['--skip-browser']); + + const [bin, args] = spawnSyncMock.mock.calls[0]; + expect(bin).toMatch(/^npx(\.cmd)?$/); + expect(args).toEqual(['-y', '@posthog/wizard@latest']); + }); + }); + + describe('--json mode', () => { + it('skips wizard, emits JSON with wizardCommand', async () => { + apiMock.startPosthogCliFlow.mockResolvedValue({ type: 'connected' }); + apiMock.fetchPosthogConnection.mockResolvedValue({ + kind: 'connected', + connection: { apiKey: 'phc_', host: 'h', posthogProjectId: '1' }, + }); + + await runSetup(['--skip-browser']); + spawnSyncMock.mockClear(); + + // re-run in JSON mode + const program = new Command(); + program.option('--json').option('--api-url ').option('-y, --yes'); + const posthog = program.command('posthog'); + registerPosthogSetupCommand(posthog); + await program.parseAsync(['node', 'test', '--json', 'posthog', 'setup', '--skip-browser']); + + expect(spawnSyncMock).not.toHaveBeenCalled(); + expect(outputMock.outputJson).toHaveBeenCalledOnce(); + const payload = outputMock.outputJson.mock.calls[0][0] as { + success: boolean; + wizardSkipped: boolean; + wizardCommand: string; + }; + expect(payload.success).toBe(true); + expect(payload.wizardSkipped).toBe(true); + expect(payload.wizardCommand).toMatch(/^npx(\.cmd)? -y @posthog\/wizard@latest$/); + }); + }); +}); diff --git a/src/commands/posthog/setup.ts b/src/commands/posthog/setup.ts index b9dff51..c4c8dbf 100644 --- a/src/commands/posthog/setup.ts +++ b/src/commands/posthog/setup.ts @@ -1,6 +1,5 @@ import type { Command } from 'commander'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { spawnSync } from 'node:child_process'; import * as clack from '@clack/prompts'; import pc from 'picocolors'; import { getProjectConfig, getAccessToken } from '../../lib/config.js'; @@ -16,22 +15,7 @@ import { fetchPosthogConnection, pollPosthogConnection, startPosthogCliFlow, - type PosthogConnectionResponse, } from '../../lib/api/posthog.js'; -import { - contextFromCwd, - detectFramework, - type Framework, -} from '../../lib/framework-detect.js'; -import { - detectPackageManager, - hasPackage, - installCommand, - runInstall, - type PackageManager, -} from '../../lib/package-manager.js'; -import { upsertEnvFile } from '../../lib/env-writer.js'; -import { templates, renderTemplate } from '../../templates/posthog/index.js'; import { outputJson, outputSuccess, outputInfo } from '../../lib/output.js'; const POLL_INTERVAL_MS = 2000; @@ -39,31 +23,34 @@ const POLL_TIMEOUT_MS = 15 * 60 * 1000; const MAX_TRANSIENT_RETRIES = 5; interface SetupResult { - framework: Framework | null; - installedSdk: boolean; - filesWritten: string[]; - envWritten: { file: string; added: string[]; mismatched: string[] }; - notes: string[]; + /** Whether the dashboard connection already existed (skipped OAuth) or was just established. */ + dashboardConnection: 'already-connected' | 'newly-connected'; + /** True when JSON mode skipped the wizard step (wizard is interactive and incompatible with piped stdio). */ + wizardSkipped: boolean; + /** Wizard process exit code; only set when wizardSkipped is false. */ + wizardExitCode?: number; + /** Command the caller should run to complete the wizard step; only set when wizardSkipped is true. */ + wizardCommand?: string; } +// `npx` is installed as `npx.cmd` on Windows; passing the bare name to spawn +// without a shell results in ENOENT. Use the platform-specific binary. +const NPX_COMMAND = process.platform === 'win32' ? 'npx.cmd' : 'npx'; +const WIZARD_COMMAND = `${NPX_COMMAND} -y @posthog/wizard@latest`; + export function registerPosthogSetupCommand(program: Command): void { program .command('setup') - .description('Install the PostHog SDK into the current directory app') - .option('--framework ', 'Force framework (next-app|next-pages|vite-react|sveltekit|astro)') - .option('--skip-install', 'Do not run the package manager install step') - .option('--skip-browser', 'Do not auto-open the browser; only print the URL') + .description('Connect PostHog to your InsForge dashboard, then run the official PostHog wizard to wire it into your app') + .option('--skip-browser', 'Do not auto-open the browser for OAuth; only print the URL') .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { const result = await runSetup({ json, apiUrl, - forceFramework: opts.framework as string | undefined, - skipInstall: Boolean(opts.skipInstall), skipBrowser: Boolean(opts.skipBrowser), }); - if (json) { outputJson({ success: true, ...result }); } @@ -76,11 +63,20 @@ export function registerPosthogSetupCommand(program: Command): void { interface RunSetupOpts { json: boolean; apiUrl?: string; - forceFramework?: string; - skipInstall: boolean; skipBrowser: boolean; } +// Two-step flow: +// 1. Ensure the InsForge dashboard has a PostHog connection (cli-start / +// OAuth). This is what populates `posthog_connections` in cloud-backend +// and makes the in-product Analytics page renderable. +// 2. Spawn `npx @posthog/wizard` — it runs its own OAuth, lets the user pick +// a PostHog project, and installs + wires up the SDK in the app code. +// +// The two OAuths are independent and may even land on different PostHog +// projects (rare in practice — same user account usually means same project). +// We don't pass anything to the wizard; per its docs it discovers the project +// interactively. async function runSetup(opts: RunSetupOpts): Promise { // 1. Linked project const proj = getProjectConfig(); @@ -88,9 +84,7 @@ async function runSetup(opts: RunSetupOpts): Promise { throw new ProjectNotLinkedError(); } - // 2. Login token (raw access — cloud-backend's posthog endpoints use - // user-Bearer auth, not the refresh-on-401 path. Re-running `insforge login` - // is the recovery; we don't plumb refresh here.) + // 2. Login token const token = getAccessToken(); if (!token) { throw new AuthError('Not logged in. Run `insforge login` first.'); @@ -101,82 +95,87 @@ async function runSetup(opts: RunSetupOpts): Promise { outputSuccess(`Linked to InsForge project: ${proj.project_name} (${proj.project_id})`); } - // 3. Auto-provision via cli-start. New users get inline-provisioned - // server-side, in which case we skip the browser hop entirely. Existing - // users who haven't connected yet get an authorize URL to send them through. - const startResult = await startPosthogCliFlow(proj.project_id, token, opts.apiUrl); + // 3. Ensure dashboard connection exists + const dashboardConnection = await ensureDashboardConnection(proj.project_id, token, opts); - let conn: PosthogConnectionResponse; - if (startResult.type === 'connected') { - if (!opts.json) { - outputSuccess('PostHog already connected (or auto-provisioned for new user). Continuing...'); - } - // cli-start only signals completion; fetch the actual connection details - // (phc_/host) to render templates with. - const fetchResult = await fetchPosthogConnection(proj.project_id, token, opts.apiUrl); - if (fetchResult.kind !== 'connected') { - throw new CLIError( - 'cli-start reported connected, but /connection returned not-connected. Try again, or check the dashboard.', - ); - } - conn = fetchResult.connection; - } else { - conn = await runConnectFlow(proj.project_id, token, startResult.authorizeUrl, opts); + // 4. Run the official PostHog wizard for app-code wiring. + // + // JSON mode is for scripted / headless callers, but the wizard is interactive + // (terminal TUI for framework + project selection, browser OAuth). Skip it + // and surface the command so the caller can run it themselves under a real + // TTY; piping its stdio would either hang or swallow the prompts. + if (opts.json) { + return { + dashboardConnection, + wizardSkipped: true, + wizardCommand: WIZARD_COMMAND, + }; } - if (!conn.apiKey) { - // Defensive: pollPosthogConnection should have guaranteed a phc_ key, but - // cloud-backend could conceivably 200 with a partial body. Surface a clear - // error rather than writing `undefined` into the user's env file. - throw new CLIError( - 'Connection succeeded but cloud-backend returned no apiKey. Try again or check the dashboard.', - ); - } + outputInfo('Running the official PostHog setup wizard to wire PostHog into your app...'); + outputInfo( + pc.dim('(it will open a browser for OAuth and let you pick a PostHog project)'), + ); - // 4. Detect framework - const framework = resolveFramework(opts); - if (framework === null) { - return reportNoFramework(conn, opts); + const wizardResult = spawnSync(NPX_COMMAND, ['-y', '@posthog/wizard@latest'], { + stdio: 'inherit', + env: process.env, + }); + + if (wizardResult.error) { + throw new CLIError(`Failed to launch PostHog wizard: ${wizardResult.error.message}`); + } + const exitCode = wizardResult.status ?? 1; + // Treat user-initiated cancellation (Ctrl+C → 130 / SIGINT) as a clean exit + // rather than an error; the user just chose to abort the wizard step. + if (wizardResult.signal === 'SIGINT' || exitCode === 130) { + clack.outro('Setup cancelled.'); + return { + dashboardConnection, + wizardSkipped: false, + wizardExitCode: exitCode, + }; } + if (exitCode !== 0) { + throw new CLIError(`PostHog wizard exited with code ${exitCode}.`); + } + + clack.outro('Done. Open the Analytics page in your InsForge dashboard to view data.'); - if (!opts.json) outputSuccess(`Detected framework: ${frameworkLabel(framework)}`); + return { + dashboardConnection, + wizardSkipped: false, + wizardExitCode: exitCode, + }; +} - // 5. Install SDK - const cwd = process.cwd(); - const ctx = contextFromCwd(cwd); - const pm = detectPackageManager(cwd); - const alreadyInstalled = hasPackage(ctx.pkg, 'posthog-js'); - let installedSdk = false; +// Calls cli-start. If already connected, no-op. Otherwise opens the OAuth +// browser flow and polls until the connection appears. Returns whether we +// hit the fast path or had to wait. +async function ensureDashboardConnection( + projectId: string, + token: string, + opts: RunSetupOpts, +): Promise<'already-connected' | 'newly-connected'> { + const startResult = await startPosthogCliFlow(projectId, token, opts.apiUrl); - if (alreadyInstalled) { - if (!opts.json) outputInfo(pc.dim('posthog-js is already installed — skipping install.')); - } else if (opts.skipInstall) { + if (startResult.type === 'connected') { if (!opts.json) { - outputInfo(pc.yellow(`Skipping install. Run manually: ${installCommand(pm, 'posthog-js')}`)); + outputSuccess('PostHog is already connected to your InsForge dashboard.'); } - } else { - installedSdk = await installSdk(pm, cwd, opts); - } - - // 6. Write init code + env - const filesWritten: string[] = []; - const notes: string[] = []; - const envResult = writeForFramework(framework, conn, cwd, filesWritten, notes, opts); - - if (!opts.json) { - if (notes.length > 0) { - for (const n of notes) clack.log.info(n); + // Sanity-check that cloud-backend has the connection row, surface a clear + // error if cli-start says yes but /connection says no (data drift). + const fetchResult = await fetchPosthogConnection(projectId, token, opts.apiUrl); + if (fetchResult.kind !== 'connected') { + throw new CLIError( + 'cli-start reported connected, but /connection returned not-connected. Try again, or check the dashboard.', + ); } - clack.outro('Done. Run your dev server to start sending events.'); + return 'already-connected'; } - return { - framework, - installedSdk, - filesWritten, - envWritten: envResult, - notes, - }; + await runConnectFlow(projectId, token, startResult.authorizeUrl, opts); + return 'newly-connected'; } async function runConnectFlow( @@ -184,14 +183,14 @@ async function runConnectFlow( token: string, authorizeUrl: string, opts: RunSetupOpts, -): Promise { +): Promise { if (opts.json) { // JSON mode: keep stdout clean for the final result object. Print the // URL to stderr so a human can copy it if the browser fails to open. process.stderr.write(`Authorize PostHog: ${authorizeUrl}\n`); process.stderr.write('Your browser should open automatically. If not, copy the URL above.\n'); } else { - clack.log.info('PostHog is not connected to this project yet.'); + clack.log.info('PostHog is not yet connected to your InsForge dashboard.'); outputInfo(''); outputInfo(`Open this URL to authorize PostHog:\n ${pc.cyan(pc.underline(authorizeUrl))}`); outputInfo(''); @@ -207,10 +206,10 @@ async function runConnectFlow( } const spinner = !opts.json && isInteractive ? clack.spinner() : null; - spinner?.start('Waiting for connection... (timeout: 15 minutes)'); + spinner?.start('Waiting for InsForge dashboard connection... (timeout: 15 minutes)'); try { - const conn = await pollPosthogConnection( + await pollPosthogConnection( projectId, token, { @@ -222,345 +221,15 @@ async function runConnectFlow( const secs = Math.floor(elapsed / 1000); const mins = Math.floor(secs / 60); const remaining = `${mins}m ${secs % 60}s elapsed`; - spinner.message(`Waiting for connection... (${remaining})`); + spinner.message(`Waiting for InsForge dashboard connection... (${remaining})`); } }, }, opts.apiUrl, ); - spinner?.stop('Connection received from PostHog.'); - return conn; + spinner?.stop('InsForge dashboard connection received.'); } catch (err) { - spinner?.stop('Connection wait failed.'); + spinner?.stop('InsForge dashboard connection wait failed.'); throw err; } } - -function resolveFramework(opts: RunSetupOpts): Framework | null { - if (opts.forceFramework) { - const valid: Framework[] = ['next-app', 'next-pages', 'vite-react', 'sveltekit', 'astro']; - if (!valid.includes(opts.forceFramework as Framework)) { - throw new CLIError( - `Invalid --framework "${opts.forceFramework}". Valid: ${valid.join(', ')}`, - ); - } - return opts.forceFramework as Framework; - } - - return detectFramework(contextFromCwd(process.cwd())); -} - -async function installSdk( - pm: PackageManager, - cwd: string, - opts: RunSetupOpts, -): Promise { - const cmd = installCommand(pm, 'posthog-js'); - const spinner = !opts.json && isInteractive ? clack.spinner() : null; - spinner?.start(`Installing posthog-js (${cmd})...`); - try { - await runInstall(pm, 'posthog-js', cwd); - spinner?.stop('Installed posthog-js.'); - return true; - } catch (err) { - spinner?.stop('Install failed.'); - if (!opts.json) { - clack.log.warn( - `Could not run \`${cmd}\` automatically: ${(err as Error).message}\nRun it manually, then re-run \`insforge posthog setup\`.`, - ); - } - return false; - } -} - -function writeForFramework( - framework: Framework, - conn: PosthogConnectionResponse, - cwd: string, - filesWritten: string[], - notes: string[], - opts: RunSetupOpts, -): { file: string; added: string[]; mismatched: string[] } { - const host = conn.host || 'https://us.posthog.com'; - const phc = conn.apiKey ?? ''; - - switch (framework) { - case 'next-app': - return writeNextApp(cwd, phc, host, filesWritten, notes, opts); - case 'next-pages': - return writeNextPages(cwd, phc, host, filesWritten, notes, opts); - case 'vite-react': - return writeViteReact(cwd, phc, host, filesWritten, notes, opts); - case 'sveltekit': - return writeSveltekit(cwd, phc, host, filesWritten, notes, opts); - case 'astro': - return writeAstro(cwd, phc, host, filesWritten, notes, opts); - } -} - -function writeNextApp( - cwd: string, - phc: string, - host: string, - filesWritten: string[], - notes: string[], - opts: RunSetupOpts, -): { file: string; added: string[]; mismatched: string[] } { - const appDir = existsSync(join(cwd, 'src/app')) ? 'src/app' : 'app'; - const providerPath = join(cwd, appDir, 'posthog-provider.tsx'); - writeIfMissing( - providerPath, - renderTemplate(templates['next-app'].provider, { HOST: host }), - filesWritten, - notes, - opts, - ); - - // Layout snippet — emit as a printable note rather than auto-modifying - // app/layout.tsx (too much variance in user layout files to rewrite safely). - notes.push( - `Add the provider to your ${appDir}/layout.tsx:\n${templates['next-app'].layoutSnippet}`, - ); - - const envFile = '.env.local'; - return writeEnv( - cwd, - envFile, - { - NEXT_PUBLIC_POSTHOG_KEY: phc, - NEXT_PUBLIC_POSTHOG_HOST: host, - }, - opts, - ); -} - -function writeNextPages( - cwd: string, - phc: string, - host: string, - filesWritten: string[], - notes: string[], - opts: RunSetupOpts, -): { file: string; added: string[]; mismatched: string[] } { - const pagesDir = existsSync(join(cwd, 'src/pages')) ? 'src/pages' : 'pages'; - const appPath = join(cwd, pagesDir, '_app.tsx'); - writeIfMissing( - appPath, - renderTemplate(templates['next-pages'].app, { HOST: host }), - filesWritten, - notes, - opts, - 'pages/_app.tsx already exists. Open it and add `posthog.init(...)` near the top — see PostHog Next.js docs.', - ); - - const envFile = '.env.local'; - return writeEnv( - cwd, - envFile, - { - NEXT_PUBLIC_POSTHOG_KEY: phc, - NEXT_PUBLIC_POSTHOG_HOST: host, - }, - opts, - ); -} - -function writeViteReact( - cwd: string, - phc: string, - host: string, - _filesWritten: string[], - notes: string[], - opts: RunSetupOpts, -): { file: string; added: string[]; mismatched: string[] } { - // Vite users almost always already have src/main.tsx. We don't auto-edit - // it (too varied — some users have providers, custom roots, etc.); emit - // a snippet they can paste in. - notes.push( - `Add this snippet near the top of src/main.tsx:\n${renderTemplate(templates['vite-react'].mainSnippet, { HOST: host })}`, - ); - - const envFile = '.env'; - return writeEnv( - cwd, - envFile, - { - VITE_PUBLIC_POSTHOG_KEY: phc, - VITE_PUBLIC_POSTHOG_HOST: host, - }, - opts, - ); -} - -function writeSveltekit( - cwd: string, - phc: string, - host: string, - filesWritten: string[], - notes: string[], - opts: RunSetupOpts, -): { file: string; added: string[]; mismatched: string[] } { - const hooksPath = join(cwd, 'src/hooks.client.ts'); - writeIfMissing( - hooksPath, - renderTemplate(templates.sveltekit.hooks, { HOST: host }), - filesWritten, - notes, - opts, - 'src/hooks.client.ts already exists. Add `posthog.init(...)` to it — see PostHog SvelteKit docs.', - ); - - const envFile = '.env'; - return writeEnv( - cwd, - envFile, - { - PUBLIC_POSTHOG_KEY: phc, - PUBLIC_POSTHOG_HOST: host, - }, - opts, - ); -} - -function writeAstro( - cwd: string, - phc: string, - host: string, - filesWritten: string[], - notes: string[], - opts: RunSetupOpts, -): { file: string; added: string[]; mismatched: string[] } { - const initPath = join(cwd, 'src/lib/posthog.ts'); - writeIfMissing( - initPath, - renderTemplate(templates.astro.init, { HOST: host }), - filesWritten, - notes, - opts, - 'src/lib/posthog.ts already exists. Add `posthog.init(...)` per PostHog Astro docs.', - ); - - // Astro doesn't auto-import client modules — user has to reference the init - // module from their layout's `, - ); - - const envFile = '.env'; - return writeEnv( - cwd, - envFile, - { - PUBLIC_POSTHOG_KEY: phc, - PUBLIC_POSTHOG_HOST: host, - }, - opts, - ); -} - -function writeIfMissing( - filePath: string, - contents: string, - filesWritten: string[], - notes: string[], - opts: RunSetupOpts, - conflictNote?: string, -): void { - if (existsSync(filePath)) { - const existing = readFileSync(filePath, 'utf-8'); - if (existing.includes('posthog.init')) { - if (!opts.json) { - outputInfo(pc.dim(`${relative(filePath)} already calls posthog.init — leaving it alone.`)); - } - return; - } - if (conflictNote) notes.push(conflictNote); - if (!opts.json) { - outputInfo( - pc.yellow( - `${relative(filePath)} exists. Skipped writing — see notes below for manual changes.`, - ), - ); - } - return; - } - mkdirSync(dirname(filePath), { recursive: true }); - writeFileSync(filePath, contents); - filesWritten.push(filePath); - if (!opts.json) outputSuccess(`Wrote ${relative(filePath)}`); -} - -function writeEnv( - cwd: string, - envFile: string, - entries: Record, - opts: RunSetupOpts, -): { file: string; added: string[]; mismatched: string[] } { - const path = join(cwd, envFile); - const r = upsertEnvFile(path, entries); - - if (!opts.json) { - if (r.added.length > 0) { - outputSuccess(`Wrote ${envFile}: ${r.added.join(', ')}`); - } - if (r.skipped.length > 0) { - outputInfo( - pc.dim(`${envFile}: ${r.skipped.join(', ')} already set (matching) — left as-is.`), - ); - } - for (const m of r.mismatched) { - clack.log.warn( - `${envFile} has ${m.key}=${pc.dim(m.existingValue)}, expected ${m.newValue}. Left existing value untouched.`, - ); - } - } - - return { - file: envFile, - added: r.added, - mismatched: r.mismatched.map((m) => m.key), - }; -} - -function reportNoFramework( - conn: PosthogConnectionResponse, - opts: RunSetupOpts, -): SetupResult { - if (!opts.json) { - clack.log.warn('No supported framework detected in this directory.'); - outputInfo(''); - outputInfo(`Your PostHog public key: ${pc.cyan(conn.apiKey ?? '(missing)')}`); - outputInfo(`Your PostHog host: ${conn.host ?? 'https://us.posthog.com'}`); - outputInfo(''); - outputInfo('See https://posthog.com/docs/libraries to install the SDK manually.'); - clack.outro('Done.'); - } - return { - framework: null, - installedSdk: false, - filesWritten: [], - envWritten: { file: '', added: [], mismatched: [] }, - notes: ['No supported framework detected.'], - }; -} - -function frameworkLabel(framework: Framework): string { - switch (framework) { - case 'next-app': - return 'Next.js (App Router)'; - case 'next-pages': - return 'Next.js (Pages Router)'; - case 'vite-react': - return 'Vite + React'; - case 'sveltekit': - return 'SvelteKit'; - case 'astro': - return 'Astro'; - } -} - -function relative(p: string): string { - return p.replace(process.cwd() + '/', ''); -} diff --git a/src/lib/api/posthog.ts b/src/lib/api/posthog.ts index 29f6af6..f323973 100644 --- a/src/lib/api/posthog.ts +++ b/src/lib/api/posthog.ts @@ -260,57 +260,6 @@ export async function startPosthogCliFlow( throw new CLIError('PostHog cli-start returned an unexpected response shape.'); } -export interface PosthogCliCredentials { - apiKey: string; // phc_ - personalApiKey: string; // phx_ - posthogProjectId: string | number; - region: string; - host: string; - status?: string; -} - -/** - * GET /integrations/posthog/v1/cli-credentials?project_id= - * - * Returns phx_ in addition to phc_ so the CLI can spawn - * `npx @posthog/wizard --ci --api-key `. Same membership rules as - * /connection — gated by `authenticate` middleware on cloud-backend. - */ -export async function fetchPosthogCliCredentials( - projectId: string, - jwt: string, - apiUrl?: string, -): Promise { - const baseUrl = getPlatformApiUrl(apiUrl); - const url = `${baseUrl}/integrations/posthog/v1/cli-credentials?project_id=${encodeURIComponent(projectId)}`; - - let res: Response; - try { - res = await fetchWithTimeout(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${jwt}`, - Accept: 'application/json', - }, - }); - } catch (err) { - throw new CLIError(`Failed to fetch CLI credentials: ${formatFetchError(err, url)}`); - } - - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { error?: string }; - throw new CLIError( - `Failed to fetch CLI credentials (HTTP ${res.status}): ${body.error ?? res.statusText}`, - ); - } - - const data = (await res.json()) as Partial; - if (!data.apiKey || !data.personalApiKey || !data.posthogProjectId || !data.region || !data.host) { - throw new CLIError('CLI credentials response is missing required fields.'); - } - return data as PosthogCliCredentials; -} - function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { diff --git a/src/lib/framework-detect.test.ts b/src/lib/framework-detect.test.ts deleted file mode 100644 index fd84ead..0000000 --- a/src/lib/framework-detect.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - detectFramework, - contextFromCwd, - type DetectionContext, - type PackageJsonShape, -} from './framework-detect.js'; - -function ctx( - pkg: PackageJsonShape | null, - dirs: string[] = [], -): DetectionContext { - const set = new Set(dirs); - return { - pkg, - hasDir: (rel: string): boolean => set.has(rel), - }; -} - -describe('detectFramework', () => { - it('returns next-app when next + app/ directory present', () => { - const result = detectFramework( - ctx({ dependencies: { next: '14.0.0' } }, ['app']), - ); - expect(result).toBe('next-app'); - }); - - it('returns next-app when next + src/app/ directory present', () => { - const result = detectFramework( - ctx({ dependencies: { next: '14.0.0' } }, ['src/app']), - ); - expect(result).toBe('next-app'); - }); - - it('returns next-pages when next + pages/ directory present', () => { - const result = detectFramework( - ctx({ dependencies: { next: '14.0.0' } }, ['pages']), - ); - expect(result).toBe('next-pages'); - }); - - it('returns next-pages when next + src/pages/ directory present', () => { - const result = detectFramework( - ctx({ dependencies: { next: '14.0.0' } }, ['src/pages']), - ); - expect(result).toBe('next-pages'); - }); - - it('prefers next-app when both app/ and pages/ exist', () => { - const result = detectFramework( - ctx({ dependencies: { next: '14.0.0' } }, ['app', 'pages']), - ); - expect(result).toBe('next-app'); - }); - - it('defaults to next-app when next is present but neither dir exists', () => { - const result = detectFramework(ctx({ dependencies: { next: '14.0.0' } })); - expect(result).toBe('next-app'); - }); - - it('returns vite-react when vite + react are deps and next is absent', () => { - const result = detectFramework( - ctx({ dependencies: { vite: '5.0.0', react: '18.2.0' } }), - ); - expect(result).toBe('vite-react'); - }); - - it('treats devDependencies the same as dependencies', () => { - const result = detectFramework( - ctx({ - devDependencies: { vite: '5.0.0' }, - dependencies: { react: '18.2.0' }, - }), - ); - expect(result).toBe('vite-react'); - }); - - it('returns sveltekit when @sveltejs/kit is present', () => { - const result = detectFramework( - ctx({ devDependencies: { '@sveltejs/kit': '2.0.0' } }), - ); - expect(result).toBe('sveltekit'); - }); - - it('returns astro when astro is present', () => { - const result = detectFramework( - ctx({ dependencies: { astro: '4.0.0' } }), - ); - expect(result).toBe('astro'); - }); - - it('returns null when no supported framework is present', () => { - const result = detectFramework( - ctx({ dependencies: { express: '4.0.0' } }), - ); - expect(result).toBeNull(); - }); - - it('returns null for a missing package.json', () => { - expect(detectFramework(ctx(null))).toBeNull(); - }); - - it('returns null when only react is present (no vite)', () => { - const result = detectFramework( - ctx({ dependencies: { react: '18.2.0' } }), - ); - expect(result).toBeNull(); - }); - - it('returns null when only vite is present (no react)', () => { - const result = detectFramework( - ctx({ dependencies: { vite: '5.0.0' } }), - ); - expect(result).toBeNull(); - }); - - it('next takes priority over vite when both are listed', () => { - const result = detectFramework( - ctx( - { - dependencies: { next: '14.0.0', vite: '5.0.0', react: '18.2.0' }, - }, - ['app'], - ), - ); - expect(result).toBe('next-app'); - }); -}); - -describe('contextFromCwd', () => { - it('reads package.json and detects directories from disk', () => { - const dir = mkdtempSync(join(tmpdir(), 'fwk-detect-')); - try { - writeFileSync( - join(dir, 'package.json'), - JSON.stringify({ dependencies: { next: '14.0.0' } }), - ); - mkdirSync(join(dir, 'app')); - - const c = contextFromCwd(dir); - expect(c.pkg?.dependencies?.next).toBe('14.0.0'); - expect(c.hasDir('app')).toBe(true); - expect(c.hasDir('pages')).toBe(false); - expect(detectFramework(c)).toBe('next-app'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('returns null pkg when package.json is missing', () => { - const dir = mkdtempSync(join(tmpdir(), 'fwk-detect-')); - try { - const c = contextFromCwd(dir); - expect(c.pkg).toBeNull(); - expect(detectFramework(c)).toBeNull(); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('handles malformed package.json gracefully', () => { - const dir = mkdtempSync(join(tmpdir(), 'fwk-detect-')); - try { - writeFileSync(join(dir, 'package.json'), '{not json}'); - const c = contextFromCwd(dir); - expect(c.pkg).toBeNull(); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/lib/framework-detect.ts b/src/lib/framework-detect.ts deleted file mode 100644 index f427a55..0000000 --- a/src/lib/framework-detect.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -export type Framework = - | 'next-app' - | 'next-pages' - | 'vite-react' - | 'sveltekit' - | 'astro'; - -export interface PackageJsonShape { - dependencies?: Record; - devDependencies?: Record; -} - -export interface DetectionContext { - /** Directory contents of the project root, used to check for `app/` and `pages/`. */ - hasDir: (relativePath: string) => boolean; - /** Parsed package.json (or null if not present/parseable). */ - pkg: PackageJsonShape | null; -} - -/** - * Build a DetectionContext from a real filesystem directory. Used by the CLI; - * tests construct contexts directly without touching disk. - */ -export function contextFromCwd(cwd: string): DetectionContext { - let pkg: PackageJsonShape | null = null; - const pkgPath = join(cwd, 'package.json'); - if (existsSync(pkgPath)) { - try { - pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJsonShape; - } catch { - pkg = null; - } - } - return { - hasDir: (rel: string): boolean => existsSync(join(cwd, rel)), - pkg, - }; -} - -function hasDep(pkg: PackageJsonShape | null, name: string): boolean { - if (!pkg) return false; - return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]); -} - -/** - * Detect a supported web framework from a project's package.json + filesystem. - * - * Decision rules (in priority order): - * 1. `next` + `app/` directory → next-app - * 2. `next` + `pages/` directory → next-pages - * 3. `next` only (no entry dir) → next-app (fallback default; caller may prompt) - * 4. `vite` + `react` → vite-react - * 5. `@sveltejs/kit` → sveltekit - * 6. `astro` → astro - * 7. otherwise → null - * - * Note: Next.js takes priority over Vite even if both happen to be present - * (rare but possible in monorepos), because the `next` dependency is the more - * specific signal. If users hit ambiguous cases the caller can prompt. - */ -export function detectFramework(ctx: DetectionContext): Framework | null { - if (hasDep(ctx.pkg, 'next')) { - const hasApp = ctx.hasDir('app') || ctx.hasDir('src/app'); - const hasPages = ctx.hasDir('pages') || ctx.hasDir('src/pages'); - if (hasApp && !hasPages) return 'next-app'; - if (hasPages && !hasApp) return 'next-pages'; - if (hasApp && hasPages) return 'next-app'; // App Router wins when both present - return 'next-app'; // default for Next.js with neither dir yet (newly scaffolded) - } - - if (hasDep(ctx.pkg, 'vite') && hasDep(ctx.pkg, 'react')) { - return 'vite-react'; - } - - if (hasDep(ctx.pkg, '@sveltejs/kit')) { - return 'sveltekit'; - } - - if (hasDep(ctx.pkg, 'astro')) { - return 'astro'; - } - - return null; -} diff --git a/src/lib/package-manager.ts b/src/lib/package-manager.ts deleted file mode 100644 index 9678507..0000000 --- a/src/lib/package-manager.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; -import type { PackageJsonShape } from './framework-detect.js'; - -const execAsync = promisify(exec); - -export type PackageManager = 'pnpm' | 'yarn' | 'bun' | 'npm'; - -/** - * Detect the project's package manager from lockfile presence. - * Falls back to npm when no lockfile is found. - */ -export function detectPackageManager(cwd: string): PackageManager { - if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; - if (existsSync(join(cwd, 'yarn.lock'))) return 'yarn'; - if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) { - return 'bun'; - } - return 'npm'; -} - -/** Build the install command for a single package using the given manager. */ -export function installCommand(pm: PackageManager, pkg: string): string { - switch (pm) { - case 'pnpm': - return `pnpm add ${pkg}`; - case 'yarn': - return `yarn add ${pkg}`; - case 'bun': - return `bun add ${pkg}`; - case 'npm': - default: - return `npm install ${pkg}`; - } -} - -/** - * Returns true if the given package is already in dependencies or devDependencies. - */ -export function hasPackage(pkg: PackageJsonShape | null, name: string): boolean { - if (!pkg) return false; - return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]); -} - -/** Run a package install command. Wraps errors with context. */ -export async function runInstall( - pm: PackageManager, - pkgName: string, - cwd: string, -): Promise { - const cmd = installCommand(pm, pkgName); - await execAsync(cmd, { cwd, maxBuffer: 16 * 1024 * 1024 }); -} diff --git a/src/templates/posthog/astro/posthog-init.ts.txt b/src/templates/posthog/astro/posthog-init.ts.txt deleted file mode 100644 index 8f197e9..0000000 --- a/src/templates/posthog/astro/posthog-init.ts.txt +++ /dev/null @@ -1,17 +0,0 @@ -import posthog from 'posthog-js'; - -// PostHog client init for Astro. This module runs only in the browser bundle -// (Astro inlines `client:load` / `