diff --git a/src/frameworks/elixir/elixir-wizard-agent.ts b/src/frameworks/elixir/elixir-wizard-agent.ts new file mode 100644 index 000000000..26eb007e1 --- /dev/null +++ b/src/frameworks/elixir/elixir-wizard-agent.ts @@ -0,0 +1,105 @@ +/* Elixir wizard using posthog-agent with PostHog MCP */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { WizardRunOptions } from '@utils/types'; +import type { FrameworkConfig } from '@lib/framework-config'; +import { mixPackageManager } from '@lib/detection/package-manager'; +import { Integration } from '@lib/constants'; + +type ElixirContext = { + phoenix?: boolean; +}; + +function readMixExs(installDir: string): string | undefined { + const mixExsPath = path.join(installDir, 'mix.exs'); + if (!fs.existsSync(mixExsPath)) { + return undefined; + } + return fs.readFileSync(mixExsPath, 'utf-8'); +} + +/** Phoenix apps get the Plug integration; plain Elixir apps don't. */ +function isPhoenixProject(installDir: string): boolean { + const mixExs = readMixExs(installDir); + return !!mixExs && /\{\s*:phoenix\s*,/.test(mixExs); +} + +export const ELIXIR_AGENT_CONFIG: FrameworkConfig = { + metadata: { + name: 'Elixir', + integration: Integration.elixir, + docsUrl: 'https://posthog.com/docs/libraries/elixir', + gatherContext: (options: WizardRunOptions) => { + const phoenix = isPhoenixProject(options.installDir); + return Promise.resolve({ phoenix }); + }, + }, + + detection: { + packageName: 'posthog', + packageDisplayName: 'Elixir', + usesPackageJson: false, + getVersion: () => undefined, + // A mix.exs defining a project marks a Mix project root; the def project + // check keeps stray files named mix.exs from claiming the directory. + detect: (options) => { + const mixExs = readMixExs(options.installDir); + return Promise.resolve(!!mixExs && /def\s+project\b/.test(mixExs)); + }, + detectPackageManager: mixPackageManager, + }, + + environment: { + uploadToHosting: false, + getEnvVars: (apiKey: string, host: string) => ({ + POSTHOG_API_KEY: apiKey, + POSTHOG_HOST: host, + }), + }, + + analytics: { + getTags: (context) => ({ + projectType: context.phoenix ? 'phoenix' : 'elixir', + }), + }, + + prompts: { + projectTypeDetection: + 'This is an Elixir project. Look for mix.exs, mix.lock, config/, lib/, and application.ex to confirm; Phoenix apps also have endpoint and router modules.', + packageInstallation: + 'Mix has no single add command. Add `{:posthog, "~> 2.0"}` to the deps list in mix.exs, then run `mix deps.get` to fetch it.', + getAdditionalContextLines: (context) => { + const lines = [ + `Framework docs ID: elixir (use posthog://docs/frameworks/elixir for documentation)`, + 'Configure PostHog in application config (api_key, api_host, in_app_otp_apps) reading from environment variables; set test_mode: true in the test environment.', + ]; + if (context.phoenix) { + lines.push( + 'This is a Phoenix app — add PostHog.Integrations.Plug before the router so request context is attached to captured events.', + ); + } + return lines; + }, + }, + + ui: { + successMessage: 'PostHog integration complete', + estimatedDurationMinutes: 5, + getOutroChanges: (context) => [ + `Analyzed your ${ + context.phoenix ? 'Phoenix' : 'Elixir' + } project structure`, + 'Added the posthog Hex package to mix.exs and fetched it with mix deps.get', + 'Configured PostHog in application config from environment variables', + 'Instrumented meaningful events with PostHog.capture', + ], + getOutroNextSteps: (context) => [ + context.phoenix + ? 'Start your Phoenix server with `mix phx.server` and trigger the instrumented code paths' + : 'Run your Elixir application and trigger the instrumented code paths', + 'Visit your PostHog dashboard to see incoming events', + 'Use PostHog.capture/2 to track custom events', + 'Use PostHog.set_context/1 to set a distinct_id once per request or job process', + ], + }, +}; diff --git a/src/lib/__tests__/wizard-can-use-tool.test.ts b/src/lib/__tests__/wizard-can-use-tool.test.ts index 67afbeccc..ea50eee3e 100644 --- a/src/lib/__tests__/wizard-can-use-tool.test.ts +++ b/src/lib/__tests__/wizard-can-use-tool.test.ts @@ -139,6 +139,21 @@ describe('bash fence — allows real toolchain commands (from skills + field log expect(allow('carthage bootstrap')).toBe('allow'); }); + it('elixir ecosystem', () => { + expect(allow('mix deps.get')).toBe('allow'); + expect(allow('mix deps.update posthog')).toBe('allow'); + expect(allow('mix deps.tree')).toBe('allow'); + expect(allow('mix compile')).toBe('allow'); + expect(allow('mix format')).toBe('allow'); + expect(allow('mix hex.info posthog')).toBe('allow'); + // mix runs arbitrary project-defined tasks — everything else stays denied. + expect(allow('mix run priv/repo/seeds.exs')).toBe('deny'); + expect(allow('mix test')).toBe('deny'); + expect(allow('mix phx.server')).toBe('deny'); + expect(allow('mix ecto.migrate')).toBe('deny'); + expect(allow('mix do deps.get, run evil.exs')).toBe('deny'); + }); + it('android/jvm ecosystem', () => { expect(allow('./gradlew assembleDebug')).toBe('allow'); expect(allow('./gradlew :app:assembleDebug')).toBe('allow'); diff --git a/src/lib/agent/bash-fence.ts b/src/lib/agent/bash-fence.ts index 9fe251eea..f09ba9f62 100644 --- a/src/lib/agent/bash-fence.ts +++ b/src/lib/agent/bash-fence.ts @@ -84,6 +84,18 @@ const SIMPLE_MANAGERS: Record = { swift: ['package', 'build'], pod: ['install', 'update', 'search'], carthage: ['bootstrap', 'update'], + // mix runs arbitrary project-defined tasks, so only the dependency + build + // tasks are listed. compile also executes code (compile-time macros), accepted + // under the "builds are equivalent risk" model in the file header; run/test/ + // phx.server are denied so app code, tests, and servers aren't run. + mix: [ + 'deps.get', + 'deps.update', + 'deps.tree', + 'compile', + 'format', + 'hex.info', + ], }; // Gradle tasks are verb-anchored camelCase: assembleDebug yes, publishToMavenCentral no. @@ -102,7 +114,8 @@ const ALLOWED_TOOLS_SUMMARY = 'composer (install|require|update|remove|show), bundle (install|add|remove|update|show|exec ), ' + 'gem (install|uninstall|list|search), swift (package|build), pod (install|update|search), carthage (bootstrap|update), ' + 'xcodebuild (build/clean/archive actions), gradle/gradlew (build|clean|dependencies|assemble*/compile*/bundle*/lint* tasks), ' + - 'mvn (install|compile|package|verify|dependency:tree).'; + 'mvn (install|compile|package|verify|dependency:tree), ' + + 'mix (deps.get|deps.update|deps.tree|compile|format|hex.info).'; function deny(analyticsReason: string, message: string): BashFenceDecision { return { allowed: false, message, analyticsReason }; diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts index f878a468a..c5e93a923 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts @@ -48,7 +48,7 @@ const INTEGRATION_ENTRIES = [ { id: 'integration-javascript_node', framework: 'javascript_node' }, { id: 'integration-javascript_web', framework: 'javascript_web' }, { id: 'integration-ruby', framework: 'ruby' }, - { id: 'integration-elixir' }, + { id: 'integration-elixir', framework: 'elixir' }, { id: 'integration-go' }, { id: 'integration-swift', framework: 'swift' }, { id: 'integration-kmp', framework: 'kmp' }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 78353cb61..1cedf476d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -100,6 +100,7 @@ export enum Integration { swift = 'swift', android = 'android', rails = 'rails', + elixir = 'elixir', // Language fallbacks. Keep javascriptNode last: it matches any package.json. python = 'python', diff --git a/src/lib/detection/__tests__/framework.test.ts b/src/lib/detection/__tests__/framework.test.ts index 075c46a29..b037e02b5 100644 --- a/src/lib/detection/__tests__/framework.test.ts +++ b/src/lib/detection/__tests__/framework.test.ts @@ -5,6 +5,7 @@ import { detectFramework } from '@lib/detection/framework'; import { Integration } from '@lib/constants'; import { ANDROID_AGENT_CONFIG } from '../../../frameworks/android/android-wizard-agent'; import { KMP_AGENT_CONFIG } from '../../../frameworks/kmp/kmp-wizard-agent'; +import { ELIXIR_AGENT_CONFIG } from '../../../frameworks/elixir/elixir-wizard-agent'; /** A throwaway project dir seeded with the given files. */ function makeProject(files: Record): string { @@ -137,6 +138,17 @@ describe('detectFramework (end-to-end over real project dirs)', () => { ); }); + test('a Phoenix project resolves to elixir', async () => { + const opts = project({ + 'mix.exs': + 'defmodule MyApp.MixProject do\n use Mix.Project\n\n def project do\n [app: :my_app, deps: deps()]\n end\n\n defp deps do\n [{:phoenix, "~> 1.7"}]\n end\nend\n', + 'config/config.exs': 'import Config', + }); + await expect(detectFramework(opts.installDir)).resolves.toBe( + Integration.elixir, + ); + }); + test('a Flutter project is not claimed by anything', async () => { const opts = project({ 'pubspec.yaml': 'name: my_flutter_app\nenvironment:\n sdk: ^3.0.0\n', @@ -169,6 +181,28 @@ describe('android detect', () => { }); }); +describe('elixir detect', () => { + const detect = ELIXIR_AGENT_CONFIG.detection.detect; + + const mixExs = + 'defmodule MyApp.MixProject do\n use Mix.Project\n\n def project do\n [app: :my_app]\n end\nend\n'; + + test('claims a Mix project', async () => { + const opts = project({ 'mix.exs': mixExs }); + await expect(detect(opts)).resolves.toBe(true); + }); + + test('does not claim a mix.exs without a project definition', async () => { + const opts = project({ 'mix.exs': '# placeholder\n' }); + await expect(detect(opts)).resolves.toBe(false); + }); + + test('does not claim a project without a mix.exs', async () => { + const opts = project({ 'lib/my_app.ex': 'defmodule MyApp do\nend\n' }); + await expect(detect(opts)).resolves.toBe(false); + }); +}); + describe('kmp detect', () => { const detect = KMP_AGENT_CONFIG.detection.detect; diff --git a/src/lib/detection/__tests__/package-manager.test.ts b/src/lib/detection/__tests__/package-manager.test.ts index 27b468ad2..a19f0360c 100644 --- a/src/lib/detection/__tests__/package-manager.test.ts +++ b/src/lib/detection/__tests__/package-manager.test.ts @@ -7,6 +7,7 @@ import { composerPackageManager, swiftPackageManager, gradlePackageManager, + mixPackageManager, } from '@lib/detection/package-manager'; vi.mock('../../../utils/debug'); @@ -188,6 +189,7 @@ describe('static package manager helpers', () => { { fn: composerPackageManager, name: 'composer' }, { fn: swiftPackageManager, name: 'spm' }, { fn: gradlePackageManager, name: 'gradle' }, + { fn: mixPackageManager, name: 'mix' }, ])('$name returns valid PackageManagerInfo', async ({ fn }) => { const result = await fn(); expect(result.detected).toHaveLength(1); diff --git a/src/lib/detection/agentic.ts b/src/lib/detection/agentic.ts index c808bf092..ce1b7d5b8 100644 --- a/src/lib/detection/agentic.ts +++ b/src/lib/detection/agentic.ts @@ -74,11 +74,12 @@ export const PROJECT_MANIFESTS: readonly string[] = [ // Ruby / PHP 'Gemfile', 'composer.json', - // Rust / Go / Elixir / JVM / .NET: no framework targets yet, but found so + // Elixir + 'mix.exs', + // Rust / Go / JVM / .NET: no framework targets yet, but found so // an existing PostHog SDK is reported (feeds self-driving's "continue" path). 'Cargo.toml', 'go.mod', - 'mix.exs', 'pom.xml', '*.csproj', // Mobile / native diff --git a/src/lib/detection/package-manager.ts b/src/lib/detection/package-manager.ts index add3cc49d..4b33eb350 100644 --- a/src/lib/detection/package-manager.ts +++ b/src/lib/detection/package-manager.ts @@ -215,6 +215,25 @@ export function bundlerPackageManager(): Promise { }); } +// --------------------------------------------------------------------------- +// Elixir (Mix) helper +// --------------------------------------------------------------------------- + +const MIX: DetectedPackageManager = { + name: 'mix', + label: 'Mix', + installCommand: 'mix deps.get', +}; + +export function mixPackageManager(): Promise { + return Promise.resolve({ + detected: [MIX], + primary: MIX, + recommendation: + 'Use Mix. Add the dependency to the deps list in mix.exs, then run mix deps.get.', + }); +} + // --------------------------------------------------------------------------- // Android (Gradle) helper // --------------------------------------------------------------------------- diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 2dfba061c..537279d56 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -18,6 +18,7 @@ import { SWIFT_AGENT_CONFIG } from '@frameworks/swift/swift-wizard-agent'; import { KMP_AGENT_CONFIG } from '@frameworks/kmp/kmp-wizard-agent'; import { ANDROID_AGENT_CONFIG } from '@frameworks/android/android-wizard-agent'; import { RAILS_AGENT_CONFIG } from '@frameworks/rails/rails-wizard-agent'; +import { ELIXIR_AGENT_CONFIG } from '@frameworks/elixir/elixir-wizard-agent'; import { PYTHON_AGENT_CONFIG } from '@frameworks/python/python-wizard-agent'; import { RUBY_AGENT_CONFIG } from '@frameworks/ruby/ruby-wizard-agent'; import { JAVASCRIPT_NODE_AGENT_CONFIG } from '@frameworks/javascript-node/javascript-node-wizard-agent'; @@ -42,6 +43,7 @@ export const FRAMEWORK_REGISTRY: Record = { [Integration.swift]: SWIFT_AGENT_CONFIG, [Integration.android]: ANDROID_AGENT_CONFIG, [Integration.rails]: RAILS_AGENT_CONFIG, + [Integration.elixir]: ELIXIR_AGENT_CONFIG, [Integration.python]: PYTHON_AGENT_CONFIG, [Integration.ruby]: RUBY_AGENT_CONFIG, [Integration.javascriptNode]: JAVASCRIPT_NODE_AGENT_CONFIG,