diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fa53e5b..fa5a64c 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,9 +19,11 @@ export enum Integration { sveltekit = 'sveltekit', swift = 'swift', android = 'android', + rails = 'rails', // Language fallbacks python = 'python', + ruby = 'ruby', } export interface Args { debug: boolean; diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 3787f29..4765460 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -16,7 +16,9 @@ import { LARAVEL_AGENT_CONFIG } from '../laravel/laravel-wizard-agent'; import { SVELTEKIT_AGENT_CONFIG } from '../svelte/svelte-wizard-agent'; import { SWIFT_AGENT_CONFIG } from '../swift/swift-wizard-agent'; import { ANDROID_AGENT_CONFIG } from '../android/android-wizard-agent'; +import { RAILS_AGENT_CONFIG } from '../rails/rails-wizard-agent'; import { PYTHON_AGENT_CONFIG } from '../python/python-wizard-agent'; +import { RUBY_AGENT_CONFIG } from '../ruby/ruby-wizard-agent'; export const FRAMEWORK_REGISTRY: Record = { [Integration.nextjs]: NEXTJS_AGENT_CONFIG, @@ -35,5 +37,7 @@ export const FRAMEWORK_REGISTRY: Record = { [Integration.sveltekit]: SVELTEKIT_AGENT_CONFIG, [Integration.swift]: SWIFT_AGENT_CONFIG, [Integration.android]: ANDROID_AGENT_CONFIG, + [Integration.rails]: RAILS_AGENT_CONFIG, [Integration.python]: PYTHON_AGENT_CONFIG, + [Integration.ruby]: RUBY_AGENT_CONFIG, }; diff --git a/src/rails/rails-wizard-agent.ts b/src/rails/rails-wizard-agent.ts new file mode 100644 index 0000000..6df8310 --- /dev/null +++ b/src/rails/rails-wizard-agent.ts @@ -0,0 +1,120 @@ +/* Ruby on Rails wizard using posthog-agent with PostHog MCP */ +import type { WizardOptions } from '../utils/types'; +import type { FrameworkConfig } from '../lib/framework-config'; +import { Integration } from '../lib/constants'; +import { + getRailsVersion, + getRailsProjectType, + getRailsProjectTypeName, + getRailsVersionBucket, + RailsProjectType, + findInitializersDir, + isRailsProject, +} from './utils'; + +type RailsContext = { + projectType?: RailsProjectType; + initializersDir?: string; +}; + +export const RAILS_AGENT_CONFIG: FrameworkConfig = { + metadata: { + name: 'Ruby on Rails', + integration: Integration.rails, + beta: true, + docsUrl: 'https://posthog.com/docs/libraries/ruby-on-rails', + unsupportedVersionDocsUrl: 'https://posthog.com/docs/libraries/ruby', + gatherContext: (options: WizardOptions) => { + const projectType = getRailsProjectType(options); + const initializersDir = findInitializersDir(options); + return Promise.resolve({ projectType, initializersDir }); + }, + }, + + detection: { + packageName: 'rails', + packageDisplayName: 'Ruby on Rails', + usesPackageJson: false, + getVersion: () => undefined, + getVersionBucket: getRailsVersionBucket, + minimumVersion: '6.0.0', + getInstalledVersion: (options: WizardOptions) => + Promise.resolve(getRailsVersion(options)), + detect: async (options) => isRailsProject(options), + }, + + environment: { + uploadToHosting: false, + getEnvVars: (apiKey: string, host: string) => ({ + POSTHOG_API_KEY: apiKey, + POSTHOG_HOST: host, + }), + }, + + analytics: { + getTags: (context) => ({ + projectType: context.projectType || 'unknown', + }), + }, + + prompts: { + projectTypeDetection: + 'This is a Ruby on Rails project. Look for Gemfile, config/application.rb, bin/rails, and config/routes.rb to confirm.', + packageInstallation: + "Use Bundler to install gems. Add `gem 'posthog-ruby'` and `gem 'posthog-rails'` to the Gemfile and run `bundle install`. Do not pin specific versions.", + getAdditionalContextLines: (context) => { + const projectTypeName = context.projectType + ? getRailsProjectTypeName(context.projectType) + : 'unknown'; + + const lines = [ + `Project type: ${projectTypeName}`, + `Framework docs ID: ruby-on-rails (use posthog://docs/frameworks/ruby-on-rails for documentation)`, + ]; + + if (context.initializersDir) { + lines.push(`Initializers directory: ${context.initializersDir}`); + } + + if (context.projectType === RailsProjectType.API) { + lines.push( + 'Note: This is an API-only Rails app — skip frontend posthog-js integration', + ); + } + + return lines; + }, + }, + + ui: { + successMessage: 'PostHog integration complete', + estimatedDurationMinutes: 5, + getOutroChanges: (context) => { + const projectTypeName = context.projectType + ? getRailsProjectTypeName(context.projectType) + : 'Rails'; + + const changes = [ + `Analyzed your ${projectTypeName} project structure`, + `Installed the posthog-ruby and posthog-rails gems via Bundler`, + `Created PostHog initializer in config/initializers/posthog.rb`, + `Configured automatic exception capture and ActiveJob instrumentation`, + ]; + + if (context.projectType !== RailsProjectType.API) { + changes.push( + 'Added posthog-js snippet to the layout template for frontend tracking', + ); + } + + return changes; + }, + getOutroNextSteps: () => [ + 'Start your Rails development server with `bin/rails server`', + 'Visit your PostHog dashboard to see incoming events', + 'Use PostHog.capture() to track custom events', + 'Use PostHog.identify() to associate events with users', + 'Define posthog_distinct_id on your User model for automatic user association', + ], + }, +}; diff --git a/src/rails/utils.ts b/src/rails/utils.ts new file mode 100644 index 0000000..9c83d72 --- /dev/null +++ b/src/rails/utils.ts @@ -0,0 +1,180 @@ +import fg from 'fast-glob'; +import clack from '../utils/clack'; +import type { WizardOptions } from '../utils/types'; +import { createVersionBucket } from '../utils/semver'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export enum RailsProjectType { + STANDARD = 'standard', // Traditional Rails app (rails new) + API = 'api', // Rails API-only (rails new --api) +} + +const IGNORE_PATTERNS = [ + '**/node_modules/**', + '**/vendor/**', + '**/vendor/bundle/**', + '**/tmp/**', + '**/log/**', + '**/storage/**', +]; + +/** + * Get Rails version bucket for analytics + */ +export const getRailsVersionBucket = createVersionBucket(); + +/** + * Read and parse Gemfile contents + */ +function readGemfile( + options: Pick, +): string | undefined { + const { installDir } = options; + + const gemfilePath = path.join(installDir, 'Gemfile'); + try { + return fs.readFileSync(gemfilePath, 'utf-8'); + } catch { + return undefined; + } +} + +/** + * Check if a gem is present in the Gemfile + */ +export function hasGem( + gemName: string, + options: Pick, +): boolean { + const content = readGemfile(options); + if (!content) return false; + + // Match gem declarations like: gem 'rails', gem "rails", gem 'rails', '~> 7.0' + const gemPattern = new RegExp(`^\\s*gem\\s+['"]${gemName}['"]`, 'im'); + return gemPattern.test(content); +} + +/** + * Extract version for a gem from Gemfile + */ +export function getGemVersion( + gemName: string, + options: Pick, +): string | undefined { + const content = readGemfile(options); + if (!content) return undefined; + + const versionPattern = new RegExp( + `^\\s*gem\\s+['"]${gemName}['"]\\s*,\\s*['"][^0-9]*([0-9]+\\.[0-9]+(?:\\.[0-9]+)?)['"]`, + 'im', + ); + const match = content.match(versionPattern); + return match?.[1]; +} + +/** + * Get Rails version from Gemfile + */ +export function getRailsVersion( + options: Pick, +): string | undefined { + return getGemVersion('rails', options); +} + +/** + * Detect Rails project type + */ +export function getRailsProjectType(options: WizardOptions): RailsProjectType { + const { installDir } = options; + + // Check for API-only mode in config/application.rb + const appConfigPath = path.join(installDir, 'config/application.rb'); + if (fs.existsSync(appConfigPath)) { + try { + const content = fs.readFileSync(appConfigPath, 'utf-8'); + if (content.includes('config.api_only = true')) { + clack.log.info('Detected Rails API-only project'); + return RailsProjectType.API; + } + } catch { + // Continue to default + } + } + + clack.log.info('Detected standard Rails project'); + return RailsProjectType.STANDARD; +} + +/** + * Get human-readable name for Rails project type + */ +export function getRailsProjectTypeName(projectType: RailsProjectType): string { + switch (projectType) { + case RailsProjectType.STANDARD: + return 'Standard Rails'; + case RailsProjectType.API: + return 'Rails API'; + } +} + +/** + * Find the Rails initializers directory + */ +export function findInitializersDir( + options: Pick, +): string | undefined { + const { installDir } = options; + + const initializersDir = path.join(installDir, 'config/initializers'); + if (fs.existsSync(initializersDir)) { + return 'config/initializers'; + } + + return undefined; +} + +/** + * Detect if the project is a Rails project by looking for typical Rails files + */ +export async function isRailsProject( + options: Pick, +): Promise { + const { installDir } = options; + + // Check for bin/rails + const binRailsPath = path.join(installDir, 'bin/rails'); + if (fs.existsSync(binRailsPath)) { + return true; + } + + // Check for config/application.rb with Rails reference + const appConfigPath = path.join(installDir, 'config/application.rb'); + if (fs.existsSync(appConfigPath)) { + try { + const content = fs.readFileSync(appConfigPath, 'utf-8'); + if ( + content.includes('Rails::Application') || + content.includes('require "rails"') || + content.includes("require 'rails'") + ) { + return true; + } + } catch { + // Continue to other checks + } + } + + // Check Gemfile for rails gem + if (hasGem('rails', options)) { + return true; + } + + // Check for typical Rails directory structure + const railsStructureFiles = await fg( + ['config/routes.rb', 'config/environment.rb'], + { cwd: installDir, ignore: IGNORE_PATTERNS }, + ); + + return railsStructureFiles.length >= 2; +} diff --git a/src/ruby/ruby-wizard-agent.ts b/src/ruby/ruby-wizard-agent.ts new file mode 100644 index 0000000..c9143e7 --- /dev/null +++ b/src/ruby/ruby-wizard-agent.ts @@ -0,0 +1,132 @@ +/* Generic Ruby language wizard using posthog-agent with PostHog MCP */ +import type { WizardOptions } from '../utils/types'; +import type { FrameworkConfig } from '../lib/framework-config'; +import { Integration } from '../lib/constants'; +import { + getRubyVersion, + getRubyVersionBucket, + detectPackageManager, + getPackageManagerName, + RubyPackageManager, + isRubyProject, +} from './utils'; + +type RubyContext = { + packageManager?: RubyPackageManager; +}; + +export const RUBY_AGENT_CONFIG: FrameworkConfig = { + metadata: { + name: 'Ruby', + integration: Integration.ruby, + beta: true, + docsUrl: 'https://posthog.com/docs/libraries/ruby', + gatherContext: (options: WizardOptions) => { + const packageManager = detectPackageManager(options); + return Promise.resolve({ packageManager }); + }, + }, + + detection: { + packageName: 'ruby', + packageDisplayName: 'Ruby', + usesPackageJson: false, + getVersion: () => undefined, + getVersionBucket: getRubyVersionBucket, + minimumVersion: '2.7.0', + getInstalledVersion: (options: WizardOptions) => + Promise.resolve(getRubyVersion(options)), + detect: async (options) => isRubyProject(options), + }, + + environment: { + uploadToHosting: false, + getEnvVars: (apiKey: string, host: string) => ({ + POSTHOG_API_KEY: apiKey, + POSTHOG_HOST: host, + }), + }, + + analytics: { + getTags: (context) => { + const packageManagerName = context.packageManager + ? getPackageManagerName(context.packageManager) + : 'unknown'; + return { + packageManager: packageManagerName, + }; + }, + }, + + prompts: { + projectTypeDetection: + 'This is a Ruby project. Look for Gemfile, *.gemspec, .ruby-version, or *.rb files to confirm.', + packageInstallation: + "Use Bundler if a Gemfile is present (add `gem 'posthog-ruby'` and run `bundle install`). Otherwise use `gem install posthog-ruby`. Do not pin a specific version.", + getAdditionalContextLines: (context) => { + const packageManagerName = context.packageManager + ? getPackageManagerName(context.packageManager) + : 'unknown'; + + const lines = [ + `Package manager: ${packageManagerName}`, + `Framework docs ID: ruby (use posthog://docs/frameworks/ruby for documentation)`, + `Project type: Generic Ruby application (CLI, script, gem, worker, etc.)`, + ``, + `## CRITICAL: Ruby PostHog Best Practices`, + ``, + `### 1. Gem Name vs Require`, + `The gem is named posthog-ruby but you require it as 'posthog':`, + ` gem 'posthog-ruby' # in Gemfile`, + ` require 'posthog' # in code (NOT require 'posthog-ruby')`, + ``, + `### 2. Use Instance-Based API (REQUIRED for scripts/CLIs)`, + `Use PostHog::Client.new for scripts and standalone applications:`, + ``, + `client = PostHog::Client.new(`, + ` api_key: ENV['POSTHOG_API_KEY'],`, + ` host: ENV['POSTHOG_HOST'] || 'https://us.i.posthog.com'`, + `)`, + ``, + `### 3. MUST Call shutdown Before Exit`, + `In scripts and CLIs, you MUST call client.shutdown or events will be lost:`, + ``, + `begin`, + ` client.capture(distinct_id: 'user_123', event: 'my_event')`, + `ensure`, + ` client.shutdown`, + `end`, + ``, + `### 4. capture_exception Takes Positional Args`, + `client.capture_exception(exception, distinct_id, additional_properties)`, + `Do NOT use keyword arguments for capture_exception.`, + ``, + `### 5. NEVER Send PII`, + `Do NOT include emails, names, phone numbers, or user content in event properties.`, + ]; + + return lines; + }, + }, + + ui: { + successMessage: 'PostHog integration complete', + estimatedDurationMinutes: 5, + getOutroChanges: (context) => { + const packageManagerName = context.packageManager + ? getPackageManagerName(context.packageManager) + : 'package manager'; + return [ + `Analyzed your Ruby project structure`, + `Installed the posthog-ruby gem using ${packageManagerName}`, + `Created PostHog initialization with instance-based API`, + `Configured shutdown handler for proper event flushing`, + ]; + }, + getOutroNextSteps: () => [ + 'Use client.capture() for events and client.identify() for users', + 'Always call client.shutdown() before your application exits', + 'Visit your PostHog dashboard to see incoming events', + ], + }, +}; diff --git a/src/ruby/utils.ts b/src/ruby/utils.ts new file mode 100644 index 0000000..9ba65fe --- /dev/null +++ b/src/ruby/utils.ts @@ -0,0 +1,137 @@ +import fg from 'fast-glob'; +import type { WizardOptions } from '../utils/types'; +import { createVersionBucket } from '../utils/semver'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export enum RubyPackageManager { + BUNDLER = 'bundler', + MANUAL = 'manual', +} + +const IGNORE_PATTERNS = [ + '**/node_modules/**', + '**/vendor/**', + '**/vendor/bundle/**', + '**/tmp/**', + '**/log/**', +]; + +/** + * Get Ruby version bucket for analytics + */ +export const getRubyVersionBucket = createVersionBucket(); + +/** + * Detect Ruby package manager + */ +export function detectPackageManager( + options: Pick, +): RubyPackageManager { + const { installDir } = options; + + const gemfilePath = path.join(installDir, 'Gemfile'); + if (fs.existsSync(gemfilePath)) { + return RubyPackageManager.BUNDLER; + } + + return RubyPackageManager.MANUAL; +} + +/** + * Get human-readable name for package manager + */ +export function getPackageManagerName( + packageManager: RubyPackageManager, +): string { + switch (packageManager) { + case RubyPackageManager.BUNDLER: + return 'Bundler'; + case RubyPackageManager.MANUAL: + return 'gem install'; + } +} + +/** + * Get Ruby version from .ruby-version file or Gemfile + */ +export function getRubyVersion( + options: Pick, +): string | undefined { + const { installDir } = options; + + // Check .ruby-version file + const rubyVersionPath = path.join(installDir, '.ruby-version'); + try { + const content = fs.readFileSync(rubyVersionPath, 'utf-8').trim(); + // Remove "ruby-" prefix if present + const version = content.replace(/^ruby-/, ''); + if (/^[0-9]+\.[0-9]+/.test(version)) { + return version; + } + } catch { + // Continue to other checks + } + + // Check Gemfile for ruby version declaration + const gemfilePath = path.join(installDir, 'Gemfile'); + try { + const content = fs.readFileSync(gemfilePath, 'utf-8'); + const match = content.match(/ruby\s+['"]([0-9]+\.[0-9]+(?:\.[0-9]+)?)['"]/); + if (match) { + return match[1]; + } + } catch { + // No Gemfile + } + + return undefined; +} + +/** + * Check if the project is a Ruby project (but not Rails) + */ +export async function isRubyProject( + options: Pick, +): Promise { + const { installDir } = options; + + // Check for Gemfile + const gemfilePath = path.join(installDir, 'Gemfile'); + if (fs.existsSync(gemfilePath)) { + // Make sure this isn't a Rails project (Rails should be detected first) + try { + const content = fs.readFileSync(gemfilePath, 'utf-8'); + if (/^\s*gem\s+['"]rails['"]/im.test(content)) { + return false; // Rails project, use rails agent instead + } + } catch { + // Continue checking + } + return true; + } + + // Check for .ruby-version file + const rubyVersionPath = path.join(installDir, '.ruby-version'); + if (fs.existsSync(rubyVersionPath)) { + return true; + } + + // Check for *.gemspec files + const gemspecFiles = await fg('*.gemspec', { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }); + + if (gemspecFiles.length > 0) { + return true; + } + + // Check for Ruby source files in the root + const rubyFiles = await fg(['*.rb', 'lib/**/*.rb', 'bin/**/*.rb'], { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }); + + return rubyFiles.length > 0; +}