Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, FrameworkConfig> = {
[Integration.nextjs]: NEXTJS_AGENT_CONFIG,
Expand All @@ -35,5 +37,7 @@ export const FRAMEWORK_REGISTRY: Record<Integration, FrameworkConfig> = {
[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,
};
120 changes: 120 additions & 0 deletions src/rails/rails-wizard-agent.ts
Original file line number Diff line number Diff line change
@@ -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<RailsContext> = {
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',
],
},
};
180 changes: 180 additions & 0 deletions src/rails/utils.ts
Original file line number Diff line number Diff line change
@@ -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<WizardOptions, 'installDir'>,
): 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<WizardOptions, 'installDir'>,
): 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<WizardOptions, 'installDir'>,
): 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<WizardOptions, 'installDir'>,
): 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<WizardOptions, 'installDir'>,
): 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<WizardOptions, 'installDir'>,
): Promise<boolean> {
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;
}
Loading
Loading