diff --git a/bin.ts b/bin.ts index 1b9d9f1..190ef57 100644 --- a/bin.ts +++ b/bin.ts @@ -21,6 +21,11 @@ import { runMCPInstall, runMCPRemove } from './src/mcp'; import type { CloudRegion, WizardOptions } from './src/utils/types'; import { runWizard } from './src/run'; import { runEventSetupWizard } from './src/nextjs/event-setup'; +import { + runMigrationWizard, + getAvailableMigrationProviders, + type MigrationOptions, +} from './src/migrate'; import { readEnvironment, isNonInteractiveEnvironment, @@ -155,6 +160,62 @@ yargs(hideBin(process.argv)) void runEventSetupWizard(wizardOptions); }, ) + .command( + 'migrate', + 'Migrate from another analytics provider to PostHog', + (yargs) => { + const availableProviders = getAvailableMigrationProviders(); + return yargs + .options({ + 'install-dir': { + describe: + 'Directory to run the migration in\nenv: POSTHOG_WIZARD_INSTALL_DIR', + type: 'string', + }, + 'force-install': { + default: false, + describe: + 'Force install packages even if peer dependency checks fail\nenv: POSTHOG_WIZARD_FORCE_INSTALL', + type: 'boolean', + }, + from: { + describe: 'Analytics provider to migrate from', + choices: availableProviders, + default: 'amplitude', + type: 'string', + }, + }); + }, + (argv) => { + const finalArgs = { + ...argv, + ...readEnvironment(), + } as any; + + let resolvedInstallDir: string; + if (finalArgs.installDir) { + if (path.isAbsolute(finalArgs.installDir)) { + resolvedInstallDir = finalArgs.installDir; + } else { + resolvedInstallDir = path.join(process.cwd(), finalArgs.installDir); + } + } else { + resolvedInstallDir = process.cwd(); + } + + const migrationOptions: MigrationOptions = { + debug: finalArgs.debug ?? false, + installDir: resolvedInstallDir, + cloudRegion: finalArgs.region as CloudRegion | undefined, + default: finalArgs.default ?? false, + signup: finalArgs.signup ?? false, + forceInstall: finalArgs.forceInstall ?? false, + localMcp: finalArgs.localMcp ?? false, + }; + + void runMigrationWizard(migrationOptions, finalArgs.from as string); + }, + ) .command('mcp ', 'MCP server management commands', (yargs) => { return yargs .command( diff --git a/e2e-tests/fixtures/18c11dda2ee4cd5c20c337d23dd61387.json b/e2e-tests/fixtures/18c11dda2ee4cd5c20c337d23dd61387.json index c1d63c6..fd21f0d 100644 --- a/e2e-tests/fixtures/18c11dda2ee4cd5c20c337d23dd61387.json +++ b/e2e-tests/fixtures/18c11dda2ee4cd5c20c337d23dd61387.json @@ -1,3 +1,3 @@ { - "newContent": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { PostHogProvider } from 'posthog-js/react'\nimport './index.css'\nimport App from './App.tsx'\n\ncreateRoot(document.getElementById('root')!).render(\n \n \n \n \n ,\n)\n" + "newContent": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport posthog from 'posthog-js'\nimport { PostHogProvider } from 'posthog-js/react'\nimport './index.css'\nimport App from './App.tsx'\n\nposthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {\n api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,\n defaults: '2025-05-24',\n capture_exceptions: true,\n debug: import.meta.env.MODE === 'development',\n});\n\ncreateRoot(document.getElementById('root')!).render(\n \n \n \n \n ,\n)\n" } \ No newline at end of file diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6a99ef1..8af1c9e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,6 +6,19 @@ export enum Integration { astro = 'astro', } +export enum MigrationSource { + amplitude = 'amplitude', +} + +export function getMigrationSourceDescription(source: MigrationSource): string { + switch (source) { + case MigrationSource.amplitude: + return 'Amplitude'; + default: + throw new Error(`Unknown migration source ${source}`); + } +} + export enum FeatureFlagDefinition { NextV2 = 'wizard-next-v2', } diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 73e7983..52257b3 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -79,3 +79,82 @@ Here are the files that have not been changed yet: Below is the current file contents: {file_content}`, }); + +export const migrationFilterFilesPromptTemplate = new PromptTemplate({ + inputVariables: [ + 'documentation', + 'file_list', + 'source_sdk', + 'integration_rules', + ], + template: `You are a PostHog migration wizard, a master AI programming assistant that migrates projects from {source_sdk} to PostHog. +Given the following list of file paths from a project, determine which files contain {source_sdk} code that needs to be migrated to PostHog. + +- Look for files that import or use {source_sdk} SDK +- Look for files that initialize {source_sdk} +- Look for files that track events with {source_sdk} +- Look for files that identify users with {source_sdk} +- Look for configuration files that may reference {source_sdk} + +You should return all files that contain {source_sdk} code that needs to be migrated. Return them in the order you would like to see them processed. + +Rules: +- Only return files that actually contain {source_sdk} code or references +- Do not return files that don't use {source_sdk} +- If you are unsure, return the file, since it's better to have more files than less +- Include any configuration or initialization files +{integration_rules} + +Migration documentation: +{documentation} + +All current files in the repository: + +{file_list}`, +}); + +export const migrationGenerateFileChangesPromptTemplate = new PromptTemplate({ + inputVariables: [ + 'file_content', + 'documentation', + 'file_path', + 'changed_files', + 'unchanged_files', + 'source_sdk', + 'integration_rules', + ], + template: `You are a PostHog migration wizard, a master AI programming assistant that migrates projects from {source_sdk} to PostHog. + +Your task is to migrate the file from {source_sdk} to PostHog according to the migration documentation. +Do not return a diff — you should return the complete updated file content. + +Rules: +- Replace ALL {source_sdk} imports with PostHog imports +- Replace ALL {source_sdk} initialization code with PostHog initialization +- Replace ALL {source_sdk} tracking calls with PostHog equivalents +- Replace ALL {source_sdk} identify calls with PostHog equivalents +- Remove ALL {source_sdk}-specific code that has no PostHog equivalent +- Preserve the existing code formatting and style +- Make sure to remove any unused {source_sdk} imports after migration +- If the file has no {source_sdk} code after review, return it unchanged +{integration_rules} + + +CONTEXT +--- + +Migration documentation from {source_sdk} to PostHog: +{documentation} + +The file you are updating is: +{file_path} + +Here are the changes you have already made to the project: +{changed_files} + +Here are the files that have not been changed yet: +{unchanged_files} + +Below is the current file contents: +{file_content}`, +}); diff --git a/src/migrate/index.ts b/src/migrate/index.ts new file mode 100644 index 0000000..edbe457 --- /dev/null +++ b/src/migrate/index.ts @@ -0,0 +1,40 @@ +export { + runMigrationWizard, + checkAndOfferMigration, + detectProviderInstallation, + getAllInstalledProviderPackages, +} from './migration-wizard'; + +export type { + MigrationProviderConfig, + InstalledPackage, + MigrationOptions, + MigrationDocsOptions, + MigrationContext, + MigrationOutroOptions, +} from './types'; + +export { + getMigrationProvider, + getAvailableMigrationProviders, + migrationProviders, + amplitudeProvider, +} from './providers'; + +export { AMPLITUDE_PACKAGES } from './providers/amplitude'; + +import { runMigrationWizard, checkAndOfferMigration } from './migration-wizard'; +import type { MigrationOptions } from './types'; +import type { WizardOptions } from '../utils/types'; + +export async function runAmplitudeMigrationWizard( + options: MigrationOptions, +): Promise { + return runMigrationWizard(options, 'amplitude'); +} + +export async function checkAndOfferAmplitudeMigration( + options: WizardOptions, +): Promise { + return checkAndOfferMigration(options, 'amplitude'); +} diff --git a/src/migrate/migration-wizard.ts b/src/migrate/migration-wizard.ts new file mode 100644 index 0000000..e5d3987 --- /dev/null +++ b/src/migrate/migration-wizard.ts @@ -0,0 +1,632 @@ +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import path from 'path'; +import chalk from 'chalk'; +import z from 'zod'; +import fg from 'fast-glob'; + +import { + abort, + abortIfCancelled, + confirmContinueIfNoOrDirtyGitRepo, + getOrAskForProjectData, + getPackageDotJson, + getPackageManager, + installPackage, + isUsingTypeScript, + printWelcome, + askForCloudRegion, + askForAIConsent, +} from '../utils/clack-utils'; +import clack from '../utils/clack'; +import { analytics } from '../utils/analytics'; +import { detectEnvVarPrefix } from '../utils/environment'; +import { query } from '../utils/query'; +import { updateFile, GLOBAL_IGNORE_PATTERN } from '../utils/file-utils'; +import type { CloudRegion, FileChange, WizardOptions } from '../utils/types'; +import type { PackageDotJson } from '../utils/package-json'; +import { Integration } from '../lib/constants'; +import { + migrationFilterFilesPromptTemplate, + migrationGenerateFileChangesPromptTemplate, +} from '../lib/prompts'; +import { + addEditorRulesStep, + addMCPServerToClientsStep, + addOrUpdateEnvironmentVariablesStep, + runPrettierStep, +} from '../steps'; +import { uploadEnvironmentVariablesStep } from '../steps/upload-environment-variables'; +import type { PackageManager } from '../utils/package-manager'; +import type { + MigrationProviderConfig, + InstalledPackage, + MigrationOptions, + MigrationOutroOptions, +} from './types'; +import { getMigrationProvider } from './providers'; + +export function detectProviderInstallation( + packageJson: PackageDotJson, + provider: MigrationProviderConfig, +): InstalledPackage | undefined { + for (const pkgName of provider.packages) { + const version = + packageJson?.dependencies?.[pkgName] || + packageJson?.devDependencies?.[pkgName]; + + if (version) { + return { packageName: pkgName, version }; + } + } + return undefined; +} + +export function getAllInstalledProviderPackages( + packageJson: PackageDotJson, + provider: MigrationProviderConfig, +): InstalledPackage[] { + const installedPackages: InstalledPackage[] = []; + + for (const pkgName of provider.packages) { + const version = + packageJson?.dependencies?.[pkgName] || + packageJson?.devDependencies?.[pkgName]; + + if (version) { + installedPackages.push({ packageName: pkgName, version }); + } + } + + return installedPackages; +} + +export async function runMigrationWizard( + options: MigrationOptions, + providerId: string, +): Promise { + const provider = getMigrationProvider(providerId); + + if (!provider) { + clack.log.error(`Unknown migration provider: ${providerId}`); + process.exit(1); + } + + printWelcome({ + wizardName: `PostHog Migration Wizard`, + message: `This wizard will help you migrate from ${provider.name} to PostHog.\nIt will replace ${provider.name} SDK code with PostHog equivalents.`, + }); + + const aiConsent = await askForAIConsent(options); + + if (!aiConsent) { + await abort( + `The migration wizard requires AI to work. Please view the docs to migrate manually: ${provider.docsUrl}`, + 0, + ); + } + + const cloudRegion = options.cloudRegion ?? (await askForCloudRegion()); + + await confirmContinueIfNoOrDirtyGitRepo(options); + + const packageJson = await getPackageDotJson(options); + + const providerInstallation = detectProviderInstallation( + packageJson, + provider, + ); + + if (!providerInstallation) { + clack.log.warn( + `No ${provider.name} SDK detected in your project. Are you sure you want to continue?`, + ); + + const continueAnyway = await abortIfCancelled( + clack.confirm({ + message: 'Continue with migration anyway?', + initialValue: false, + }), + ); + + if (!continueAnyway) { + await abort('Migration cancelled.', 0); + } + } else { + clack.log.success( + `Detected ${provider.name} installation: ${chalk.cyan( + providerInstallation.packageName, + )}@${providerInstallation.version}`, + ); + } + + analytics.setTag('migration-source', providerId); + analytics.setTag(`${providerId}-package`, providerInstallation?.packageName); + + const typeScriptDetected = isUsingTypeScript(options); + const envVarPrefix = await detectEnvVarPrefix(options); + + const { projectApiKey, accessToken, host, projectId } = + await getOrAskForProjectData({ + ...options, + cloudRegion, + }); + + const framework = await detectFramework(options); + analytics.setTag('migration-framework', framework); + + const allProviderPackages = getAllInstalledProviderPackages( + packageJson, + provider, + ); + + if (allProviderPackages.length > 0) { + await uninstallPackages(allProviderPackages, provider.name, options); + } + + const posthogPackages = new Set(); + for (const pkg of allProviderPackages) { + const posthogEquivalent = provider.getPostHogEquivalent(pkg.packageName); + if (posthogEquivalent) { + posthogPackages.add(posthogEquivalent); + } + } + + if (posthogPackages.size === 0) { + posthogPackages.add('posthog-js'); + } + + let packageManagerUsed: PackageManager | undefined; + for (const posthogPackage of posthogPackages) { + const { packageManager } = await installPackage({ + packageName: posthogPackage, + packageNameDisplayLabel: posthogPackage, + alreadyInstalled: false, + forceInstall: options.forceInstall, + askBeforeUpdating: false, + installDir: options.installDir, + integration: framework as Integration, + }); + packageManagerUsed = packageManager; + } + + const migrationDocumentation = provider.getMigrationDocs({ + language: typeScriptDetected ? 'typescript' : 'javascript', + envVarPrefix, + framework, + }); + + const relevantFiles = await getRelevantFilesForMigration(options); + + clack.log.info(`Reviewing project files for ${provider.name} code...`); + + const filesToMigrate = await getFilesToMigrate({ + relevantFiles, + documentation: migrationDocumentation, + accessToken, + cloudRegion, + projectId, + providerName: provider.name, + }); + + if (filesToMigrate.length === 0) { + clack.log.warn( + `No files with ${provider.name} code detected. The migration may already be complete.`, + ); + } else { + await migrateFiles({ + filesToMigrate, + accessToken, + documentation: migrationDocumentation, + installDir: options.installDir, + cloudRegion, + projectId, + providerName: provider.name, + }); + } + + const { relativeEnvFilePath, addedEnvVariables } = + await addOrUpdateEnvironmentVariablesStep({ + variables: { + [envVarPrefix + 'POSTHOG_KEY']: projectApiKey, + [envVarPrefix + 'POSTHOG_HOST']: host, + }, + installDir: options.installDir, + integration: framework as Integration, + }); + + const packageManagerForOutro = + packageManagerUsed ?? (await getPackageManager(options)); + + await runPrettierStep({ + installDir: options.installDir, + integration: framework as Integration, + }); + + const addedEditorRules = await addEditorRulesStep({ + installDir: options.installDir, + rulesName: 'react-rules.md', + integration: framework as Integration, + }); + + const uploadedEnvVars = await uploadEnvironmentVariablesStep( + { + [envVarPrefix + 'POSTHOG_KEY']: projectApiKey, + [envVarPrefix + 'POSTHOG_HOST']: host, + }, + { + integration: framework as Integration, + options, + }, + ); + + await addMCPServerToClientsStep({ + cloudRegion, + integration: framework as Integration, + }); + + const outroMessage = getMigrationOutroMessage({ + options, + cloudRegion, + provider, + addedEditorRules, + packageManager: packageManagerForOutro, + envFileChanged: addedEnvVariables ? relativeEnvFilePath : undefined, + uploadedEnvVars, + migratedFilesCount: filesToMigrate.length, + }); + + clack.outro(outroMessage); + + await analytics.shutdown('success'); +} + +async function detectFramework( + options: Pick, +): Promise<'react' | 'nextjs' | 'svelte' | 'astro' | 'react-native' | 'node'> { + const packageJson = await getPackageDotJson(options); + + if ( + packageJson?.dependencies?.['next'] || + packageJson?.devDependencies?.['next'] + ) { + return 'nextjs'; + } + if ( + packageJson?.dependencies?.['react-native'] || + packageJson?.devDependencies?.['react-native'] + ) { + return 'react-native'; + } + if ( + packageJson?.dependencies?.['@sveltejs/kit'] || + packageJson?.devDependencies?.['@sveltejs/kit'] + ) { + return 'svelte'; + } + if ( + packageJson?.dependencies?.['astro'] || + packageJson?.devDependencies?.['astro'] + ) { + return 'astro'; + } + if ( + packageJson?.dependencies?.['react'] || + packageJson?.devDependencies?.['react'] + ) { + return 'react'; + } + return 'node'; +} + +async function uninstallPackages( + packages: InstalledPackage[], + providerName: string, + options: Pick, +): Promise { + const packageManager = await getPackageManager(options); + + const uninstallSpinner = clack.spinner(); + uninstallSpinner.start( + `Removing ${providerName} packages: ${packages + .map((p) => p.packageName) + .join(', ')}`, + ); + + try { + const packageNames = packages.map((p) => p.packageName).join(' '); + const uninstallCommand = + packageManager.name === 'yarn' + ? `yarn remove ${packageNames}` + : packageManager.name === 'pnpm' + ? `pnpm remove ${packageNames}` + : `npm uninstall ${packageNames}`; + + await new Promise((resolve, reject) => { + childProcess.exec( + uninstallCommand, + { cwd: options.installDir }, + (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }, + ); + }); + + uninstallSpinner.stop( + `Removed ${providerName} packages: ${packages + .map((p) => chalk.cyan(p.packageName)) + .join(', ')}`, + ); + + analytics.capture('wizard interaction', { + action: 'uninstalled packages', + packages: packages.map((p) => p.packageName), + migration_source: providerName.toLowerCase(), + }); + } catch (error) { + uninstallSpinner.stop(`Failed to remove ${providerName} packages`); + clack.log.warn( + `Could not automatically remove ${providerName} packages. Please remove them manually.`, + ); + } +} + +async function getRelevantFilesForMigration( + options: Pick, +): Promise { + const filterPatterns = ['**/*.{tsx,ts,jsx,js,mjs,cjs}']; + const ignorePatterns = GLOBAL_IGNORE_PATTERN; + + const filteredFiles = await fg(filterPatterns, { + cwd: options.installDir, + ignore: ignorePatterns, + }); + + analytics.capture('wizard interaction', { + action: 'detected relevant files for migration', + number_of_files: filteredFiles.length, + }); + + return filteredFiles; +} + +async function getFilesToMigrate({ + relevantFiles, + documentation, + accessToken, + cloudRegion, + projectId, + providerName, +}: { + relevantFiles: string[]; + documentation: string; + accessToken: string; + cloudRegion: CloudRegion; + projectId: number; + providerName: string; +}): Promise { + const filterFilesSpinner = clack.spinner(); + filterFilesSpinner.start(`Scanning for ${providerName} code...`); + + const filterFilesResponseSchema = z.object({ + files: z.array(z.string()), + }); + + const prompt = await migrationFilterFilesPromptTemplate.format({ + documentation, + file_list: relevantFiles.join('\n'), + source_sdk: providerName, + integration_rules: '', + }); + + const filterFilesResponse = await query({ + message: prompt, + schema: filterFilesResponseSchema, + accessToken, + region: cloudRegion, + projectId, + }); + + const filesToMigrate = filterFilesResponse.files; + + filterFilesSpinner.stop( + `Found ${filesToMigrate.length} files with ${providerName} code`, + ); + + analytics.capture('wizard interaction', { + action: 'detected files to migrate', + files: filesToMigrate, + migration_source: providerName.toLowerCase(), + }); + + return filesToMigrate; +} + +async function migrateFiles({ + filesToMigrate, + accessToken, + documentation, + installDir, + cloudRegion, + projectId, + providerName, +}: { + filesToMigrate: string[]; + accessToken: string; + documentation: string; + installDir: string; + cloudRegion: CloudRegion; + projectId: number; + providerName: string; +}): Promise { + const changes: FileChange[] = []; + + for (const filePath of filesToMigrate) { + const fileChangeSpinner = clack.spinner(); + + analytics.capture('wizard interaction', { + action: 'migrating file', + file: filePath, + migration_source: providerName.toLowerCase(), + }); + + try { + let oldContent: string | undefined = undefined; + try { + oldContent = await fs.promises.readFile( + path.join(installDir, filePath), + 'utf8', + ); + } catch (readError: unknown) { + if ( + readError instanceof Error && + (readError as NodeJS.ErrnoException).code !== 'ENOENT' + ) { + clack.log.warn(`Error reading file ${filePath}`); + continue; + } + } + + if (!oldContent) { + continue; + } + + fileChangeSpinner.start(`Migrating ${filePath}`); + + const unchangedFiles = filesToMigrate.filter( + (f) => !changes.some((change) => change.filePath === f), + ); + + const prompt = await migrationGenerateFileChangesPromptTemplate.format({ + file_content: oldContent, + file_path: filePath, + documentation, + source_sdk: providerName, + integration_rules: '', + changed_files: changes + .map((change) => `${change.filePath}\n${change.newContent}`) + .join('\n'), + unchanged_files: unchangedFiles.join('\n'), + }); + + const response = await query({ + message: prompt, + schema: z.object({ + newContent: z.string(), + }), + accessToken, + region: cloudRegion, + projectId, + }); + + const newContent = response.newContent; + + if (newContent !== oldContent) { + await updateFile({ filePath, oldContent, newContent }, { installDir }); + changes.push({ filePath, oldContent, newContent }); + } + + fileChangeSpinner.stop(`Migrated ${filePath}`); + + analytics.capture('wizard interaction', { + action: 'migrated file', + file: filePath, + migration_source: providerName.toLowerCase(), + }); + } catch (error) { + fileChangeSpinner.stop(`Error migrating ${filePath}`); + clack.log.warn(`Could not migrate ${filePath}. Please migrate manually.`); + } + } + + analytics.capture('wizard interaction', { + action: 'completed migration', + files: filesToMigrate, + migration_source: providerName.toLowerCase(), + }); + + return changes; +} + +function getMigrationOutroMessage({ + options: _options, + cloudRegion, + provider, + addedEditorRules, + packageManager: _packageManager, + envFileChanged, + uploadedEnvVars, + migratedFilesCount, +}: MigrationOutroOptions): string { + const cloudUrl = + cloudRegion === 'eu' ? 'https://eu.posthog.com' : 'https://us.posthog.com'; + + let message = chalk.green(`Migration from ${provider.name} complete! 🎉\n\n`); + + message += chalk.bold('What we did:\n'); + message += provider.defaultChanges; + message += `\n• Migrated ${migratedFilesCount} file${ + migratedFilesCount !== 1 ? 's' : '' + }`; + + if (envFileChanged) { + message += `\n• Added environment variables to ${envFileChanged}`; + } + + if (uploadedEnvVars.length > 0) { + message += '\n• Uploaded environment variables to Vercel'; + } + + if (addedEditorRules) { + message += '\n• Added PostHog editor rules'; + } + + message += '\n\n'; + message += chalk.bold('Next steps:\n'); + message += provider.nextSteps; + + message += `\n\n`; + message += `View your data at: ${chalk.cyan(cloudUrl)}`; + + return message; +} + +export async function checkAndOfferMigration( + options: WizardOptions, + providerId: string, +): Promise { + const provider = getMigrationProvider(providerId); + if (!provider) return false; + + const packageJson = await getPackageDotJson(options); + const installation = detectProviderInstallation(packageJson, provider); + + if (!installation) { + return false; + } + + clack.log.warn( + `Detected ${provider.name} SDK: ${chalk.cyan(installation.packageName)}@${ + installation.version + }`, + ); + + const shouldMigrate = await abortIfCancelled( + clack.confirm({ + message: `Would you like to migrate from ${provider.name} to PostHog? This will replace ${provider.name} code with PostHog equivalents.`, + initialValue: true, + }), + ); + + analytics.capture('wizard interaction', { + action: `offered ${providerId} migration`, + accepted: shouldMigrate, + package: installation.packageName, + }); + + return shouldMigrate; +} diff --git a/src/migrate/providers/amplitude.ts b/src/migrate/providers/amplitude.ts new file mode 100644 index 0000000..b853339 --- /dev/null +++ b/src/migrate/providers/amplitude.ts @@ -0,0 +1,399 @@ +import type { MigrationProviderConfig, MigrationDocsOptions } from '../types'; + +export const AMPLITUDE_PACKAGES = [ + '@amplitude/analytics-browser', + '@amplitude/analytics-node', + '@amplitude/analytics-react-native', + 'amplitude-js', +] as const; + +const AMPLITUDE_TO_POSTHOG_MAP: Record = { + '@amplitude/analytics-browser': 'posthog-js', + 'amplitude-js': 'posthog-js', + '@amplitude/analytics-node': 'posthog-node', + '@amplitude/analytics-react-native': 'posthog-react-native', +}; + +function getPostHogEquivalent(amplitudePackage: string): string | undefined { + return AMPLITUDE_TO_POSTHOG_MAP[amplitudePackage]; +} + +function getMigrationDocs(options: MigrationDocsOptions): string { + const { language, envVarPrefix, framework } = options; + + const apiKeyText = + envVarPrefix === 'VITE_PUBLIC_' + ? 'import.meta.env.VITE_PUBLIC_POSTHOG_KEY' + : envVarPrefix.startsWith('NEXT_PUBLIC_') + ? `process.env.NEXT_PUBLIC_POSTHOG_KEY` + : `process.env.${envVarPrefix}POSTHOG_KEY`; + + const hostText = + envVarPrefix === 'VITE_PUBLIC_' + ? 'import.meta.env.VITE_PUBLIC_POSTHOG_HOST' + : envVarPrefix.startsWith('NEXT_PUBLIC_') + ? `process.env.NEXT_PUBLIC_POSTHOG_HOST` + : `process.env.${envVarPrefix}POSTHOG_HOST`; + + return ` +============================== +AMPLITUDE TO POSTHOG MIGRATION GUIDE +============================== + +This is a migration from Amplitude Analytics to PostHog. You need to: +1. Replace all Amplitude imports with PostHog imports +2. Replace Amplitude initialization with PostHog initialization +3. Replace all Amplitude tracking calls with PostHog equivalents +4. Remove Amplitude packages from the codebase +5. If there is an 'ampli' directory or generated Amplitude SDK wrapper, remove it entirely or replace with PostHog calls + +============================== +IMPORT REPLACEMENTS +============================== + +BEFORE (Amplitude): +- import { init, track, identify, setUserId, Identify, Revenue } from '@amplitude/analytics-browser'; +- import amplitude from 'amplitude-js'; +- import * as amplitude from '@amplitude/analytics-browser'; +- import { ampli } from './ampli'; // Generated Amplitude SDK + +AFTER (PostHog): +- import posthog from 'posthog-js'; +- import { PostHogProvider, usePostHog } from 'posthog-js/react'; // For React + +============================== +AMPLI (GENERATED SDK) MIGRATION +============================== + +If the project uses Ampli (Amplitude's generated type-safe SDK): + +BEFORE (Ampli): +-------------------------------------------------- +import { ampli } from './ampli'; + +ampli.load({ client: { apiKey: 'API_KEY' } }); +ampli.identify(userId, { requiredNumber: 42 }); +ampli.track({ event_type: 'page', event_properties: { category: 'Docs' } }); +ampli.eventNoProperties(); +ampli.eventWithAllProperties({ requiredNumber: 1, requiredString: 'Hi' }); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +import posthog from 'posthog-js'; + +posthog.init(${apiKeyText}, { api_host: ${hostText}, defaults: '2025-05-24' }); +posthog.identify(userId, { requiredNumber: 42 }); +posthog.capture('page', { category: 'Docs' }); +posthog.capture('Event No Properties'); +posthog.capture('Event With All Properties', { requiredNumber: 1, requiredString: 'Hi' }); +-------------------------------------------------- + +NOTE: You can delete the entire 'ampli' folder/directory after migration since PostHog doesn't use generated SDKs. + +============================== +INITIALIZATION REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +import { init } from '@amplitude/analytics-browser'; +init('AMPLITUDE_API_KEY'); +// or +amplitude.getInstance().init('AMPLITUDE_API_KEY'); +// or with ampli +ampli.load({ client: { apiKey: 'AMPLITUDE_API_KEY' } }); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +import posthog from 'posthog-js'; +posthog.init(${apiKeyText}, { + api_host: ${hostText}, + defaults: '2025-05-24', + capture_exceptions: true, +}); +-------------------------------------------------- + +============================== +EVENT TRACKING REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +import { track } from '@amplitude/analytics-browser'; +track('Button Clicked', { buttonName: 'signup' }); +// or +amplitude.getInstance().logEvent('Button Clicked', { buttonName: 'signup' }); +// or with ampli +ampli.track({ event_type: 'Button Clicked', event_properties: { buttonName: 'signup' } }); +ampli.buttonClicked({ buttonName: 'signup' }); // type-safe method +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +import posthog from 'posthog-js'; +posthog.capture('Button Clicked', { buttonName: 'signup' }); +-------------------------------------------------- + +============================== +USER IDENTIFICATION REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +import { identify, setUserId, Identify } from '@amplitude/analytics-browser'; +setUserId('user-123'); +const identifyObj = new Identify(); +identifyObj.set('email', 'user@example.com'); +identify(identifyObj); +// or +amplitude.getInstance().setUserId('user-123'); +amplitude.getInstance().setUserProperties({ email: 'user@example.com' }); +// or with ampli +ampli.identify('user-123', { email: 'user@example.com' }); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +import posthog from 'posthog-js'; +posthog.identify('user-123', { + email: 'user@example.com', +}); +-------------------------------------------------- + +============================== +RESET / LOGOUT REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +import { reset } from '@amplitude/analytics-browser'; +reset(); +// or +amplitude.getInstance().setUserId(null); +amplitude.getInstance().regenerateDeviceId(); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +import posthog from 'posthog-js'; +posthog.reset(); +-------------------------------------------------- + +============================== +GROUP/COMPANY IDENTIFICATION REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +import { setGroup } from '@amplitude/analytics-browser'; +setGroup('company', 'company-123'); +// or +amplitude.getInstance().setGroup('company', 'company-123'); +ampli.client.setGroup('test group', 'browser-ts-ampli'); +ampli.client.groupIdentify('test group', 'browser-ts-ampli', amplitudeIdentify); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +import posthog from 'posthog-js'; +posthog.group('company', 'company-123', { + // optional group properties + name: 'Acme Inc', +}); +-------------------------------------------------- + +============================== +REVENUE TRACKING REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +import { revenue, Revenue } from '@amplitude/analytics-browser'; +const revenueEvent = new Revenue() + .setProductId('product-123') + .setPrice(9.99) + .setQuantity(1); +revenue(revenueEvent); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +import posthog from 'posthog-js'; +posthog.capture('purchase', { + $set: { total_revenue: 9.99 }, + product_id: 'product-123', + price: 9.99, + quantity: 1, +}); +-------------------------------------------------- + +============================== +USER PROPERTIES REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +amplitude.getInstance().setUserProperties({ plan: 'premium' }); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +posthog.capture('$set', { + $set: { plan: 'premium' }, +}); +// Or include in identify call: +posthog.identify(userId, { plan: 'premium' }); +-------------------------------------------------- + +============================== +OPT-OUT REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +amplitude.getInstance().setOptOut(true); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +posthog.opt_out_capturing(); +// To opt back in: +posthog.opt_in_capturing(); +-------------------------------------------------- + +============================== +DEVICE ID REPLACEMENT +============================== + +BEFORE (Amplitude): +-------------------------------------------------- +const deviceId = amplitude.getInstance().getDeviceId(); +-------------------------------------------------- + +AFTER (PostHog): +-------------------------------------------------- +const distinctId = posthog.get_distinct_id(); +-------------------------------------------------- + +============================== +${ + framework === 'react' || framework === 'nextjs' + ? getReactSpecificDocs(apiKeyText, hostText, language) + : '' +} +============================== +IMPORTANT MIGRATION NOTES +============================== + +1. Remove ALL Amplitude packages from package.json after migration: + - @amplitude/analytics-browser + - @amplitude/analytics-node + - @amplitude/analytics-react-native + - amplitude-js + +2. Remove any Amplitude-related environment variables like: + - AMPLITUDE_API_KEY + - REACT_APP_AMPLITUDE_API_KEY + - NEXT_PUBLIC_AMPLITUDE_API_KEY + - VITE_AMPLITUDE_API_KEY + +3. Remove any 'ampli' directory or generated Amplitude SDK files + +4. PostHog uses 'distinct_id' instead of Amplitude's 'user_id' and 'device_id' combination. + +5. PostHog automatically captures page views and clicks by default (autocapture). + If you want to disable this, add autocapture: false to the init options. + +6. PostHog init options should include defaults: '2025-05-24' for the latest recommended settings. + +7. Feature flags in PostHog use isFeatureEnabled() instead of Amplitude's experiments API. +`; +} + +function getReactSpecificDocs( + apiKeyText: string, + hostText: string, + _language: 'typescript' | 'javascript', +): string { + return ` +REACT-SPECIFIC MIGRATION +============================== + +BEFORE (Amplitude with React): +-------------------------------------------------- +// Using AmplitudeProvider or manual init in useEffect +import { init } from '@amplitude/analytics-browser'; +import { ampli } from './ampli'; + +function App() { + useEffect(() => { + init('AMPLITUDE_API_KEY'); + // or + ampli.load({ client: { apiKey: 'AMPLITUDE_API_KEY' } }); + }, []); + + return ; +} +-------------------------------------------------- + +AFTER (PostHog with React): +-------------------------------------------------- +import posthog from 'posthog-js'; +import { PostHogProvider } from 'posthog-js/react'; + +// Initialize PostHog before rendering +posthog.init(${apiKeyText}, { + api_host: ${hostText}, + defaults: '2025-05-24', + capture_exceptions: true, +}); + +function App() { + return ( + + + + ); +} +-------------------------------------------------- + +USING THE POSTHOG HOOK: +-------------------------------------------------- +import { usePostHog } from 'posthog-js/react'; + +function MyComponent() { + const posthog = usePostHog(); + + const handleClick = () => { + posthog.capture('button_clicked', { button: 'signup' }); + }; + + return ; +} +-------------------------------------------------- + +NOTE: Do not directly import posthog apart from the initialization file. +Use the usePostHog hook in components to access the PostHog client. +`; +} + +export const amplitudeProvider: MigrationProviderConfig = { + id: 'amplitude', + name: 'Amplitude', + packages: AMPLITUDE_PACKAGES, + docsUrl: 'https://posthog.com/docs/migrate/migrate-from-amplitude', + getPostHogEquivalent, + getMigrationDocs, + defaultChanges: `• Replaced Amplitude SDK with PostHog SDK +• Migrated event tracking calls from Amplitude to PostHog +• Migrated user identification from Amplitude to PostHog +• Updated initialization code +• Removed Amplitude packages`, + nextSteps: `• Remove any remaining Amplitude environment variables +• Delete the 'ampli' directory if it exists (generated Amplitude SDK) +• Verify all events are being captured correctly in PostHog +• Set up feature flags and experiments in PostHog if previously using Amplitude experiments +• Configure PostHog autocapture settings as needed`, +}; diff --git a/src/migrate/providers/index.ts b/src/migrate/providers/index.ts new file mode 100644 index 0000000..a1fbb1c --- /dev/null +++ b/src/migrate/providers/index.ts @@ -0,0 +1,18 @@ +import type { MigrationProviderConfig } from '../types'; +import { amplitudeProvider } from './amplitude'; + +export const migrationProviders: Record = { + amplitude: amplitudeProvider, +}; + +export function getMigrationProvider( + id: string, +): MigrationProviderConfig | undefined { + return migrationProviders[id]; +} + +export function getAvailableMigrationProviders(): string[] { + return Object.keys(migrationProviders); +} + +export { amplitudeProvider } from './amplitude'; diff --git a/src/migrate/types.ts b/src/migrate/types.ts new file mode 100644 index 0000000..c42e74c --- /dev/null +++ b/src/migrate/types.ts @@ -0,0 +1,55 @@ +import type { CloudRegion, WizardOptions } from '../utils/types'; +import type { PackageDotJson } from '../utils/package-json'; +import type { PackageManager } from '../utils/package-manager'; + +export interface InstalledPackage { + packageName: string; + version: string; +} + +export interface MigrationProviderConfig { + id: string; + name: string; + packages: readonly string[]; + docsUrl: string; + getPostHogEquivalent: (sourcePackage: string) => string | undefined; + getMigrationDocs: (options: MigrationDocsOptions) => string; + defaultChanges: string; + nextSteps: string; +} + +export interface MigrationDocsOptions { + language: 'typescript' | 'javascript'; + envVarPrefix: string; + framework: 'react' | 'nextjs' | 'svelte' | 'astro' | 'react-native' | 'node'; +} + +export interface MigrationOptions extends WizardOptions { + targetIntegration?: string; +} + +export interface MigrationContext { + options: MigrationOptions; + cloudRegion: CloudRegion; + packageJson: PackageDotJson; + provider: MigrationProviderConfig; + installedPackages: InstalledPackage[]; + framework: 'react' | 'nextjs' | 'svelte' | 'astro' | 'react-native' | 'node'; + envVarPrefix: string; + typeScriptDetected: boolean; + accessToken: string; + projectApiKey: string; + host: string; + projectId: number; +} + +export interface MigrationOutroOptions { + options: WizardOptions; + cloudRegion: CloudRegion; + provider: MigrationProviderConfig; + addedEditorRules: boolean; + packageManager: PackageManager; + envFileChanged?: string; + uploadedEnvVars: string[]; + migratedFilesCount: number; +} diff --git a/src/react/docs.ts b/src/react/docs.ts index 2abf1c2..8bd8bd4 100644 --- a/src/react/docs.ts +++ b/src/react/docs.ts @@ -23,8 +23,10 @@ FILE: {index / App}.${ LOCATION: Wherever the root of the app is ============================== Changes: -- Add the PostHogProvider to the root of the app in the provider tree. +- Initialize PostHog with posthog.init() before rendering. +- Add the PostHogProvider to the root of the app in the provider tree, passing the initialized client. - Make sure to include the defaults: '2025-05-24' option in the init call. +- Do not directly import posthog apart from initialization. Use the usePostHog hook in components instead. Example: -------------------------------------------------- @@ -32,27 +34,28 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; -import { PostHogProvider} from 'posthog-js/react' +import posthog from 'posthog-js'; +import { PostHogProvider } from 'posthog-js/react'; + +posthog.init(${apiKeyText}, { + api_host: ${hostText}, + defaults: '2025-05-24', + capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this + debug: ${ + envVarPrefix === 'VITE_PUBLIC_' + ? 'import.meta.env.MODE === "development"' + : 'process.env.NODE_ENV === "development"' + }, +}); const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - + +); --------------------------------------------------`; }; diff --git a/src/run.ts b/src/run.ts index 7716692..df021cb 100644 --- a/src/run.ts +++ b/src/run.ts @@ -24,6 +24,12 @@ import chalk from 'chalk'; import { RateLimitError } from './utils/errors'; import { getPackageVersion } from './utils/package-json'; import * as semver from 'semver'; +import { + checkAndOfferMigration, + runMigrationWizard, + migrationProviders, + detectProviderInstallation, +} from './migrate'; EventEmitter.defaultMaxListeners = 50; @@ -67,6 +73,13 @@ export async function runWizard(argv: Args) { clack.intro(`Welcome to the PostHog setup wizard ✨`); + const migrationProvider = await detectAndOfferMigration(wizardOptions); + + if (migrationProvider) { + await runMigrationWizard(wizardOptions, migrationProvider); + return; + } + const integration = finalArgs.integration ?? (await getIntegrationForSetup(wizardOptions)); @@ -158,6 +171,28 @@ async function getIntegrationForSetup( return integration; } +async function detectAndOfferMigration( + options: WizardOptions, +): Promise { + const packageJson = await getPackageDotJson(options); + + for (const [providerId, provider] of Object.entries(migrationProviders)) { + const installation = detectProviderInstallation(packageJson, provider); + + if (installation) { + const shouldMigrate = await checkAndOfferMigration(options, providerId); + + if (shouldMigrate) { + return providerId; + } + + return undefined; + } + } + + return undefined; +} + async function chooseNextjsWizard(options: WizardOptions): Promise { try { const packageJson = await getPackageDotJson(options);