diff --git a/src/frameworks/java/java-wizard-agent.ts b/src/frameworks/java/java-wizard-agent.ts new file mode 100644 index 00000000..9223a052 --- /dev/null +++ b/src/frameworks/java/java-wizard-agent.ts @@ -0,0 +1,153 @@ +/* Java (server) wizard using posthog-agent with PostHog MCP */ +import fg from 'fast-glob'; +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 { detectJavaPackageManagers } from '@lib/detection/package-manager'; +import { Integration } from '@lib/constants'; + +type JavaContext = { + buildTool?: 'maven' | 'gradle'; +}; + +const GRADLE_FILES = [ + 'build.gradle', + 'build.gradle.kts', + 'settings.gradle', + 'settings.gradle.kts', +]; + +const ANDROID_GRADLE_MARKERS = [ + 'com.android.application', + 'com.android.library', + 'com.android.tools.build:gradle', +]; + +const KMP_GRADLE_MARKERS = [ + 'kotlin("multiplatform")', + 'org.jetbrains.kotlin.multiplatform', + 'kotlin-multiplatform', +]; + +function readRootGradleFiles(installDir: string): string[] { + return GRADLE_FILES.map((name) => path.join(installDir, name)) + .filter((p) => fs.existsSync(p)) + .map((p) => fs.readFileSync(p, 'utf-8')); +} + +function getBuildTool(installDir: string): 'maven' | 'gradle' | undefined { + if (fs.existsSync(path.join(installDir, 'pom.xml'))) { + return 'maven'; + } + if (readRootGradleFiles(installDir).length > 0) { + return 'gradle'; + } + return undefined; +} + +export const JAVA_AGENT_CONFIG: FrameworkConfig = { + metadata: { + name: 'Java', + integration: Integration.java, + docsUrl: 'https://posthog.com/docs/libraries/java', + gatherContext: (options: WizardRunOptions) => { + const buildTool = getBuildTool(options.installDir); + return Promise.resolve({ buildTool }); + }, + }, + + detection: { + packageName: 'posthog-server', + packageDisplayName: 'Java', + usesPackageJson: false, + getVersion: () => undefined, + // Maven projects (pom.xml) are unambiguously JVM backends. Gradle + // projects are only claimed when they are neither Android nor KMP — + // those detectors are ordered earlier, and this re-check keeps a + // direct java detect() call from ever claiming their projects. + detect: async (options) => { + const { installDir } = options; + + // pubspec.yaml means Flutter — its gradle subtree is not a JVM backend. + if (fs.existsSync(path.join(installDir, 'pubspec.yaml'))) { + return false; + } + + if (fs.existsSync(path.join(installDir, 'pom.xml'))) { + return true; + } + + const gradleContents = readRootGradleFiles(installDir); + if (gradleContents.length === 0) { + return false; + } + const combined = gradleContents.join('\n'); + if ( + [...ANDROID_GRADLE_MARKERS, ...KMP_GRADLE_MARKERS].some((marker) => + combined.includes(marker), + ) + ) { + return false; + } + + const manifestFiles = await fg('**/AndroidManifest.xml', { + cwd: installDir, + ignore: ['**/build/**', '**/node_modules/**', '**/.gradle/**'], + }); + return manifestFiles.length === 0; + }, + detectPackageManager: detectJavaPackageManagers, + }, + + environment: { + uploadToHosting: false, + getEnvVars: (apiKey: string, host: string) => ({ + POSTHOG_API_KEY: apiKey, + POSTHOG_HOST: host, + }), + }, + + analytics: { + getTags: (context) => ({ + buildTool: context.buildTool || 'unknown', + }), + }, + + prompts: { + projectTypeDetection: + 'This is a Java server project. Look for pom.xml (Maven) or build.gradle/build.gradle.kts (Gradle), src/main/java/, and the framework in use (Spring Boot, Quarkus, Micronaut, plain servlets) to confirm.', + packageInstallation: + 'Neither Maven nor Gradle has a single add command. Add the com.posthog:posthog-server dependency (pinned to the latest release on Maven Central) to pom.xml or build.gradle(.kts), matching the existing dependency format, then run `mvn install` or `gradle build` to resolve it.', + getAdditionalContextLines: (context) => { + const lines = [ + `Framework docs ID: java (use posthog://docs/frameworks/java for documentation)`, + 'Use the posthog-server SDK (com.posthog:posthog-server) — posthog-android is a different library and must not be used on the server.', + 'Create one PostHog client per process with PostHogConfig.builder(...) and PostHog.with(config); call close() during graceful shutdown, and flush() before returning in serverless handlers.', + ]; + if (context.buildTool) { + lines.push(`Build tool: ${context.buildTool}`); + } + return lines; + }, + }, + + ui: { + successMessage: 'PostHog integration complete', + estimatedDurationMinutes: 5, + getOutroChanges: (context) => [ + 'Analyzed your Java project structure', + `Added the posthog-server dependency to your ${ + context.buildTool === 'maven' ? 'pom.xml' : 'Gradle build' + }`, + 'Initialized a shared PostHog client configured from environment variables', + 'Instrumented meaningful server events with posthog.capture', + ], + getOutroNextSteps: () => [ + 'Run your Java service and trigger the instrumented code paths', + 'Visit your PostHog dashboard to see incoming events', + 'Use posthog.capture(distinctId, eventName, options) to track custom events', + 'Keep posthog.close() in your graceful shutdown path so queued events flush', + ], + }, +}; 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 f878a468..f8b1ecc4 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 @@ -50,6 +50,7 @@ const INTEGRATION_ENTRIES = [ { id: 'integration-ruby', framework: 'ruby' }, { id: 'integration-elixir' }, { id: 'integration-go' }, + { id: 'integration-java', framework: 'java' }, { id: 'integration-swift', framework: 'swift' }, { id: 'integration-kmp', framework: 'kmp' }, { id: 'integration-flutter' }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 78353cb6..09050f2b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -100,6 +100,8 @@ export enum Integration { swift = 'swift', android = 'android', rails = 'rails', + // Must stay after kmp/swift/android: those claim gradle projects first. + java = 'java', // 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 075c46a2..22707a77 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 { JAVA_AGENT_CONFIG } from '../../../frameworks/java/java-wizard-agent'; /** A throwaway project dir seeded with the given files. */ function makeProject(files: Record): string { @@ -54,6 +55,18 @@ describe('Integration enum order (drives first-match detection)', () => { expect(order[order.length - 1]).toBe(Integration.javascriptNode); }); + test('java is ordered after kmp, swift, and android (they claim gradle projects first)', () => { + for (const specific of [ + Integration.kmp, + Integration.swift, + Integration.android, + ]) { + expect(order.indexOf(Integration.java)).toBeGreaterThan( + order.indexOf(specific), + ); + } + }); + test('kmp is ordered before android and swift (more specific detection wins)', () => { // A KMP project also looks like an Android/Swift project, so KMP must be // checked first for first-match detection to resolve it correctly. @@ -137,6 +150,27 @@ describe('detectFramework (end-to-end over real project dirs)', () => { ); }); + test('a Maven project resolves to java', async () => { + const opts = project({ + 'pom.xml': + 'com.exampleapp', + 'src/main/java/App.java': 'class App {}', + }); + await expect(detectFramework(opts.installDir)).resolves.toBe( + Integration.java, + ); + }); + + test('a plain Gradle JVM project resolves to java, not android', async () => { + const opts = project({ + 'build.gradle': `plugins { id 'java' id 'org.springframework.boot' }`, + 'src/main/java/App.java': 'class App {}', + }); + await expect(detectFramework(opts.installDir)).resolves.toBe( + Integration.java, + ); + }); + 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 +203,58 @@ describe('android detect', () => { }); }); +describe('java detect', () => { + const detect = JAVA_AGENT_CONFIG.detection.detect; + + test('claims a Maven project', async () => { + const opts = project({ 'pom.xml': '' }); + await expect(detect(opts)).resolves.toBe(true); + }); + + test('claims a plain Gradle JVM project', async () => { + const opts = project({ + 'build.gradle.kts': `plugins { java }\ndependencies {}`, + }); + await expect(detect(opts)).resolves.toBe(true); + }); + + test('does not claim an Android gradle project', async () => { + const opts = project({ + 'build.gradle': `plugins { id 'com.android.application' }`, + }); + await expect(detect(opts)).resolves.toBe(false); + }); + + test('does not claim a gradle project with an AndroidManifest.xml subtree', async () => { + const opts = project({ + 'build.gradle': `plugins { id 'java' }`, + 'app/src/main/AndroidManifest.xml': '', + }); + await expect(detect(opts)).resolves.toBe(false); + }); + + test('does not claim a KMP project', async () => { + const opts = project({ + 'build.gradle.kts': `plugins { kotlin("multiplatform") }`, + }); + await expect(detect(opts)).resolves.toBe(false); + }); + + test('does not claim a Flutter project', async () => { + const opts = project({ + 'pubspec.yaml': 'name: my_flutter_app\n', + 'android/build.gradle': `plugins { id 'com.android.application' }`, + 'settings.gradle': 'include ":app"', + }); + await expect(detect(opts)).resolves.toBe(false); + }); + + test('does not claim a project with no build files', async () => { + const opts = project({ 'src/main/java/App.java': 'class App {}' }); + 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 27b468ad..2c30b806 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, + detectJavaPackageManagers, } from '@lib/detection/package-manager'; vi.mock('../../../utils/debug'); @@ -179,6 +180,33 @@ describe('detectPythonPackageManagers', () => { }); }); +// --------------------------------------------------------------------------- +// Java detection +// --------------------------------------------------------------------------- + +describe('detectJavaPackageManagers', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => cleanup(tmpDir)); + + it('detects maven via pom.xml', async () => { + fs.writeFileSync(path.join(tmpDir, 'pom.xml'), ''); + const result = await detectJavaPackageManagers(tmpDir); + expect(result.primary?.name).toBe('maven'); + expect(result.primary?.installCommand).toBe('mvn install'); + expect(result.recommendation).toContain('pom.xml'); + }); + + it('falls back to gradle without a pom.xml', async () => { + fs.writeFileSync(path.join(tmpDir, 'build.gradle'), ''); + const result = await detectJavaPackageManagers(tmpDir); + expect(result.primary?.name).toBe('gradle'); + }); +}); + // --------------------------------------------------------------------------- // Static helpers // --------------------------------------------------------------------------- diff --git a/src/lib/detection/agentic.ts b/src/lib/detection/agentic.ts index c808bf09..0b82021d 100644 --- a/src/lib/detection/agentic.ts +++ b/src/lib/detection/agentic.ts @@ -74,12 +74,13 @@ export const PROJECT_MANIFESTS: readonly string[] = [ // Ruby / PHP 'Gemfile', 'composer.json', - // Rust / Go / Elixir / JVM / .NET: no framework targets yet, but found so + // Java + 'pom.xml', + // Rust / Go / Elixir / .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 'Package.swift', diff --git a/src/lib/detection/package-manager.ts b/src/lib/detection/package-manager.ts index add3cc49..39f73837 100644 --- a/src/lib/detection/package-manager.ts +++ b/src/lib/detection/package-manager.ts @@ -7,6 +7,8 @@ * current framework supplies. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { detectAllPackageManagers, type PackageManager, @@ -233,3 +235,31 @@ export function gradlePackageManager(): Promise { 'Add dependencies to build.gradle(.kts) using implementation().', }); } + +// --------------------------------------------------------------------------- +// Java (Maven or Gradle) helper +// --------------------------------------------------------------------------- + +const MAVEN: DetectedPackageManager = { + name: 'maven', + label: 'Maven', + installCommand: 'mvn install', +}; + +/** + * Java backends split between Maven and Gradle; pom.xml decides. + * Defaults to Gradle when neither manifest is present. + */ +export function detectJavaPackageManagers( + installDir: string, +): Promise { + if (fs.existsSync(path.join(installDir, 'pom.xml'))) { + return Promise.resolve({ + detected: [MAVEN], + primary: MAVEN, + recommendation: + 'Use Maven. Add the dependency to pom.xml, then run mvn install to resolve it.', + }); + } + return gradlePackageManager(); +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 2dfba061..a8b82978 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 { JAVA_AGENT_CONFIG } from '@frameworks/java/java-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.java]: JAVA_AGENT_CONFIG, [Integration.python]: PYTHON_AGENT_CONFIG, [Integration.ruby]: RUBY_AGENT_CONFIG, [Integration.javascriptNode]: JAVASCRIPT_NODE_AGENT_CONFIG,