diff --git a/src/laravel/laravel-wizard-agent.ts b/src/laravel/laravel-wizard-agent.ts new file mode 100644 index 0000000..bb5782a --- /dev/null +++ b/src/laravel/laravel-wizard-agent.ts @@ -0,0 +1,187 @@ +/* Laravel wizard using posthog-agent with PostHog MCP */ +import type { WizardOptions } from '../utils/types'; +import type { FrameworkConfig } from '../lib/framework-config'; +import { enableDebugLogs } from '../utils/debug'; +import { runAgentWizard } from '../lib/agent-runner'; +import { Integration } from '../lib/constants'; +import clack from '../utils/clack'; +import chalk from 'chalk'; +import * as semver from 'semver'; +import { + getLaravelVersion, + getLaravelProjectType, + getLaravelProjectTypeName, + getLaravelVersionBucket, + LaravelProjectType, + findLaravelServiceProvider, + findLaravelBootstrapFile, + detectLaravelStructure, +} from './utils'; + +/** + * Laravel framework configuration for the universal agent runner + */ +const MINIMUM_LARAVEL_VERSION = '9.0.0'; + +const LARAVEL_AGENT_CONFIG: FrameworkConfig = { + metadata: { + name: 'Laravel', + integration: Integration.laravel, + docsUrl: 'https://posthog.com/docs/libraries/php', + unsupportedVersionDocsUrl: 'https://posthog.com/docs/libraries/php', + gatherContext: async (options: WizardOptions) => { + const projectType = await getLaravelProjectType(options); + const serviceProvider = await findLaravelServiceProvider(options); + const bootstrapFile = findLaravelBootstrapFile(options); + const laravelStructure = detectLaravelStructure(options); + + return { + projectType, + serviceProvider, + bootstrapFile, + laravelStructure, + }; + }, + }, + + detection: { + packageName: 'laravel/framework', + packageDisplayName: 'Laravel', + usesPackageJson: false, + getVersion: (_packageJson: any) => { + // For Laravel, we don't use package.json. Version is extracted separately + // from composer.json in the wizard entry point + return undefined; + }, + getVersionBucket: getLaravelVersionBucket, + }, + + environment: { + uploadToHosting: false, + getEnvVars: (apiKey: string, host: string) => ({ + POSTHOG_API_KEY: apiKey, + POSTHOG_HOST: host, + }), + }, + + analytics: { + getTags: (context: any) => { + const projectType = context.projectType as LaravelProjectType; + return { + projectType: projectType || 'unknown', + laravelStructure: context.laravelStructure || 'unknown', + }; + }, + }, + + prompts: { + projectTypeDetection: + 'This is a PHP/Laravel project. Look for composer.json, artisan CLI, and app/ directory structure to confirm. Check for Laravel-specific packages like laravel/framework.', + packageInstallation: + 'Use Composer to install packages. Run `composer require posthog/posthog-php` without pinning a specific version.', + getAdditionalContextLines: (context: any) => { + const projectType = context.projectType as LaravelProjectType; + const projectTypeName = projectType + ? getLaravelProjectTypeName(projectType) + : 'unknown'; + + const lines = [ + `Project type: ${projectTypeName}`, + `Framework docs ID: php (use posthog://docs/frameworks/php for documentation)`, + `Laravel structure: ${context.laravelStructure} (affects where to add configuration)`, + ]; + + if (context.serviceProvider) { + lines.push(`Service provider: ${context.serviceProvider}`); + } + + if (context.bootstrapFile) { + lines.push(`Bootstrap file: ${context.bootstrapFile}`); + } + + // Add Laravel-specific guidance based on version structure + if (context.laravelStructure === 'latest') { + lines.push( + 'Note: Laravel 11+ uses simplified bootstrap/app.php for middleware and providers', + ); + } else { + lines.push( + 'Note: Use app/Http/Kernel.php for middleware, app/Providers for service providers', + ); + } + + return lines; + }, + }, + + ui: { + successMessage: 'PostHog integration complete', + estimatedDurationMinutes: 5, + getOutroChanges: (context: any) => { + const projectType = context.projectType as LaravelProjectType; + const projectTypeName = projectType + ? getLaravelProjectTypeName(projectType) + : 'Laravel'; + + const changes = [ + `Analyzed your ${projectTypeName} project structure`, + `Installed the PostHog PHP package via Composer`, + `Configured PostHog in your Laravel application`, + ]; + + if (context.laravelStructure === 'latest') { + changes.push('Added PostHog initialization to bootstrap/app.php'); + } else { + changes.push('Created a PostHog service provider for initialization'); + } + + if (projectType === LaravelProjectType.INERTIA) { + changes.push('Configured PostHog to work with Inertia.js'); + } + + if (projectType === LaravelProjectType.LIVEWIRE) { + changes.push('Configured PostHog to work with Livewire'); + } + + return changes; + }, + getOutroNextSteps: () => [ + 'Start your Laravel development server with `php artisan serve`', + 'Visit your PostHog dashboard to see incoming events', + 'Use PostHog::capture() to track custom events', + 'Use PostHog::identify() to associate events with users', + ], + }, +}; + +/** + * Laravel wizard powered by the universal agent runner. + */ +export async function runLaravelWizardAgent( + options: WizardOptions, +): Promise { + if (options.debug) { + enableDebugLogs(); + } + + // Check Laravel version - agent wizard requires >= 9.0.0 + const laravelVersion = getLaravelVersion(options); + + if (laravelVersion) { + const coercedVersion = semver.coerce(laravelVersion); + if (coercedVersion && semver.lt(coercedVersion, MINIMUM_LARAVEL_VERSION)) { + const docsUrl = + LARAVEL_AGENT_CONFIG.metadata.unsupportedVersionDocsUrl ?? + LARAVEL_AGENT_CONFIG.metadata.docsUrl; + + clack.log.warn( + `Sorry: the wizard can't help you with Laravel ${laravelVersion}. Upgrade to Laravel ${MINIMUM_LARAVEL_VERSION} or later, or check out the manual setup guide.`, + ); + clack.log.info(`Setup Laravel manually: ${chalk.cyan(docsUrl)}`); + clack.outro('PostHog wizard will see you next time!'); + return; + } + } + + await runAgentWizard(LARAVEL_AGENT_CONFIG, options); +} diff --git a/src/laravel/utils.ts b/src/laravel/utils.ts new file mode 100644 index 0000000..6642d4e --- /dev/null +++ b/src/laravel/utils.ts @@ -0,0 +1,271 @@ +import { major, minVersion } from 'semver'; +import fg from 'fast-glob'; +import clack from '../utils/clack'; +import type { WizardOptions } from '../utils/types'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export enum LaravelProjectType { + STANDARD = 'standard', // Basic Laravel app + INERTIA = 'inertia', // Inertia.js (Vue/React SPA) - may need JS SDK too + LIVEWIRE = 'livewire', // Livewire (reactive components, includes Filament) +} + +/** + * Ignore patterns for Laravel projects + */ +const LARAVEL_IGNORE_PATTERNS = [ + '**/node_modules/**', + '**/vendor/**', + '**/storage/**', + '**/bootstrap/cache/**', + '**/.phpunit.cache/**', + '**/public/build/**', + '**/public/hot/**', +]; + +/** + * Get Laravel version bucket for analytics + */ +export function getLaravelVersionBucket(version: string | undefined): string { + if (!version) { + return 'none'; + } + + try { + const minVer = minVersion(version); + if (!minVer) { + return 'invalid'; + } + const majorVersion = major(minVer); + if (majorVersion >= 9) { + return `${majorVersion}.x`; + } + return '<9.0.0'; + } catch { + return 'unknown'; + } +} + +/** + * Read and parse composer.json + */ +export function getComposerJson( + options: Pick, +): Record | undefined { + const { installDir } = options; + + const composerPath = path.join(installDir, 'composer.json'); + try { + const content = fs.readFileSync(composerPath, 'utf-8'); + return JSON.parse(content); + } catch { + return undefined; + } +} + +/** + * Check if a package is installed (present in composer.json) + */ +function hasComposerPackage( + packageName: string, + options: Pick, +): boolean { + const composer = getComposerJson(options); + if (!composer) return false; + + return !!( + composer.require?.[packageName] || composer['require-dev']?.[packageName] + ); +} + +/** + * Extract version for a package from composer.json + */ +function getComposerPackageVersion( + packageName: string, + options: Pick, +): string | undefined { + const composer = getComposerJson(options); + if (!composer) return undefined; + + const version = + composer.require?.[packageName] || composer['require-dev']?.[packageName]; + if (version) { + // Clean version string (remove ^, ~, >=, etc.) + return version.replace(/^[\^~>=<]+/, ''); + } + + return undefined; +} + +/** + * Check if a pattern exists in PHP source files + */ +async function hasLaravelCodePattern( + pattern: RegExp | string, + options: Pick, + filePatterns: string[] = ['**/*.php'], +): Promise { + const { installDir } = options; + + const phpFiles = await fg(filePatterns, { + cwd: installDir, + ignore: LARAVEL_IGNORE_PATTERNS, + }); + + const searchPattern = + typeof pattern === 'string' ? new RegExp(pattern) : pattern; + + for (const phpFile of phpFiles) { + try { + const content = fs.readFileSync(path.join(installDir, phpFile), 'utf-8'); + if (searchPattern.test(content)) return true; + } catch { + continue; + } + } + + return false; +} + +/** + * Get Laravel version from composer.json + */ +export function getLaravelVersion( + options: Pick, +): string | undefined { + return getComposerPackageVersion('laravel/framework', options); +} + +/** + * Get human-readable name for Laravel project type + */ +export function getLaravelProjectTypeName( + projectType: LaravelProjectType, +): string { + switch (projectType) { + case LaravelProjectType.STANDARD: + return 'Standard Laravel'; + case LaravelProjectType.INERTIA: + return 'Laravel with Inertia.js'; + case LaravelProjectType.LIVEWIRE: + return 'Laravel with Livewire'; + default: + return 'Laravel'; + } +} + +/** + * Check for Inertia.js + */ +async function hasInertia( + options: Pick, +): Promise { + return ( + hasComposerPackage('inertiajs/inertia-laravel', options) || + (await hasLaravelCodePattern(/Inertia::render|inertia\(/, options)) + ); +} + +/** + * Check for Livewire + */ +async function hasLivewire( + options: Pick, +): Promise { + return ( + hasComposerPackage('livewire/livewire', options) || + (await hasLaravelCodePattern(/extends\s+Component|@livewire/, options)) + ); +} + +/** + * Detect Laravel project type + */ +export async function getLaravelProjectType( + options: WizardOptions, +): Promise { + // Check for SPA/Reactive frameworks (important to detect - affects SDK needs) + if (await hasInertia(options)) { + clack.log.info('Detected Laravel with Inertia.js'); + return LaravelProjectType.INERTIA; + } + if (await hasLivewire(options)) { + clack.log.info('Detected Laravel with Livewire'); + return LaravelProjectType.LIVEWIRE; + } + + // Default to standard + clack.log.info('Detected standard Laravel project'); + return LaravelProjectType.STANDARD; +} + +/** + * Find the main service provider file + */ +export async function findLaravelServiceProvider( + options: Pick, +): Promise { + const { installDir } = options; + + // Look for AppServiceProvider first (most common place for setup) + const appServiceProvider = path.join( + installDir, + 'app/Providers/AppServiceProvider.php', + ); + + if (fs.existsSync(appServiceProvider)) { + return 'app/Providers/AppServiceProvider.php'; + } + + // Fall back to searching for any service provider + const providers = await fg(['**/app/Providers/*ServiceProvider.php'], { + cwd: installDir, + ignore: LARAVEL_IGNORE_PATTERNS, + }); + + return providers[0]; +} + +/** + * Find the bootstrap file (differs between Laravel versions) + */ +export function findLaravelBootstrapFile( + options: Pick, +): string | undefined { + const { installDir } = options; + + // Laravel 11+ uses bootstrap/app.php with new structure + const bootstrapApp = path.join(installDir, 'bootstrap/app.php'); + if (fs.existsSync(bootstrapApp)) { + return 'bootstrap/app.php'; + } + + // Older Laravel uses app/Http/Kernel.php + const httpKernel = path.join(installDir, 'app/Http/Kernel.php'); + if (fs.existsSync(httpKernel)) { + return 'app/Http/Kernel.php'; + } + + return undefined; +} + +/** + * Detect Laravel version structure for configuration guidance + */ +export function detectLaravelStructure( + options: Pick, +): 'legacy' | 'modern' | 'latest' { + const version = getLaravelVersion(options); + if (!version) return 'modern'; + + try { + const majorVersion = parseInt(version.split('.')[0], 10); + if (majorVersion >= 11) return 'latest'; // Laravel 11+ (new structure) + if (majorVersion >= 9) return 'modern'; // Laravel 9-10 + return 'legacy'; // Laravel 8 and below + } catch { + return 'modern'; + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 7630350..9eb5862 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -328,6 +328,67 @@ export const INTEGRATION_CONFIG = { nextSteps: '• Use posthog.identify() to associate events with users\n• Call posthog.capture() to capture custom events\n• Use feature flags with posthog.feature_enabled()', }, + [Integration.laravel]: { + name: 'Laravel', + filterPatterns: ['**/*.php'], + ignorePatterns: [ + 'node_modules', + 'vendor', + 'storage', + 'bootstrap/cache', + 'public/build', + 'public/hot', + '.phpunit.cache', + ], + detect: async (options) => { + const { installDir } = options; + + // Primary check: artisan file (definitive Laravel indicator) + const artisanPath = path.join(installDir, 'artisan'); + if (fs.existsSync(artisanPath)) { + try { + const content = fs.readFileSync(artisanPath, 'utf-8'); + if (content.includes('Laravel') || content.includes('Artisan')) { + return true; + } + } catch { + // Continue to other checks + } + } + + // Secondary check: composer.json with laravel/framework + const composerPath = path.join(installDir, 'composer.json'); + if (fs.existsSync(composerPath)) { + try { + const content = fs.readFileSync(composerPath, 'utf-8'); + const composer = JSON.parse(content); + if ( + composer.require?.['laravel/framework'] || + composer['require-dev']?.['laravel/framework'] + ) { + return true; + } + } catch { + // Continue to other checks + } + } + + // Tertiary check: Laravel-specific directory structure + const hasLaravelStructure = await fg( + ['**/bootstrap/app.php', '**/app/Http/Kernel.php'], + { cwd: installDir, ignore: ['**/vendor/**'] }, + ); + + return hasLaravelStructure.length > 0; + }, + generateFilesRules: '', + filterFilesRules: '', + docsUrl: 'https://posthog.com/docs/libraries/php', + defaultChanges: + '• Installed posthog/posthog-php via Composer\n• Added PostHog service provider\n• Configured automatic event tracking', + nextSteps: + '• Use PostHog::capture() to track custom events\n• Use PostHog::identify() to associate events with users', + }, } as const satisfies Record; export const INTEGRATION_ORDER = [ @@ -338,5 +399,6 @@ export const INTEGRATION_ORDER = [ Integration.reactRouter, Integration.django, Integration.flask, + Integration.laravel, Integration.react, ] as const; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6c2f63b..936cff4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -7,6 +7,7 @@ export enum Integration { reactRouter = 'react-router', django = 'django', flask = 'flask', + laravel = 'laravel', } export enum FeatureFlagDefinition { @@ -31,6 +32,8 @@ export function getIntegrationDescription(type: string): string { return 'Django'; case Integration.flask: return 'Flask'; + case Integration.laravel: + return 'Laravel'; default: throw new Error(`Unknown integration ${type}`); } diff --git a/src/run.ts b/src/run.ts index df957c6..33afa98 100644 --- a/src/run.ts +++ b/src/run.ts @@ -20,6 +20,7 @@ import { runAstroWizard } from './astro/astro-wizard'; import { runReactRouterWizardAgent } from './react-router/react-router-wizard-agent'; import { runDjangoWizardAgent } from './django/django-wizard-agent'; import { runFlaskWizardAgent } from './flask/flask-wizard-agent'; +import { runLaravelWizardAgent } from './laravel/laravel-wizard-agent'; import { EventEmitter } from 'events'; import chalk from 'chalk'; import { RateLimitError } from './utils/errors'; @@ -128,6 +129,16 @@ export async function runWizard(argv: Args) { ); await runFlaskWizardAgent(wizardOptions); break; + case Integration.laravel: + clack.log.info( + `${chalk.yellow( + '[BETA]', + )} The Laravel wizard is in beta. Questions or feedback? Email ${chalk.cyan( + 'wizard@posthog.com', + )}`, + ); + await runLaravelWizardAgent(wizardOptions); + break; default: clack.log.error('No setup wizard selected!'); } @@ -191,6 +202,7 @@ async function getIntegrationForSetup( { value: Integration.reactRouter, label: 'React Router' }, { value: Integration.django, label: 'Django' }, { value: Integration.flask, label: 'Flask' }, + { value: Integration.laravel, label: 'Laravel' }, { value: Integration.svelte, label: 'Svelte' }, { value: Integration.reactNative, label: 'React Native' }, ],