diff --git a/.gitignore b/.gitignore index d1873128..bfe216e8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,7 @@ plugins e2e-tests/fixtures/.tracking/* +CLAUDE.local.md + # Generated at build time by scripts/generate-version.js -src/lib/version.ts \ No newline at end of file +src/lib/version.ts diff --git a/src/android/android-wizard-agent.ts b/src/android/android-wizard-agent.ts index 9aecde36..2c08a1ac 100644 --- a/src/android/android-wizard-agent.ts +++ b/src/android/android-wizard-agent.ts @@ -96,8 +96,6 @@ export const ANDROID_AGENT_CONFIG: FrameworkConfig = { prompts: { projectTypeDetection: 'This is an Android/Kotlin project. Look for build.gradle or build.gradle.kts files, AndroidManifest.xml, and Kotlin source files (.kt) to confirm.', - packageInstallation: - 'Add the PostHog Android SDK dependency to the app-level build.gradle(.kts) file. Use implementation("com.posthog:posthog-android:"). Check the existing dependency format (Groovy vs Kotlin DSL) and match it.', getAdditionalContextLines: (context) => { const lines = [ `Framework docs ID: android (use posthog://docs/frameworks/android for documentation)`, diff --git a/src/angular/angular-wizard-agent.ts b/src/angular/angular-wizard-agent.ts index d87c8fa9..927c348f 100644 --- a/src/angular/angular-wizard-agent.ts +++ b/src/angular/angular-wizard-agent.ts @@ -60,8 +60,6 @@ export const ANGULAR_AGENT_CONFIG: FrameworkConfig = { return [ `Framework docs ID: ${frameworkId} (use posthog://docs/frameworks/${frameworkId} for documentation)`, - 'Angular uses dependency injection for services. PostHog should be initialized as a service.', - 'For standalone components, ensure PostHog is properly provided in the application config.', ]; }, }, diff --git a/src/django/django-wizard-agent.ts b/src/django/django-wizard-agent.ts index 9232d0ec..3707719b 100644 --- a/src/django/django-wizard-agent.ts +++ b/src/django/django-wizard-agent.ts @@ -7,6 +7,7 @@ import { Integration } from '../lib/constants'; import fg from 'fast-glob'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { PYTHON_DETECTION_IGNORES } from '../lib/glob-patterns'; import { getDjangoVersion, getDjangoProjectType, @@ -48,7 +49,7 @@ export const DJANGO_AGENT_CONFIG: FrameworkConfig = { const managePyMatches = await fg('**/manage.py', { cwd: installDir, - ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'], + ignore: PYTHON_DETECTION_IGNORES, }); if (managePyMatches.length > 0) { @@ -77,7 +78,7 @@ export const DJANGO_AGENT_CONFIG: FrameworkConfig = { ['**/requirements*.txt', '**/pyproject.toml', '**/setup.py'], { cwd: installDir, - ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'], + ignore: PYTHON_DETECTION_IGNORES, }, ); diff --git a/src/fastapi/fastapi-wizard-agent.ts b/src/fastapi/fastapi-wizard-agent.ts index 2c5af0bb..3245bb37 100644 --- a/src/fastapi/fastapi-wizard-agent.ts +++ b/src/fastapi/fastapi-wizard-agent.ts @@ -3,12 +3,7 @@ import type { WizardOptions } from '../utils/types'; import type { FrameworkConfig } from '../lib/framework-config'; import { PYTHON_PACKAGE_INSTALLATION } from '../lib/framework-config'; import { detectPythonPackageManagers } from '../lib/package-manager-detection'; -import { enableDebugLogs } from '../utils/debug'; -import { runAgentWizard } from '../lib/agent-runner'; import { Integration } from '../lib/constants'; -import clack from '../utils/clack'; -import chalk from 'chalk'; -import * as semver from 'semver'; import { getFastAPIVersion, getFastAPIProjectType, @@ -20,12 +15,14 @@ import { import fg from 'fast-glob'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { + PYTHON_DETECTION_IGNORES, + PYTHON_SOURCE_IGNORES, +} from '../lib/glob-patterns'; /** * FastAPI framework configuration for the universal agent runner */ -const MINIMUM_FASTAPI_VERSION = '0.100.0'; - export const FASTAPI_AGENT_CONFIG: FrameworkConfig = { metadata: { name: 'FastAPI', @@ -49,6 +46,7 @@ export const FASTAPI_AGENT_CONFIG: FrameworkConfig = { return undefined; }, getVersionBucket: getFastAPIVersionBucket, + minimumVersion: '0.100.0', getInstalledVersion: getFastAPIVersion, detect: async (options) => { const { installDir } = options; @@ -66,7 +64,7 @@ export const FASTAPI_AGENT_CONFIG: FrameworkConfig = { ], { cwd: installDir, - ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'], + ignore: PYTHON_DETECTION_IGNORES, }, ); @@ -94,13 +92,7 @@ export const FASTAPI_AGENT_CONFIG: FrameworkConfig = { ['**/main.py', '**/app.py', '**/application.py', '**/__init__.py'], { cwd: installDir, - ignore: [ - '**/venv/**', - '**/.venv/**', - '**/env/**', - '**/.env/**', - '**/__pycache__/**', - ], + ignore: PYTHON_SOURCE_IGNORES, }, ); @@ -198,35 +190,3 @@ export const FASTAPI_AGENT_CONFIG: FrameworkConfig = { ], }, }; - -/** - * FastAPI wizard powered by the universal agent runner. - */ -export async function runFastAPIWizardAgent( - options: WizardOptions, -): Promise { - if (options.debug) { - enableDebugLogs(); - } - - // Check FastAPI version - agent wizard requires >= 0.100.0 - const fastapiVersion = await getFastAPIVersion(options); - - if (fastapiVersion) { - const coercedVersion = semver.coerce(fastapiVersion); - if (coercedVersion && semver.lt(coercedVersion, MINIMUM_FASTAPI_VERSION)) { - const docsUrl = - FASTAPI_AGENT_CONFIG.metadata.unsupportedVersionDocsUrl ?? - FASTAPI_AGENT_CONFIG.metadata.docsUrl; - - clack.log.warn( - `Sorry: the wizard can't help you with FastAPI ${fastapiVersion}. Upgrade to FastAPI ${MINIMUM_FASTAPI_VERSION} or later, or check out the manual setup guide.`, - ); - clack.log.info(`Setup FastAPI manually: ${chalk.cyan(docsUrl)}`); - clack.outro('PostHog wizard will see you next time!'); - return; - } - } - - await runAgentWizard(FASTAPI_AGENT_CONFIG, options); -} diff --git a/src/flask/flask-wizard-agent.ts b/src/flask/flask-wizard-agent.ts index 7bcba7d4..bd8b0db1 100644 --- a/src/flask/flask-wizard-agent.ts +++ b/src/flask/flask-wizard-agent.ts @@ -7,6 +7,10 @@ import { Integration } from '../lib/constants'; import fg from 'fast-glob'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { + PYTHON_DETECTION_IGNORES, + PYTHON_SOURCE_IGNORES, +} from '../lib/glob-patterns'; import { getFlaskVersion, getFlaskProjectType, @@ -55,7 +59,7 @@ export const FLASK_AGENT_CONFIG: FrameworkConfig = { ], { cwd: installDir, - ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'], + ignore: PYTHON_DETECTION_IGNORES, }, ); @@ -80,13 +84,7 @@ export const FLASK_AGENT_CONFIG: FrameworkConfig = { ['**/app.py', '**/wsgi.py', '**/application.py', '**/__init__.py'], { cwd: installDir, - ignore: [ - '**/venv/**', - '**/.venv/**', - '**/env/**', - '**/.env/**', - '**/__pycache__/**', - ], + ignore: PYTHON_SOURCE_IGNORES, }, ); diff --git a/src/javascript-node/javascript-node-wizard-agent.ts b/src/javascript-node/javascript-node-wizard-agent.ts index 7f31397d..4d5bd14d 100644 --- a/src/javascript-node/javascript-node-wizard-agent.ts +++ b/src/javascript-node/javascript-node-wizard-agent.ts @@ -3,6 +3,9 @@ import type { FrameworkConfig } from '../lib/framework-config'; import { Integration } from '../lib/constants'; import { tryGetPackageJson } from '../utils/clack-utils'; import { detectNodePackageManagers } from '../lib/package-manager-detection'; +import { hasPackageInstalled } from '../utils/package-json'; +import { FRAMEWORK_PACKAGES } from '../javascript-web/utils'; +import { hasLockfileOrDeps } from '../utils/js-detection'; type JavaScriptNodeContext = Record; @@ -23,7 +26,22 @@ export const JAVASCRIPT_NODE_AGENT_CONFIG: FrameworkConfig { const packageJson = await tryGetPackageJson(options); - return !!packageJson; + if (!packageJson) { + return false; + } + + // Exclude projects with known framework packages (handled by + // their dedicated detectors earlier in the enum) + for (const frameworkPkg of FRAMEWORK_PACKAGES) { + if (hasPackageInstalled(frameworkPkg, packageJson)) { + return false; + } + } + + // Catch-all for JS projects without browser signals (those + // matched javascript_web already). Require a lockfile or real + // dependencies so we don't match bare tooling package.json files. + return hasLockfileOrDeps(options.installDir, packageJson); }, }, @@ -42,8 +60,6 @@ export const JAVASCRIPT_NODE_AGENT_CONFIG: FrameworkConfig [ `Framework docs ID: javascript_node (use posthog://docs/frameworks/javascript_node for documentation)`, ], diff --git a/src/javascript-web/javascript-web-wizard-agent.ts b/src/javascript-web/javascript-web-wizard-agent.ts index 64121099..673201f8 100644 --- a/src/javascript-web/javascript-web-wizard-agent.ts +++ b/src/javascript-web/javascript-web-wizard-agent.ts @@ -49,30 +49,31 @@ export const JAVASCRIPT_WEB_AGENT_CONFIG: FrameworkConfig = { } } - // Ensure this is actually a JS project, not just a package.json for tooling - const { installDir } = options; + // Require a positive browser signal — without one, the project is + // more likely a Node.js server/CLI/worker and should fall through + // to the javascript_node catch-all (posthog-node is the safer + // default since posthog-js crashes without window/document). + // + // Bundlers alone are NOT a reliable browser signal — Vite/esbuild + // are commonly used for server-side builds (Cloudflare Workers, SSR, + // Vitest, etc.). Instead we check for: + // 1. An HTML entry point (fundamental to browser apps) + // 2. A "browser" field in package.json (standard npm browser flag) + const hasHtmlEntry = [ + 'index.html', + 'public/index.html', + 'src/index.html', + ].some((f) => fs.existsSync(path.join(options.installDir, f))); - // Check for a lockfile - const hasLockfile = [ - 'package-lock.json', - 'yarn.lock', - 'pnpm-lock.yaml', - 'bun.lockb', - 'bun.lock', - ].some((lockfile) => fs.existsSync(path.join(installDir, lockfile))); + const hasBrowserField = 'browser' in packageJson; - if (hasLockfile) { - return true; - } - - // Fallback: check if package.json has actual dependencies - const hasDeps = - (packageJson.dependencies && - Object.keys(packageJson.dependencies).length > 0) || - (packageJson.devDependencies && - Object.keys(packageJson.devDependencies).length > 0); + // Known browser frameworks without dedicated integrations + const BROWSER_FRAMEWORK_PACKAGES = ['gatsby']; + const hasBrowserFramework = BROWSER_FRAMEWORK_PACKAGES.some((pkg) => + hasPackageInstalled(pkg, packageJson), + ); - return !!hasDeps; + return hasHtmlEntry || hasBrowserField || hasBrowserFramework; }, }, diff --git a/src/javascript-web/utils.ts b/src/javascript-web/utils.ts index 013959dc..9c1f4e64 100644 --- a/src/javascript-web/utils.ts +++ b/src/javascript-web/utils.ts @@ -21,6 +21,8 @@ export const FRAMEWORK_PACKAGES = [ 'nuxt', 'vue', 'react-router', + '@remix-run/react', + '@remix-run/node', '@tanstack/react-start', '@tanstack/react-router', 'react-native', diff --git a/src/laravel/laravel-wizard-agent.ts b/src/laravel/laravel-wizard-agent.ts index b7785b30..6e8b400b 100644 --- a/src/laravel/laravel-wizard-agent.ts +++ b/src/laravel/laravel-wizard-agent.ts @@ -114,8 +114,6 @@ export const LARAVEL_AGENT_CONFIG: FrameworkConfig = { prompts: { projectTypeDetection: 'This is a PHP/Laravel project. Look for composer.json, artisan CLI, and app/ directory structure to confirm. Check for Laravel-specific packages like laravel/framework.', - packageInstallation: - 'Use Composer to install packages. Run `composer require posthog/posthog-php` without pinning a specific version.', getAdditionalContextLines: (context) => { const projectTypeName = context.projectType ? getLaravelProjectTypeName(context.projectType) @@ -135,17 +133,6 @@ export const LARAVEL_AGENT_CONFIG: FrameworkConfig = { lines.push(`Bootstrap file: ${context.bootstrapFile}`); } - // Add Laravel-specific guidance based on version structure - if (context.laravelStructure === 'latest') { - lines.push( - 'Note: Laravel 11+ uses simplified bootstrap/app.php for middleware and providers', - ); - } else { - lines.push( - 'Note: Use app/Http/Kernel.php for middleware, app/Providers for service providers', - ); - } - return lines; }, }, diff --git a/src/lib/__tests__/wizard-tools.test.ts b/src/lib/__tests__/wizard-tools.test.ts index aeb1319a..555ed748 100644 --- a/src/lib/__tests__/wizard-tools.test.ts +++ b/src/lib/__tests__/wizard-tools.test.ts @@ -47,6 +47,27 @@ describe('resolveEnvPath', () => { // edge case: filePath resolves to exactly workingDirectory expect(() => resolveEnvPath('/project', '.')).not.toThrow(); }); + + it('strips redundant subdirectory prefix from relative path', () => { + // Agent passes "services/mcp/.env" when workingDir is already "/ws/services/mcp" + const result = resolveEnvPath('/ws/services/mcp', 'services/mcp/.env'); + expect(result).toBe(path.resolve('/ws/services/mcp', '.env')); + }); + + it('strips single-level redundant prefix', () => { + const result = resolveEnvPath('/ws/frontend', 'frontend/.env.local'); + expect(result).toBe(path.resolve('/ws/frontend', '.env.local')); + }); + + it('does not strip when there is no redundant prefix', () => { + const result = resolveEnvPath('/ws/services/mcp', '.env'); + expect(result).toBe(path.resolve('/ws/services/mcp', '.env')); + }); + + it('does not strip legitimate nested paths', () => { + const result = resolveEnvPath('/ws/services/mcp', 'config/.env'); + expect(result).toBe(path.resolve('/ws/services/mcp', 'config/.env')); + }); }); // --------------------------------------------------------------------------- diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index ef6ebcf6..de868c8f 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -430,6 +430,7 @@ export async function runAgent( onMessage(message: any): void; finalize(resultMessage: any, totalDurationMs: number): any; }, + onStatus?: (message: string) => void, ): Promise<{ error?: AgentErrorType; message?: string }> { const { estimatedDurationMinutes = 8, @@ -624,6 +625,7 @@ export async function runAgent( spinner, collectedText, receivedSuccessResult, + onStatus, ); try { @@ -734,6 +736,7 @@ function handleSDKMessage( spinner: ReturnType, collectedText: string[], receivedSuccessResult = false, + onStatus?: (message: string) => void, ): void { logToFile(`SDK Message: ${message.type}`, JSON.stringify(message, null, 2)); @@ -760,8 +763,10 @@ function handleSDKMessage( ); const statusMatch = block.text.match(statusRegex); if (statusMatch) { - spinner.stop(statusMatch[1].trim()); + const statusMessage = statusMatch[1].trim(); + spinner.stop(statusMessage); spinner.start('Integrating PostHog...'); + onStatus?.(statusMessage); } } } diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index 2a47d7e9..6d5f1c29 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -33,57 +33,47 @@ import { uploadEnvironmentVariablesStep, } from '../steps'; import { checkAnthropicStatusWithPrompt } from '../utils/anthropic-status'; -import { enableDebugLogs } from '../utils/debug'; +import { enableDebugLogs, logToFile } from '../utils/debug'; import { createBenchmarkPipeline } from './middleware/benchmark'; - -/** - * Universal agent-powered wizard runner. - * Handles the complete flow for any framework using PostHog MCP integration. - */ -export async function runAgentWizard( - config: FrameworkConfig, +import { DisplayedError } from './errors'; +import type { CloudRegion } from '../utils/types'; + +// Re-export for consumers that import from agent-runner +export { DisplayedError } from './errors'; + +/** Setup data gathered once and reused across monorepo project runs. */ +export type SharedSetupData = { + cloudRegion: CloudRegion; + projectApiKey: string; + host: string; + accessToken: string; + projectId: number; +}; + +/** Options controlling which phases runAgentWizard executes. */ +export type AgentWizardMode = { + /** Pre-gathered setup data; skips AI consent, region, git, OAuth. */ + sharedSetup?: SharedSetupData; + /** Skip post-agent steps (env upload, MCP client install, outro). */ + skipPostAgent?: boolean; + /** Append a monorepo scope-fencing instruction to the agent prompt. */ + concurrentFence?: boolean; + /** Extra context lines appended to the agent prompt (e.g. workspace type). */ + additionalContext?: string[]; + /** Called when the agent emits a [STATUS] progress message. */ + onStatus?: (message: string) => void; +}; + +/** Run shared setup (AI consent, status check, region, git, OAuth). */ +export async function runSharedSetup( options: WizardOptions, -): Promise { + docsUrl?: string, +): Promise { if (options.debug) { enableDebugLogs(); } - // Version check - if (config.detection.minimumVersion && config.detection.getInstalledVersion) { - const version = await config.detection.getInstalledVersion(options); - if (version) { - const coerced = semver.coerce(version); - if (coerced && semver.lt(coerced, config.detection.minimumVersion)) { - const docsUrl = - config.metadata.unsupportedVersionDocsUrl ?? config.metadata.docsUrl; - clack.log.warn( - `Sorry: the wizard can't help you with ${config.metadata.name} ${version}. Upgrade to ${config.metadata.name} ${config.detection.minimumVersion} or later, or check out the manual setup guide.`, - ); - clack.log.info( - `Setup ${config.metadata.name} manually: ${chalk.cyan(docsUrl)}`, - ); - clack.outro('PostHog wizard will see you next time!'); - return; - } - } - } - - // Setup phase - printWelcome({ wizardName: getWelcomeMessage(config.metadata.name) }); - - if (config.metadata.beta) { - clack.log.info( - `${chalk.yellow('[BETA]')} The ${ - config.metadata.name - } wizard is in beta. Questions or feedback? Email ${chalk.cyan( - 'wizard@posthog.com', - )}`, - ); - } - - if (config.metadata.preRunNotice) { - clack.log.warn(config.metadata.preRunNotice); - } + const fallbackUrl = docsUrl ?? 'https://posthog.com/docs'; clack.log.info( `We're about to read your project using our LLM gateway.\n\n.env* file contents will not leave your machine.\n\nOther files will be read and edited to provide a fully-custom PostHog integration.`, @@ -92,26 +82,54 @@ export async function runAgentWizard( const aiConsent = await askForAIConsent(options); if (!aiConsent) { await abort( - `This wizard uses an LLM agent to intelligently modify your project. Please view the docs to set up ${config.metadata.name} manually instead: ${config.metadata.docsUrl}`, + `This wizard uses an LLM agent to intelligently modify your project. Please view the docs to set up PostHog manually instead: ${fallbackUrl}`, 0, ); } - // Check Anthropic/Claude service status before proceeding const statusOk = await checkAnthropicStatusWithPrompt({ ci: options.ci }); if (!statusOk) { await abort( - `Please try again later, or set up ${config.metadata.name} manually: ${config.metadata.docsUrl}`, + `Please try again later, or set up PostHog manually: ${fallbackUrl}`, 0, ); } - const typeScriptDetected = isUsingTypeScript(options); - await confirmContinueIfNoOrDirtyGitRepo(options); + const { projectApiKey, host, accessToken, projectId, cloudRegion } = + await getOrAskForProjectData(options); + + return { cloudRegion, projectApiKey, host, accessToken, projectId }; +} + +/** Run pre-flight for a project (version check, detection, gatherContext). Returns null to skip. */ +export async function runPreflight( + config: FrameworkConfig, + options: WizardOptions, +): Promise { + // Version check + if (config.detection.minimumVersion && config.detection.getInstalledVersion) { + const version = await config.detection.getInstalledVersion(options); + if (version) { + const coerced = semver.coerce(version); + if (coerced && semver.lt(coerced, config.detection.minimumVersion)) { + const docsUrl = + config.metadata.unsupportedVersionDocsUrl ?? config.metadata.docsUrl; + clack.log.warn( + `Sorry: the wizard can't help you with ${config.metadata.name} ${version}. Upgrade to ${config.metadata.name} ${config.detection.minimumVersion} or later, or check out the manual setup guide.`, + ); + clack.log.info( + `Setup ${config.metadata.name} manually: ${chalk.cyan(docsUrl)}`, + ); + return null; + } + } + } + + const typeScriptDetected = isUsingTypeScript(options); + // Framework detection and version - // Only check package.json for Node.js/JavaScript frameworks const usesPackageJson = config.detection.usesPackageJson !== false; let packageJson: PackageDotJson | null = null; let frameworkVersion: string | undefined; @@ -122,10 +140,10 @@ export async function runAgentWizard( packageJson, config.detection.packageName, config.detection.packageDisplayName, + config.detection.alternatePackageNames, ); frameworkVersion = config.detection.getVersion(packageJson); } else { - // For non-Node frameworks (e.g., Django), version is handled differently frameworkVersion = config.detection.getVersion(null); } @@ -135,15 +153,6 @@ export async function runAgentWizard( analytics.setTag(`${config.metadata.integration}-version`, versionBucket); } - analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { - action: 'started agent integration', - integration: config.metadata.integration, - }); - - // Get PostHog credentials - const { projectApiKey, host, accessToken, projectId, cloudRegion } = - await getOrAskForProjectData(options); - // Gather framework-specific context (e.g., Next.js router, React Native platform) const frameworkContext = config.metadata.gatherContext ? await config.metadata.gatherContext(options) @@ -155,7 +164,71 @@ export async function runAgentWizard( analytics.setTag(key, value); }); - const integrationPrompt = buildIntegrationPrompt( + return { frameworkContext, frameworkVersion, typeScriptDetected }; +} + +/** Data gathered during the per-project pre-flight phase. */ +export type PreflightData = { + frameworkContext: Record; + frameworkVersion: string | undefined; + typeScriptDetected: boolean; +}; + +/** Universal agent-powered wizard runner. Behavior controlled by `mode`. */ +export async function runAgentWizard( + config: FrameworkConfig, + options: WizardOptions, + mode?: AgentWizardMode & { preflight?: PreflightData }, +): Promise { + if (options.debug) { + enableDebugLogs(); + } + + const sharedSetup = mode?.sharedSetup; + const skipPostAgent = mode?.skipPostAgent ?? false; + + // If no pre-gathered preflight, show welcome inline + let preflight = mode?.preflight; + if (!preflight) { + printWelcome({ wizardName: getWelcomeMessage(config.metadata.name) }); + + if (config.metadata.beta) { + clack.log.info( + `${chalk.yellow('[BETA]')} The ${ + config.metadata.name + } wizard is in beta. Questions or feedback? Email ${chalk.cyan( + 'wizard@posthog.com', + )}`, + ); + } + + if (config.metadata.preRunNotice) { + clack.log.warn(config.metadata.preRunNotice); + } + } + + const { cloudRegion, projectApiKey, host, accessToken, projectId } = + sharedSetup ?? (await runSharedSetup(options, config.metadata.docsUrl)); + + // Use pre-gathered preflight data or run it now + if (!preflight) { + const preflightResult = await runPreflight(config, options); + if (!preflightResult) { + // Version check or package detection failed + clack.outro('PostHog wizard will see you next time!'); + return; + } + preflight = preflightResult; + } + + const { frameworkContext, frameworkVersion, typeScriptDetected } = preflight; + + analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { + action: 'started agent integration', + integration: config.metadata.integration, + }); + + let integrationPrompt = buildIntegrationPrompt( config, { frameworkVersion: frameworkVersion || 'latest', @@ -167,6 +240,19 @@ export async function runAgentWizard( frameworkContext, ); + // Prompt fencing for concurrent monorepo mode + if (mode?.concurrentFence) { + integrationPrompt += `IMPORTANT: This project is being set up as part of a monorepo with other projects running concurrently. You MUST only modify files within the project directory at ${options.installDir}. Do not navigate to, read from, or edit files in sibling packages or parent directories. Do not modify shared configuration files outside your project scope.\n\nYour working directory is already set to ${options.installDir}. When using set_env_values or check_env_keys, use paths relative to THIS directory (e.g. ".env" or ".env.local"), NOT paths that include the project's subdirectory name.\n\n`; + } + + // Append any extra context from monorepo orchestration + if (mode?.additionalContext?.length) { + integrationPrompt += mode.additionalContext + .map((line) => `${line}\n`) + .join(''); + integrationPrompt += '\n'; + } + // Initialize and run agent const spinner = clack.spinner(); @@ -202,9 +288,92 @@ export async function runAgentWizard( errorMessage: 'Integration failed', }, middleware, + mode?.onStatus, ); // Handle error cases detected in agent output + handleAgentErrors(agentResult, config); + + logToFile( + `[runAgentWizard] Agent completed successfully for ${config.metadata.name}`, + ); + + // Skip post-agent steps when the caller handles them (monorepo concurrent mode) + if (skipPostAgent) { + return; + } + + // Build environment variables from OAuth credentials + const envVars = config.environment.getEnvVars(projectApiKey, host); + + // Upload environment variables to hosting providers (if configured) + let uploadedEnvVars: string[] = []; + if (config.environment.uploadToHosting) { + uploadedEnvVars = await uploadEnvironmentVariablesStep(envVars, { + integration: config.metadata.integration, + options, + }); + } + + // Add MCP server to clients + await addMCPServerToClientsStep({ + integration: config.metadata.integration, + ci: options.ci, + }); + + // Build outro message + const continueUrl = options.signup + ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` + : undefined; + + const changes = [ + ...config.ui.getOutroChanges(frameworkContext), + Object.keys(envVars).length > 0 + ? `Added environment variables to .env file` + : '', + uploadedEnvVars.length > 0 + ? `Uploaded environment variables to your hosting provider` + : '', + ].filter(Boolean); + + const nextSteps = [ + ...config.ui.getOutroNextSteps(frameworkContext), + uploadedEnvVars.length === 0 && config.environment.uploadToHosting + ? `Upload your Project API key to your hosting provider` + : '', + ].filter(Boolean); + + const outroMessage = ` +${chalk.green('Successfully installed PostHog!')} + +${chalk.cyan('What the agent did:')} +${changes.map((change) => `• ${change}`).join('\n')} + +${chalk.yellow('Next steps:')} +${nextSteps.map((step) => `• ${step}`).join('\n')} + +Learn more: ${chalk.cyan(config.metadata.docsUrl)} +${continueUrl ? `\nContinue onboarding: ${chalk.cyan(continueUrl)}\n` : ``} +${chalk.dim( + 'Note: This wizard uses an LLM agent to analyze and modify your project. Please review the changes made.', +)} + +${chalk.dim(`How did this work for you? Drop us a line: wizard@posthog.com`)}`; + + // In monorepo sequential mode, use log.success instead of outro + if (sharedSetup) { + clack.log.success(outroMessage); + } else { + clack.outro(outroMessage); + await analytics.shutdown('success'); + } +} + +/** Check agent result for known error types and throw DisplayedError. */ +function handleAgentErrors( + agentResult: { error?: string; message?: string }, + config: FrameworkConfig, +): void { if (agentResult.error === AgentErrorType.MCP_MISSING) { analytics.captureException( new Error('Agent could not access PostHog MCP server'), @@ -226,9 +395,10 @@ Please try again, or set up ${ } manually by following our documentation: ${chalk.cyan(config.metadata.docsUrl)}`; - clack.outro(errorMessage); - await analytics.shutdown('error'); - process.exit(1); + clack.log.error(errorMessage); + throw new DisplayedError( + `Could not access the PostHog MCP server for ${config.metadata.name}`, + ); } if (agentResult.error === AgentErrorType.RESOURCE_MISSING) { @@ -251,9 +421,10 @@ Please try again, or set up ${ } manually by following our documentation: ${chalk.cyan(config.metadata.docsUrl)}`; - clack.outro(errorMessage); - await analytics.shutdown('error'); - process.exit(1); + clack.log.error(errorMessage); + throw new DisplayedError( + `Could not access setup resource for ${config.metadata.name}`, + ); } if ( @@ -279,71 +450,13 @@ ${chalk.yellow(agentResult.message || 'Unknown error')} Please report this error to: ${chalk.cyan('wizard@posthog.com')}`; - clack.outro(errorMessage); - await analytics.shutdown('error'); - process.exit(1); - } - - // Build environment variables from OAuth credentials - const envVars = config.environment.getEnvVars(projectApiKey, host); - - // Upload environment variables to hosting providers (if configured) - let uploadedEnvVars: string[] = []; - if (config.environment.uploadToHosting) { - uploadedEnvVars = await uploadEnvironmentVariablesStep(envVars, { - integration: config.metadata.integration, - options, - }); + clack.log.error(errorMessage); + throw new DisplayedError( + `API error during ${config.metadata.name} setup: ${ + agentResult.message || 'Unknown error' + }`, + ); } - - // Add MCP server to clients - await addMCPServerToClientsStep({ - integration: config.metadata.integration, - ci: options.ci, - }); - - // Build outro message - const continueUrl = options.signup - ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` - : undefined; - - const changes = [ - ...config.ui.getOutroChanges(frameworkContext), - Object.keys(envVars).length > 0 - ? `Added environment variables to .env file` - : '', - uploadedEnvVars.length > 0 - ? `Uploaded environment variables to your hosting provider` - : '', - ].filter(Boolean); - - const nextSteps = [ - ...config.ui.getOutroNextSteps(frameworkContext), - uploadedEnvVars.length === 0 && config.environment.uploadToHosting - ? `Upload your Project API key to your hosting provider` - : '', - ].filter(Boolean); - - const outroMessage = ` -${chalk.green('Successfully installed PostHog!')} - -${chalk.cyan('What the agent did:')} -${changes.map((change) => `• ${change}`).join('\n')} - -${chalk.yellow('Next steps:')} -${nextSteps.map((step) => `• ${step}`).join('\n')} - -Learn more: ${chalk.cyan(config.metadata.docsUrl)} -${continueUrl ? `\nContinue onboarding: ${chalk.cyan(continueUrl)}\n` : ``} -${chalk.dim( - 'Note: This wizard uses an LLM agent to analyze and modify your project. Please review the changes made.', -)} - -${chalk.dim(`How did this work for you? Drop us a line: wizard@posthog.com`)}`; - - clack.outro(outroMessage); - - await analytics.shutdown('success'); } /** @@ -404,7 +517,7 @@ STEP 3: Run the installation command using Bash: STEP 4: Load the installed skill's SKILL.md file to understand what references are available. -STEP 5: Follow the skill's workflow files in sequence. Look for numbered workflow files in the references (e.g., files with patterns like "1.0-", "1.1-", "1.2-"). Start with the first one and proceed through each step until completion. Each workflow file will tell you what to do and which file comes next. Never directly write PostHog keys directly to code files; always use environment variables. +STEP 5: Follow the skill's workflow files in sequence. Look for numbered workflow files in the references (e.g., files with patterns like "1.0-", "1.1-", "1.2-"). Start with the first one and proceed through each step until completion. Each workflow file will tell you what to do and which file comes next. STEP 6: Set up environment variables for PostHog using the wizard-tools MCP server (this runs locally — secret values never leave the machine): - Use check_env_keys to see which keys already exist in the project's .env file (e.g. .env.local or .env). @@ -415,5 +528,12 @@ STEP 6: Set up environment variables for PostHog using the wizard-tools MCP serv Important: Use the detect_package_manager tool (from the wizard-tools MCP server) to determine which package manager the project uses. Do not manually search for lockfiles or config files. Always install packages as a background task. Don't await completion; proceed with other work immediately after starting the installation. You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure. +IMPORTANT — Prior run detection: BEFORE exploring the codebase, check if a file named "posthog-setup-report.md" exists in the project root (use Glob or Read). If it exists, read it — it contains a summary of what a previous wizard run already set up (installed packages, initialized files, events, dashboards, insights). Use this as your starting point: skip any work that's already done and only add what's missing. This saves significant exploration time and context. + +IMPORTANT — Context window management: You have a limited context window. Be efficient: + - Call dashboards-get-all and insights-get-all exactly ONCE each during the entire session. The insights response is very large (often 100K+ tokens) and will exhaust your context if called again. Save the names/IDs from each call and refer to your saved list for all subsequent checks. + - BATCH EDITS: When you need to make multiple changes to the same file, plan ALL changes first, then make them in as few Edit calls as possible. + - AVOID re-reading files you have already read recently unless you need to edit them. + `; } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e11a6118..756f79a7 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -21,11 +21,11 @@ export enum Integration { android = 'android', rails = 'rails', - // Language fallbacks + // Language fallbacks (order matters: more-specific before catch-all) python = 'python', ruby = 'ruby', - javascriptNode = 'javascript_node', javascript_web = 'javascript_web', + javascriptNode = 'javascript_node', } export interface Args { debug: boolean; diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 00000000..e9aab72d --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,10 @@ +/** + * Error that has already been displayed to the user via clack. + * Callers can skip redundant error logging when catching this. + */ +export class DisplayedError extends Error { + constructor(message: string) { + super(message); + this.name = 'DisplayedError'; + } +} diff --git a/src/lib/framework-config.ts b/src/lib/framework-config.ts index 8e5a2c0b..7791ed2d 100644 --- a/src/lib/framework-config.ts +++ b/src/lib/framework-config.ts @@ -2,14 +2,7 @@ import type { Integration } from './constants'; import type { WizardOptions } from '../utils/types'; import type { PackageManagerDetector } from './package-manager-detection'; -/** - * Configuration interface for framework-specific agent integrations. - * Each framework exports a FrameworkConfig that the universal runner uses. - * - * The TContext generic represents the framework-specific context gathered - * before the agent runs (e.g., router type for Next.js, project type for Django). - * The runner threads this opaquely — all framework-specific logic stays inside the config. - */ +/** Configuration for a framework-specific agent integration. TContext is framework-specific pre-agent context. */ export interface FrameworkConfig< TContext extends Record = Record, > { @@ -48,11 +41,7 @@ export interface FrameworkMetadata< /** Optional notice shown before the agent runs (e.g., "Close Xcode before proceeding"). */ preRunNotice?: string; - /** - * Optional function to gather framework-specific context before agent runs. - * For Next.js: detects router type - * For React Native: detects Expo vs bare - */ + /** Gather framework-specific context before agent runs (e.g. router type, platform). */ gatherContext?: (options: WizardOptions) => Promise; /** Optional additional MCP servers for this framework (e.g., Svelte MCP). */ @@ -66,6 +55,9 @@ export interface FrameworkDetection { /** Package name to check in package.json (e.g., "next", "react") */ packageName: string; + /** Additional package names that also indicate this framework is installed (e.g., Remix for React Router). */ + alternatePackageNames?: string[]; + /** Human-readable name for error messages (e.g., "Next.js") */ packageDisplayName: string; @@ -75,11 +67,7 @@ export interface FrameworkDetection { /** Optional: Convert version to analytics bucket (e.g., "15.x") */ getVersionBucket?: (version: string) => string; - /** - * Whether this framework uses package.json (Node.js/JavaScript). - * If false, skips package.json checks (for Python, Go, etc.) - * Defaults to true if not specified. - */ + /** Whether this framework uses package.json. Defaults to true. */ usesPackageJson?: boolean; /** Minimum supported version. If set, runner checks before proceeding. */ @@ -89,7 +77,9 @@ export interface FrameworkDetection { getInstalledVersion?: (options: WizardOptions) => Promise; /** Detect whether this framework is present in the project. */ - detect: (options: Pick) => Promise; + detect: ( + options: Pick, + ) => Promise; /** Detect the project's package manager(s). Used by the in-process MCP tool. */ detectPackageManager: PackageManagerDetector; @@ -102,10 +92,7 @@ export interface EnvironmentConfig { /** Whether to upload env vars to hosting providers post-agent */ uploadToHosting: boolean; - /** - * Build the environment variables object for this framework. - * Returns the exact variable names and values to upload to hosting providers. - */ + /** Build the env vars object for this framework. */ getEnvVars: (apiKey: string, host: string) => Record; } @@ -122,13 +109,9 @@ export interface AnalyticsConfig< getEventProperties?: (context: TContext) => Record; } -/** - * Default package installation instruction used when frameworks don't - * provide their own. Frameworks with specific needs (e.g., Swift SPM, - * Composer) override this in their config. - */ +/** Default package installation instruction for the agent prompt. */ export const DEFAULT_PACKAGE_INSTALLATION = - 'Use the detect_package_manager tool to determine the package manager. Do not manually edit package.json; the package manager handles it automatically.'; + 'Use the detect_package_manager tool to determine the package manager. Do not manually edit dependency manifest files; let the package manager handle it.'; export const PYTHON_PACKAGE_INSTALLATION = 'Use the detect_package_manager tool to determine the package manager. If the detected tool manages dependencies directly (e.g. uv add, poetry add), use it — it will update the manifest automatically. If using pip, you must also add the dependency to requirements.txt or the appropriate manifest file.'; @@ -139,26 +122,13 @@ export const PYTHON_PACKAGE_INSTALLATION = export interface PromptConfig< TContext extends Record = Record, > { - /** - * Optional: Additional context lines to append to base prompt - * For Next.js: "- Router: app" - * For React Native: "- Platform: Expo" - */ + /** Additional context lines to append to the agent prompt. */ getAdditionalContextLines?: (context: TContext) => string[]; - /** - * How to detect the project type for this framework. - * Included in the agent prompt as project context. - * e.g., "Look for package.json and lockfiles" or "Look for requirements.txt and manage.py" - */ + /** Project type detection hint for the agent prompt. */ projectTypeDetection: string; - /** - * How to install packages for this framework. - * Included in the agent prompt as project context. - * Defaults to DEFAULT_PACKAGE_INSTALLATION. Only override if the framework - * has specific installation guidance (e.g., Swift SPM, Composer). - */ + /** Package installation instruction for the agent prompt. Defaults to DEFAULT_PACKAGE_INSTALLATION. */ packageInstallation?: string; } diff --git a/src/lib/glob-patterns.ts b/src/lib/glob-patterns.ts new file mode 100644 index 00000000..a054cb12 --- /dev/null +++ b/src/lib/glob-patterns.ts @@ -0,0 +1,14 @@ +/** Glob ignore patterns for Python project detection. */ +export const PYTHON_DETECTION_IGNORES = [ + '**/node_modules/**', + '**/venv/**', + '**/.venv/**', + '**/env/**', + '**/.env/**', +]; + +/** Extended ignores that also exclude __pycache__ (for source file scans). */ +export const PYTHON_SOURCE_IGNORES = [ + ...PYTHON_DETECTION_IGNORES, + '**/__pycache__/**', +]; diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 00000000..83b51fcd --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,2 @@ +// Auto-generated by scripts/generate-version.js — do not edit +export const VERSION = '1.35.2'; diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index 30689177..1b801539 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -1,11 +1,4 @@ -/** - * Unified in-process MCP server for the PostHog wizard. - * - * Provides tools that run locally (secret values never leave the machine): - * - check_env_keys: Check which env var keys exist in a .env file - * - set_env_values: Create/update env vars in a .env file - * - detect_package_manager: Detect the project's package manager(s) - */ +/** In-process MCP server providing env and package manager tools (secrets stay local). */ import path from 'path'; import fs from 'fs'; @@ -41,13 +34,33 @@ export interface WizardToolsOptions { // Env file helpers // --------------------------------------------------------------------------- -/** - * Resolve filePath relative to workingDirectory, rejecting path traversal. - */ +/** Resolve filePath relative to workingDirectory, rejecting traversal and stripping redundant prefixes. */ export function resolveEnvPath( workingDirectory: string, filePath: string, ): string { + // Defensive: strip redundant prefix if a relative filePath duplicates the + // tail of workingDirectory. E.g. workingDir="/ws/services/mcp", + // filePath="services/mcp/.env" → normalise to ".env". + // Only applies to relative paths (absolute paths skip this). + if (!path.isAbsolute(filePath)) { + const normalised = path.normalize(filePath); + const segments = normalised.split(path.sep).filter(Boolean); + if (segments.length > 1) { + for (let i = segments.length - 1; i >= 1; i--) { + const prefix = segments.slice(0, i).join(path.sep); + if (prefix && workingDirectory.endsWith(path.sep + prefix)) { + const stripped = segments.slice(i).join(path.sep); + logToFile( + `resolveEnvPath: stripped redundant prefix "${prefix}" from "${filePath}" → "${stripped}"`, + ); + filePath = stripped; + break; + } + } + } + } + const resolved = path.resolve(workingDirectory, filePath); if ( !resolved.startsWith(workingDirectory + path.sep) && @@ -60,10 +73,7 @@ export function resolveEnvPath( return resolved; } -/** - * Ensure the given env file basename is covered by .gitignore in the working directory. - * Creates .gitignore if it doesn't exist; appends the entry if missing. - */ +/** Ensure the env file is covered by .gitignore (creates or appends as needed). */ export function ensureGitignoreCoverage( workingDirectory: string, envFileName: string, @@ -139,10 +149,7 @@ export function mergeEnvValues( const SERVER_NAME = 'wizard-tools'; -/** - * Create the unified in-process MCP server with all wizard tools. - * Must be called asynchronously because the SDK is an ESM module loaded via dynamic import. - */ +/** Create the in-process MCP server with all wizard tools. */ export async function createWizardToolsServer(options: WizardToolsOptions) { const { workingDirectory, detectPackageManager } = options; const sdk = await getSDKModule(); @@ -156,7 +163,9 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { { filePath: z .string() - .describe('Path to the .env file, relative to the project root'), + .describe( + 'Path to the .env file relative to the working directory (e.g. ".env", ".env.local"). Do NOT include subdirectory prefixes.', + ), keys: z .array(z.string()) .describe('Environment variable key names to check'), @@ -190,7 +199,9 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { { filePath: z .string() - .describe('Path to the .env file, relative to the project root'), + .describe( + 'Path to the .env file relative to the working directory (e.g. ".env", ".env.local"). Do NOT include subdirectory prefixes.', + ), values: z .record(z.string(), z.string()) .describe('Key-value pairs to set'), diff --git a/src/monorepo/monorepo-flow.ts b/src/monorepo/monorepo-flow.ts new file mode 100644 index 00000000..d1170c62 --- /dev/null +++ b/src/monorepo/monorepo-flow.ts @@ -0,0 +1,502 @@ +import path from 'path'; +import chalk from 'chalk'; +import clack from '../utils/clack'; +import { withSilentOutput } from '../utils/clack'; +import { logToFile, withLogFile } from '../utils/debug'; +import { analytics } from '../utils/analytics'; +import { FRAMEWORK_REGISTRY } from '../lib/registry'; +import { + runAgentWizard, + runSharedSetup, + runPreflight, + type SharedSetupData, + type PreflightData, +} from '../lib/agent-runner'; +import { + addMCPServerToClientsStep, + uploadEnvironmentVariablesStep, +} from '../steps'; +import { abortIfCancelled } from '../utils/clack-utils'; +import { + detectWorkspaces, + type WorkspaceType, +} from '../utils/workspace-detection'; +import { isLikelyApp } from '../utils/app-detection'; +import { hasPostHogInstalled } from '../utils/posthog-detection'; +import { detectIntegration } from '../run'; +import { Integration } from '../lib/constants'; +import type { WizardOptions } from '../utils/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DetectedProject = { + dir: string; + relativePath: string; + integration: Integration; + frameworkName: string; + alreadyConfigured: boolean; + /** URL-safe slug derived from relativePath, computed once. */ + slug: string; +}; + +type ProjectResult = { + project: DetectedProject; + success: boolean; + error?: string; +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export type WorkspaceDetectionResult = { + projects: DetectedProject[]; + workspaceType: WorkspaceType; +}; + +/** Detect workspace members and their frameworks. Returns null if not a monorepo. */ +export async function detectWorkspaceProjects( + options: WizardOptions, +): Promise { + const workspace = await detectWorkspaces(options.installDir); + if (!workspace) { + return null; + } + + logToFile( + `[Monorepo] Detected ${workspace.type} workspace with ${workspace.memberDirs.length} members`, + ); + + const results = await Promise.all( + workspace.memberDirs.map(async (memberDir) => { + const integration = await detectIntegration({ + installDir: memberDir, + workspaceRootDir: workspace.rootDir, + }); + if (!integration) return null; + + // Filter out library packages for generic language-level integrations + if (!(await isLikelyApp(memberDir, integration))) { + logToFile( + `[Monorepo] Skipping ${path.relative( + options.installDir, + memberDir, + )} — detected as ${integration} but appears to be a library`, + ); + return null; + } + + const config = FRAMEWORK_REGISTRY[integration]; + const alreadyConfigured = await hasPostHogInstalled(memberDir); + const relativePath = path.relative(options.installDir, memberDir); + const slug = relativePath.replace(/[^a-zA-Z0-9-]/g, '-') || 'root'; + + return { + dir: memberDir, + relativePath, + integration, + frameworkName: config.metadata.name, + alreadyConfigured, + slug, + } as DetectedProject; + }), + ); + + const projects = results.filter((r): r is DetectedProject => r !== null); + return { projects, workspaceType: workspace.type }; +} + +/** Run the monorepo flow: preparation (sequential) → instrumentation (concurrent) → post-flight. */ +export async function runMonorepoFlow( + projects: DetectedProject[], + options: WizardOptions, + workspaceType?: WorkspaceType, +): Promise { + clack.log.info( + `Detected a monorepo with ${chalk.cyan(String(projects.length))} projects`, + ); + + // Let user select which projects to set up + const selected = await selectMonorepoProjects(projects, options); + + if (selected.length === 0) { + clack.log.warn('No projects selected.'); + clack.outro('PostHog wizard will see you next time!'); + return; + } + + analytics.setTag('monorepo', 'true'); + analytics.setTag('monorepo_projects_detected', String(projects.length)); + analytics.setTag('monorepo_projects_selected', String(selected.length)); + + // Run shared setup once (AI consent, region, git check, OAuth) + const sharedSetup = await runSharedSetup(options); + + // Benchmark mode: fall back to sequential execution + if (options.benchmark) { + clack.log.warn( + 'Benchmark mode enabled — running projects sequentially (benchmark middleware uses global log path)', + ); + await runMonorepoSequential(selected, options, sharedSetup); + return; + } + + // ── Phase 1: Preparation (sequential) ───────────────────────────────── + clack.log.info( + `Gathering context for ${chalk.cyan(String(selected.length))} project${ + selected.length > 1 ? 's' : '' + }...`, + ); + + type PreflightResult = { + project: DetectedProject; + preflight: PreflightData; + config: (typeof FRAMEWORK_REGISTRY)[Integration]; + projectOptions: WizardOptions; + }; + + const preflightResults: PreflightResult[] = []; + + for (const project of selected) { + const config = FRAMEWORK_REGISTRY[project.integration]; + const projectOptions: WizardOptions = { + ...options, + installDir: project.dir, + workspaceRootDir: options.installDir, + }; + + clack.log.step( + `Preparing ${chalk.bold(project.frameworkName)} in ${chalk.cyan( + project.relativePath, + )}`, + ); + + try { + const preflight = await runPreflight(config, projectOptions); + if (preflight) { + preflightResults.push({ project, preflight, config, projectOptions }); + } else { + logToFile( + `[Monorepo] Skipping ${project.relativePath} — preparation returned null (e.g. unsupported version)`, + ); + clack.log.warn( + `Skipping ${project.frameworkName} in ${project.relativePath}`, + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[Monorepo] Preparation error for ${project.relativePath}: ${errorMessage}`, + ); + clack.log.error( + `Setup failed for ${project.frameworkName} in ${ + project.relativePath + }: ${chalk.red(errorMessage)}`, + ); + } + } + + if (preflightResults.length === 0) { + clack.log.warn('No projects ready to set up.'); + clack.outro('PostHog wizard will see you next time!'); + await analytics.shutdown('error'); + return; + } + + // ── Phase 2: Concurrent agent execution ─────────────────────────────── + const total = preflightResults.length; + + const progressSpinner = clack.spinner(); + progressSpinner.start(`Instrumenting ${total} projects (0/${total} done)`); + + let completed = 0; + + // Use Promise.allSettled for deterministic result ordering + const settledResults = await Promise.allSettled( + preflightResults.map(({ project, preflight, config, projectOptions }) => { + const projectLogPath = `/tmp/posthog-wizard-${project.slug}.log`; + + return withLogFile(projectLogPath, () => + withSilentOutput(async () => { + try { + await runAgentWizard(config, projectOptions, { + sharedSetup, + skipPostAgent: true, + concurrentFence: true, + preflight, + additionalContext: workspaceType + ? [`This project is part of a ${workspaceType} monorepo.`] + : undefined, + onStatus: (statusMessage: string) => { + const label = project.relativePath || project.frameworkName; + progressSpinner.stop(`${chalk.cyan(label)}: ${statusMessage}`); + progressSpinner.start( + `Instrumenting ${total} projects (${completed}/${total} done)`, + ); + }, + }); + return { project, success: true } as ProjectResult; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[Monorepo] Agent error for ${project.relativePath}: ${errorMessage}`, + ); + return { + project, + success: false, + error: errorMessage, + } as ProjectResult; + } finally { + // Update progress spinner — progressSpinner is the real clack + // spinner captured before entering silent mode, so .message() + // still writes to stdout even inside withSilentOutput. + completed++; + const label = project.relativePath || project.frameworkName; + progressSpinner.message( + `${completed}/${total} done (latest: ${label})`, + ); + } + }), + ); + }), + ); + + progressSpinner.stop( + `All ${chalk.cyan(String(total))} projects instrumented`, + ); + + // Extract results in order (allSettled preserves input order) + const results: ProjectResult[] = settledResults.map((settled, i) => { + if (settled.status === 'fulfilled') { + return settled.value; + } + // Should not happen since we catch inside, but handle defensively + return { + project: preflightResults[i].project, + success: false, + error: + settled.reason instanceof Error + ? settled.reason.message + : 'Unknown error', + }; + }); + + // Print per-project results + for (const result of results) { + const label = result.project.relativePath + ? `${chalk.bold(result.project.frameworkName)} in ${chalk.cyan( + result.project.relativePath, + )}` + : chalk.bold(result.project.frameworkName); + const logPath = chalk.dim(`/tmp/posthog-wizard-${result.project.slug}.log`); + if (result.success) { + clack.log.success(`${label}\n│ Log: ${logPath}`); + } else { + clack.log.error( + `${label} — ${chalk.red( + result.error ?? 'Unknown error', + )}\n│ Log: ${logPath}`, + ); + } + } + + // ── Phase 3: Post-flight (sequential) ───────────────────────────────── + await runPostFlight(results, sharedSetup, options); + + printMonorepoSummary(results); + + const allSucceeded = results.every((r) => r.success); + await analytics.shutdown(allSucceeded ? 'success' : 'error'); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Sequential monorepo execution (benchmark mode fallback). */ +async function runMonorepoSequential( + selected: DetectedProject[], + options: WizardOptions, + sharedSetup: SharedSetupData, +): Promise { + clack.log.info( + `Setting up ${chalk.cyan(String(selected.length))} project${ + selected.length > 1 ? 's' : '' + } sequentially`, + ); + + const results: ProjectResult[] = []; + + for (let i = 0; i < selected.length; i++) { + const project = selected[i]; + const projectNumber = i + 1; + const totalProjects = selected.length; + + clack.log.step( + `\n${chalk.cyan( + `[${projectNumber}/${totalProjects}]`, + )} Setting up PostHog for ${chalk.bold( + project.frameworkName, + )} in ${chalk.cyan(project.relativePath)}`, + ); + + const projectOptions: WizardOptions = { + ...options, + installDir: project.dir, + workspaceRootDir: options.installDir, + }; + + const config = FRAMEWORK_REGISTRY[project.integration]; + + // Clear framework-specific tags from previous project to prevent leakage + if (i > 0) { + const prevIntegration = selected[i - 1].integration; + analytics.clearTagsWithPrefix(`${prevIntegration}-`); + } + analytics.setTag('integration', project.integration); + + try { + await runAgentWizard(config, projectOptions, { sharedSetup }); + results.push({ project, success: true }); + clack.log.success( + chalk.dim(`✓ ${projectNumber}/${totalProjects} complete`), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[Monorepo] Error setting up ${project.relativePath}: ${errorMessage}`, + ); + clack.log.error( + `Failed to set up ${project.frameworkName} in ${ + project.relativePath + }: ${chalk.red(errorMessage)}`, + ); + results.push({ project, success: false, error: errorMessage }); + } + } + + printMonorepoSummary(results); + + const allSucceeded = results.every((r) => r.success); + await analytics.shutdown(allSucceeded ? 'success' : 'error'); +} + +/** Run post-flight once: env var upload and MCP client installation. */ +async function runPostFlight( + results: ProjectResult[], + sharedSetup: SharedSetupData, + options: WizardOptions, +): Promise { + const successfulResults = results.filter((r) => r.success); + if (successfulResults.length === 0) return; + + // Collect and upload env vars once for all successful projects + const allEnvVars: Record = {}; + for (const result of successfulResults) { + const config = FRAMEWORK_REGISTRY[result.project.integration]; + const envVars = config.environment.getEnvVars( + sharedSetup.projectApiKey, + sharedSetup.host, + ); + if (config.environment.uploadToHosting) { + Object.assign(allEnvVars, envVars); + } + } + + if (Object.keys(allEnvVars).length > 0) { + // Use the first project that requires hosting upload for the integration tag + const uploadProject = successfulResults.find( + (r) => + FRAMEWORK_REGISTRY[r.project.integration].environment.uploadToHosting, + ); + if (uploadProject) { + await uploadEnvironmentVariablesStep(allEnvVars, { + integration: uploadProject.project.integration, + options, + }); + } + } + + // Add MCP server to clients once (integration is only used for analytics) + await addMCPServerToClientsStep({ + integration: successfulResults[0].project.integration, + ci: options.ci, + }); +} + +/** + * Present detected projects for user selection via multiselect. + */ +async function selectMonorepoProjects( + projects: DetectedProject[], + options: WizardOptions, +): Promise { + // In CI mode, select all unconfigured projects automatically + if (options.ci) { + return projects.filter((p) => !p.alreadyConfigured); + } + + const selectedDirs: string[] = await abortIfCancelled( + clack.multiselect({ + message: + 'Which projects do you want to set up with PostHog?\n (press space to toggle, enter to confirm)', + options: projects.map((p) => ({ + value: p.dir, + label: `${p.relativePath} → ${p.frameworkName}`, + hint: p.alreadyConfigured ? 'PostHog already installed' : undefined, + })), + initialValues: projects + .filter((p) => !p.alreadyConfigured) + .map((p) => p.dir), + required: false, + }), + ); + + return projects.filter((p) => selectedDirs.includes(p.dir)); +} + +/** + * Print a summary of all monorepo project setup results. + */ +function printMonorepoSummary(results: ProjectResult[]): void { + const succeeded = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + const summaryLines = results.map((r) => { + const name = r.project.relativePath + ? `${r.project.relativePath} (${r.project.frameworkName})` + : r.project.frameworkName; + if (r.success) { + return `${chalk.green('✓')} ${name}`; + } + return `${chalk.red('✗')} ${name} — ${r.error}`; + }); + + const summaryTitle = + failed.length === 0 + ? chalk.green('Monorepo setup complete!') + : chalk.yellow( + `Monorepo setup complete: ${succeeded.length} succeeded, ${failed.length} failed`, + ); + + clack.note(summaryLines.join('\n'), summaryTitle); + + if (failed.length > 0) { + clack.log.info( + `For failed projects, visit ${chalk.cyan( + 'https://posthog.com/docs', + )} for manual setup instructions.`, + ); + } + + clack.outro( + chalk.dim( + 'Note: This wizard uses an LLM agent to analyze and modify your project. Please review the changes made.', + ), + ); +} diff --git a/src/python/python-wizard-agent.ts b/src/python/python-wizard-agent.ts index a09a863b..fbec6b32 100644 --- a/src/python/python-wizard-agent.ts +++ b/src/python/python-wizard-agent.ts @@ -7,6 +7,10 @@ import { Integration } from '../lib/constants'; import fg from 'fast-glob'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { + PYTHON_DETECTION_IGNORES, + PYTHON_SOURCE_IGNORES, +} from '../lib/glob-patterns'; import { getPythonVersion, getPythonVersionBucket, @@ -53,7 +57,7 @@ export const PYTHON_AGENT_CONFIG: FrameworkConfig = { ], { cwd: installDir, - ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'], + ignore: PYTHON_DETECTION_IGNORES, }, ); @@ -65,7 +69,7 @@ export const PYTHON_AGENT_CONFIG: FrameworkConfig = { // Check for Django const managePyMatches = await fg('**/manage.py', { cwd: installDir, - ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'], + ignore: PYTHON_DETECTION_IGNORES, }); for (const match of managePyMatches) { @@ -107,13 +111,7 @@ export const PYTHON_AGENT_CONFIG: FrameworkConfig = { ['**/app.py', '**/wsgi.py', '**/application.py', '**/__init__.py'], { cwd: installDir, - ignore: [ - '**/venv/**', - '**/.venv/**', - '**/env/**', - '**/.env/**', - '**/__pycache__/**', - ], + ignore: PYTHON_SOURCE_IGNORES, }, ); diff --git a/src/rails/rails-wizard-agent.ts b/src/rails/rails-wizard-agent.ts index 17838476..906c3e1b 100644 --- a/src/rails/rails-wizard-agent.ts +++ b/src/rails/rails-wizard-agent.ts @@ -62,8 +62,6 @@ export const RAILS_AGENT_CONFIG: FrameworkConfig = { 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) @@ -78,12 +76,6 @@ export const RAILS_AGENT_CONFIG: FrameworkConfig = { 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; }, }, diff --git a/src/react-router/react-router-wizard-agent.ts b/src/react-router/react-router-wizard-agent.ts index c4846212..c6361f04 100644 --- a/src/react-router/react-router-wizard-agent.ts +++ b/src/react-router/react-router-wizard-agent.ts @@ -34,6 +34,7 @@ export const REACT_ROUTER_AGENT_CONFIG: FrameworkConfig = { detection: { packageName: 'react-router', + alternatePackageNames: ['@remix-run/react', '@remix-run/node'], packageDisplayName: 'React Router', getVersion: (packageJson: unknown) => getPackageVersion('react-router', packageJson as PackageDotJson), @@ -45,9 +46,12 @@ export const REACT_ROUTER_AGENT_CONFIG: FrameworkConfig = { }, detect: async (options) => { const packageJson = await tryGetPackageJson(options); - return packageJson - ? hasPackageInstalled('react-router', packageJson) - : false; + if (!packageJson) return false; + return ( + hasPackageInstalled('react-router', packageJson) || + hasPackageInstalled('@remix-run/react', packageJson) || + hasPackageInstalled('@remix-run/node', packageJson) + ); }, detectPackageManager: detectNodePackageManagers, }, diff --git a/src/react-router/utils.ts b/src/react-router/utils.ts index 5222b5c3..3871fde6 100644 --- a/src/react-router/utils.ts +++ b/src/react-router/utils.ts @@ -119,12 +119,19 @@ export async function getReactRouterMode( ): Promise { const { installDir } = options; - // First, get the React Router version + // First, get the React Router version and check Remix packages — Remix v2 = RR v7) const packageJson = await getPackageDotJson(options); + const remixVersion = getPackageVersion('@remix-run/react', packageJson); const reactRouterVersion = getPackageVersion('react-router-dom', packageJson) || getPackageVersion('react-router', packageJson); + // Remix v2 IS React Router v7 framework mode, skip version prompt + if (!reactRouterVersion && remixVersion) { + clack.log.info('Detected Remix app (React Router v7 - Framework mode)'); + return ReactRouterMode.V7_FRAMEWORK; + } + if (!reactRouterVersion) { // If we can't detect version, ask the user clack.log.info( diff --git a/src/ruby/ruby-wizard-agent.ts b/src/ruby/ruby-wizard-agent.ts index 94443078..b7105fed 100644 --- a/src/ruby/ruby-wizard-agent.ts +++ b/src/ruby/ruby-wizard-agent.ts @@ -63,8 +63,6 @@ export const RUBY_AGENT_CONFIG: FrameworkConfig = { 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) @@ -74,37 +72,6 @@ export const RUBY_AGENT_CONFIG: FrameworkConfig = { `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; diff --git a/src/run.ts b/src/run.ts index c54f05d1..3da0c087 100644 --- a/src/run.ts +++ b/src/run.ts @@ -9,9 +9,15 @@ import path from 'path'; import { FRAMEWORK_REGISTRY } from './lib/registry'; import { analytics } from './utils/analytics'; import { runAgentWizard } from './lib/agent-runner'; +import { DisplayedError } from './lib/errors'; import { EventEmitter } from 'events'; import chalk from 'chalk'; import { logToFile } from './utils/debug'; +import { hasPostHogInstalled } from './utils/posthog-detection'; +import { + detectWorkspaceProjects, + runMonorepoFlow, +} from './monorepo/monorepo-flow'; EventEmitter.defaultMaxListeners = 50; @@ -73,6 +79,21 @@ export async function runWizard(argv: Args) { clack.log.info(chalk.dim('Running in CI mode')); } + const posthogInstalled = await hasPostHogInstalled(resolvedInstallDir); + analytics.setTag('posthog_already_installed', posthogInstalled); + + // If user specified --integration or --menu, skip monorepo detection + if (!finalArgs.integration && !wizardOptions.menu) { + // Check for monorepo before single-framework detection + const workspaceResult = await detectWorkspaceProjects(wizardOptions); + + if (workspaceResult && workspaceResult.projects.length >= 2) { + await runMonorepoFlow(workspaceResult.projects, wizardOptions); + return; + } + } + + // Single-project flow const integration = finalArgs.integration ?? (await getIntegrationForSetup(wizardOptions)); @@ -100,16 +121,19 @@ export async function runWizard(argv: Args) { logToFile(`[Wizard run.ts] ERROR STACK: ${errorStack}`); } - clack.log.error( - `Something went wrong: ${chalk.red( - errorMessage, - )}\n\nYou can read the documentation at ${chalk.cyan( - config.metadata.docsUrl, - )} to set up PostHog manually.`, - ); + // DisplayedError means agent-runner already showed the error to the user + if (!(error instanceof DisplayedError)) { + clack.log.error( + `Something went wrong: ${chalk.red( + errorMessage, + )}\n\nYou can read the documentation at ${chalk.cyan( + config.metadata.docsUrl, + )} to set up PostHog manually.`, + ); - if (wizardOptions.debug && errorStack) { - clack.log.info(chalk.dim(errorStack)); + if (wizardOptions.debug && errorStack) { + clack.log.info(chalk.dim(errorStack)); + } } process.exit(1); @@ -118,8 +142,8 @@ export async function runWizard(argv: Args) { const DETECTION_TIMEOUT_MS = 5000; -async function detectIntegration( - options: Pick, +export async function detectIntegration( + options: Pick, ): Promise { for (const integration of Object.values(Integration)) { const config = FRAMEWORK_REGISTRY[integration]; diff --git a/src/swift/swift-wizard-agent.ts b/src/swift/swift-wizard-agent.ts index 2bf721eb..a87591cf 100644 --- a/src/swift/swift-wizard-agent.ts +++ b/src/swift/swift-wizard-agent.ts @@ -89,8 +89,6 @@ export const SWIFT_AGENT_CONFIG: FrameworkConfig = { prompts: { projectTypeDetection: 'This is a Swift project. Look for .xcodeproj directories, Package.swift, and .swift source files to confirm. Check for SwiftUI or UIKit imports to determine the UI framework.', - packageInstallation: - 'Add the posthog-ios package via Swift Package Manager. For Xcode projects, add XCRemoteSwiftPackageReference and XCSwiftPackageProductDependency to the .pbxproj file. For Swift packages, add the dependency to Package.swift.', getAdditionalContextLines: (context) => { const projectTypeName = context.projectType ? getSwiftProjectTypeName(context.projectType) diff --git a/src/utils/__tests__/app-detection.test.ts b/src/utils/__tests__/app-detection.test.ts new file mode 100644 index 00000000..b74e0419 --- /dev/null +++ b/src/utils/__tests__/app-detection.test.ts @@ -0,0 +1,345 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { isLikelyApp } from '../app-detection'; +import { Integration } from '../../lib/constants'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'app-detect-')); +}); + +afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); +}); + +async function createFile(relativePath: string, content = ''): Promise { + const fullPath = path.join(tmpDir, relativePath); + await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.promises.writeFile(fullPath, content, 'utf-8'); +} + +// --------------------------------------------------------------------------- +// Framework-specific integrations — always pass +// --------------------------------------------------------------------------- + +describe('isLikelyApp — framework integrations', () => { + it('always returns true for django', async () => { + expect(await isLikelyApp(tmpDir, Integration.django)).toBe(true); + }); + + it('always returns true for fastapi', async () => { + expect(await isLikelyApp(tmpDir, Integration.fastapi)).toBe(true); + }); + + it('always returns true for flask', async () => { + expect(await isLikelyApp(tmpDir, Integration.flask)).toBe(true); + }); + + it('always returns true for nextjs', async () => { + expect(await isLikelyApp(tmpDir, Integration.nextjs)).toBe(true); + }); + + it('always returns true for reactRouter', async () => { + expect(await isLikelyApp(tmpDir, Integration.reactRouter)).toBe(true); + }); + + it('always returns true for laravel', async () => { + expect(await isLikelyApp(tmpDir, Integration.laravel)).toBe(true); + }); + + it('always returns true for swift', async () => { + expect(await isLikelyApp(tmpDir, Integration.swift)).toBe(true); + }); + + it('always returns true for android', async () => { + expect(await isLikelyApp(tmpDir, Integration.android)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Generic Python — requires app evidence +// --------------------------------------------------------------------------- + +describe('isLikelyApp — generic python', () => { + it('returns true when main.py exists', async () => { + await createFile('main.py', 'print("hello")'); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when app.py exists', async () => { + await createFile('app.py', ''); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when wsgi.py exists', async () => { + await createFile('wsgi.py', ''); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when asgi.py exists', async () => { + await createFile('asgi.py', ''); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when server.py exists', async () => { + await createFile('server.py', ''); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when __main__.py exists', async () => { + await createFile('__main__.py', ''); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when cli.py exists', async () => { + await createFile('cli.py', ''); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when manage.py exists', async () => { + await createFile('manage.py', ''); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when pyproject.toml has [project.scripts]', async () => { + await createFile( + 'pyproject.toml', + '[project]\nname = "myapp"\n\n[project.scripts]\nmyapp = "myapp:main"', + ); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when pyproject.toml has [tool.poetry.scripts]', async () => { + await createFile( + 'pyproject.toml', + '[tool.poetry]\nname = "myapp"\n\n[tool.poetry.scripts]\nmyapp = "myapp:main"', + ); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns true when pyproject.toml has [project.gui-scripts]', async () => { + await createFile( + 'pyproject.toml', + '[project]\nname = "myapp"\n\n[project.gui-scripts]\nmyapp = "myapp:main"', + ); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(true); + }); + + it('returns false for library with no entry points', async () => { + await createFile( + 'pyproject.toml', + '[project]\nname = "parser-lib"\nversion = "1.0.0"\n\n[build-system]\nrequires = ["setuptools"]', + ); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(false); + }); + + it('returns false for empty directory', async () => { + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(false); + }); + + it('returns false for directory with only requirements.txt', async () => { + await createFile('requirements.txt', 'numpy\npandas\n'); + expect(await isLikelyApp(tmpDir, Integration.python)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Generic JavaScript — requires app evidence +// --------------------------------------------------------------------------- + +// Helper: generate a dependencies object with N entries +function fakeDeps(count: number): Record { + const deps: Record = {}; + for (let i = 0; i < count; i++) { + deps[`pkg-${i}`] = '1.0.0'; + } + return deps; +} + +describe('isLikelyApp — generic javascript_web', () => { + it('returns true when package.json has a start script with enough deps', async () => { + await createFile( + 'package.json', + JSON.stringify({ + scripts: { start: 'vite preview', build: 'vite build' }, + dependencies: fakeDeps(10), + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(true); + }); + + it('returns true when package.json has a dev script with enough deps', async () => { + await createFile( + 'package.json', + JSON.stringify({ + scripts: { dev: 'vite', build: 'vite build' }, + dependencies: fakeDeps(8), + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(true); + }); + + it('returns true when package.json has a serve script with enough deps', async () => { + await createFile( + 'package.json', + JSON.stringify({ + scripts: { serve: 'serve dist' }, + dependencies: fakeDeps(8), + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(true); + }); + + it('returns true when index.html exists at root', async () => { + await createFile('index.html', ''); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(true); + }); + + it('returns true when public/index.html exists', async () => { + await createFile('public/index.html', ''); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(true); + }); + + it('returns true when src/index.html exists', async () => { + await createFile('src/index.html', ''); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(true); + }); + + it('returns false for start script with too few deps (build tool)', async () => { + await createFile( + 'package.json', + JSON.stringify({ + name: '@posthog/tailwind', + scripts: { start: 'tailwindcss --watch' }, + dependencies: fakeDeps(2), + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(false); + }); + + it('returns false for library with only build script', async () => { + await createFile( + 'package.json', + JSON.stringify({ + name: '@posthog/utils', + scripts: { build: 'tsc', test: 'jest', lint: 'eslint .' }, + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(false); + }); + + it('returns false for empty directory', async () => { + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(false); + }); + + it('counts devDependencies towards threshold', async () => { + await createFile( + 'package.json', + JSON.stringify({ + scripts: { dev: 'wrangler dev' }, + dependencies: fakeDeps(3), + devDependencies: fakeDeps(5), + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(true); + }); + + it('returns false for Storybook dev environment despite enough deps', async () => { + await createFile( + 'package.json', + JSON.stringify({ + scripts: { start: 'storybook dev -p 6006' }, + dependencies: { + '@storybook/react': '^7.0.0', + '@storybook/addon-essentials': '^7.0.0', + ...fakeDeps(20), + }, + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(false); + }); + + it('returns false for Playwright test package despite enough deps', async () => { + await createFile( + 'package.json', + JSON.stringify({ + scripts: { start: 'playwright test' }, + dependencies: { '@playwright/test': '^1.40.0', ...fakeDeps(10) }, + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(false); + }); + + it('returns false for Cypress test package despite enough deps', async () => { + await createFile( + 'package.json', + JSON.stringify({ + scripts: { start: 'cypress open' }, + dependencies: { cypress: '^13.0.0', ...fakeDeps(10) }, + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascript_web)).toBe(false); + }); +}); + +describe('isLikelyApp — generic javascriptNode', () => { + it('returns true when package.json has a start script with enough deps', async () => { + await createFile( + 'package.json', + JSON.stringify({ + scripts: { start: 'node server.js' }, + dependencies: fakeDeps(10), + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascriptNode)).toBe(true); + }); + + it('returns true when server.ts exists', async () => { + await createFile('server.ts', 'import express from "express"'); + expect(await isLikelyApp(tmpDir, Integration.javascriptNode)).toBe(true); + }); + + it('returns true when app.js exists', async () => { + await createFile('app.js', 'const app = express()'); + expect(await isLikelyApp(tmpDir, Integration.javascriptNode)).toBe(true); + }); + + it('returns true when src/server.js exists', async () => { + await createFile('src/server.js', ''); + expect(await isLikelyApp(tmpDir, Integration.javascriptNode)).toBe(true); + }); + + it('returns true when src/app.ts exists', async () => { + await createFile('src/app.ts', ''); + expect(await isLikelyApp(tmpDir, Integration.javascriptNode)).toBe(true); + }); + + it('returns false for start script with too few deps (CLI tool)', async () => { + await createFile( + 'package.json', + JSON.stringify({ + name: '@posthog/plugin-transpiler', + scripts: { start: 'npm run build && npm run start:dist' }, + dependencies: fakeDeps(5), + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascriptNode)).toBe(false); + }); + + it('returns false for library with only build script', async () => { + await createFile( + 'package.json', + JSON.stringify({ + name: '@posthog/plugin-scaffold', + scripts: { build: 'tsc', test: 'jest' }, + }), + ); + expect(await isLikelyApp(tmpDir, Integration.javascriptNode)).toBe(false); + }); + + it('returns false for empty directory', async () => { + expect(await isLikelyApp(tmpDir, Integration.javascriptNode)).toBe(false); + }); +}); diff --git a/src/utils/__tests__/workspace-detection.test.ts b/src/utils/__tests__/workspace-detection.test.ts new file mode 100644 index 00000000..a0fe287e --- /dev/null +++ b/src/utils/__tests__/workspace-detection.test.ts @@ -0,0 +1,535 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { + detectWorkspaces, + parsePnpmWorkspaceYaml, +} from '../workspace-detection'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'workspace-detect-'), + ); +}); + +afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); +}); + +/** Helper: create a file with optional content */ +async function createFile(relativePath: string, content = ''): Promise { + const fullPath = path.join(tmpDir, relativePath); + await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.promises.writeFile(fullPath, content, 'utf-8'); +} + +// --------------------------------------------------------------------------- +// parsePnpmWorkspaceYaml (pure function, no filesystem) +// --------------------------------------------------------------------------- + +describe('parsePnpmWorkspaceYaml', () => { + it('parses standard packages list', () => { + const yaml = `packages: + - 'apps/*' + - 'packages/*' +`; + expect(parsePnpmWorkspaceYaml(yaml)).toEqual(['apps/*', 'packages/*']); + }); + + it('handles double-quoted patterns', () => { + const yaml = `packages: + - "apps/*" + - "libs/*" +`; + expect(parsePnpmWorkspaceYaml(yaml)).toEqual(['apps/*', 'libs/*']); + }); + + it('handles unquoted patterns', () => { + const yaml = `packages: + - apps/* + - libs/* +`; + expect(parsePnpmWorkspaceYaml(yaml)).toEqual(['apps/*', 'libs/*']); + }); + + it('skips negated patterns', () => { + const yaml = `packages: + - 'apps/*' + - '!apps/internal' +`; + expect(parsePnpmWorkspaceYaml(yaml)).toEqual(['apps/*']); + }); + + it('stops at next top-level key', () => { + const yaml = `packages: + - 'apps/*' +other: + - foo +`; + expect(parsePnpmWorkspaceYaml(yaml)).toEqual(['apps/*']); + }); + + it('returns empty for no packages key', () => { + const yaml = `something: + - value +`; + expect(parsePnpmWorkspaceYaml(yaml)).toEqual([]); + }); + + it('skips comments and empty lines', () => { + const yaml = `packages: + # A comment + - 'apps/*' + + - 'packages/*' +`; + expect(parsePnpmWorkspaceYaml(yaml)).toEqual(['apps/*', 'packages/*']); + }); +}); + +// --------------------------------------------------------------------------- +// detectWorkspaces — pnpm +// --------------------------------------------------------------------------- + +describe('detectWorkspaces — pnpm', () => { + it('detects pnpm workspace with pnpm-workspace.yaml', async () => { + await createFile( + 'pnpm-workspace.yaml', + `packages:\n - 'apps/*'\n - 'packages/*'\n`, + ); + await createFile('apps/web/package.json', '{}'); + await createFile('apps/api/package.json', '{}'); + await createFile('packages/shared/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('pnpm'); + expect(result!.memberDirs).toHaveLength(3); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'apps/web')); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'apps/api')); + expect(result!.memberDirs).toContain( + path.resolve(tmpDir, 'packages/shared'), + ); + }); + + it('labels as turbo when turbo.json is present', async () => { + await createFile('pnpm-workspace.yaml', `packages:\n - 'apps/*'\n`); + await createFile('turbo.json', '{}'); + await createFile('apps/web/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('turbo'); + }); +}); + +// --------------------------------------------------------------------------- +// detectWorkspaces — npm/yarn via package.json workspaces +// --------------------------------------------------------------------------- + +describe('detectWorkspaces — npm/yarn', () => { + it('detects npm workspaces from package.json array', async () => { + await createFile( + 'package.json', + JSON.stringify({ workspaces: ['apps/*', 'libs/*'] }), + ); + await createFile('apps/frontend/package.json', '{}'); + await createFile('libs/utils/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('npm'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('detects yarn classic workspaces from packages object', async () => { + await createFile( + 'package.json', + JSON.stringify({ workspaces: { packages: ['packages/*'] } }), + ); + await createFile('yarn.lock', ''); + await createFile('packages/a/package.json', '{}'); + await createFile('packages/b/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('yarn'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('labels as turbo when turbo.json + package.json workspaces', async () => { + await createFile( + 'package.json', + JSON.stringify({ workspaces: ['apps/*'] }), + ); + await createFile('turbo.json', '{}'); + await createFile('apps/web/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('turbo'); + }); +}); + +// --------------------------------------------------------------------------- +// detectWorkspaces — lerna +// --------------------------------------------------------------------------- + +describe('detectWorkspaces — lerna', () => { + it('detects lerna workspace from lerna.json', async () => { + await createFile( + 'lerna.json', + JSON.stringify({ packages: ['packages/*'] }), + ); + await createFile('packages/core/package.json', '{}'); + await createFile('packages/cli/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('lerna'); + expect(result!.memberDirs).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// detectWorkspaces — nx +// --------------------------------------------------------------------------- + +describe('detectWorkspaces — nx', () => { + it('detects nx workspace via project.json files', async () => { + await createFile('nx.json', '{}'); + await createFile('apps/web/project.json', '{}'); + await createFile('libs/shared/project.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('nx'); + expect(result!.memberDirs).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// detectWorkspaces — heuristic +// --------------------------------------------------------------------------- + +describe('detectWorkspaces — heuristic', () => { + it('detects monorepo from multiple package.json files', async () => { + // No formal workspace config, just multiple subdirs with package.json + await createFile('frontend/package.json', '{}'); + await createFile('backend/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('detects mixed JS + Python project roots', async () => { + await createFile('web/package.json', '{}'); + await createFile('api/requirements.txt', 'posthog\n'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('detects mixed JS + Django project roots', async () => { + await createFile('frontend/package.json', '{}'); + await createFile('backend/manage.py', '#!/usr/bin/env python\n'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('detects mixed JS + Laravel project roots', async () => { + await createFile('web/package.json', '{}'); + await createFile('api/composer.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('detects Android build.gradle projects', async () => { + await createFile('web/package.json', '{}'); + await createFile('android/build.gradle', ''); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('detects Android build.gradle.kts projects', async () => { + await createFile('web/package.json', '{}'); + await createFile('android/build.gradle.kts', ''); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('detects Swift Package.swift projects', async () => { + await createFile('web/package.json', '{}'); + await createFile('ios/Package.swift', ''); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('detects pyproject.toml projects', async () => { + await createFile('web/package.json', '{}'); + await createFile('ml/pyproject.toml', ''); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('deduplicates directories with multiple indicators', async () => { + // A single dir with both package.json and pyproject.toml should count once + await createFile('app-a/package.json', '{}'); + await createFile('app-b/package.json', '{}'); + await createFile('app-b/pyproject.toml', ''); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.type).toBe('heuristic'); + expect(result!.memberDirs).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// detectWorkspaces — not a monorepo +// --------------------------------------------------------------------------- + +describe('detectWorkspaces — single project (not a monorepo)', () => { + it('returns null for a single package.json project', async () => { + await createFile('package.json', '{}'); + await createFile('src/index.ts', ''); + + const result = await detectWorkspaces(tmpDir); + + expect(result).toBeNull(); + }); + + it('returns null for an empty directory', async () => { + const result = await detectWorkspaces(tmpDir); + + expect(result).toBeNull(); + }); + + it('returns null for single subdir project', async () => { + await createFile('app/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + // Only 1 candidate dir — below the 2-dir threshold + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Exclusion patterns +// --------------------------------------------------------------------------- + +describe('detectWorkspaces — exclusions', () => { + it('ignores node_modules directories', async () => { + await createFile('app/package.json', '{}'); + await createFile('node_modules/some-pkg/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + // Only 1 real candidate (node_modules excluded) — should not trigger monorepo + expect(result).toBeNull(); + }); + + it('ignores dist directories', async () => { + await createFile('app/package.json', '{}'); + await createFile('dist/server/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// detectWorkspaces — polyglot supplement +// --------------------------------------------------------------------------- + +describe('detectWorkspaces — polyglot supplement', () => { + it('discovers Python service outside pnpm workspace', async () => { + await createFile('pnpm-workspace.yaml', `packages:\n - 'apps/*'\n`); + await createFile('apps/web/package.json', '{}'); + // Python service NOT in pnpm-workspace.yaml + await createFile( + 'services/ml/pyproject.toml', + '[project]\nname = "ml-service"', + ); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.memberDirs).toHaveLength(2); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'apps/web')); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'services/ml')); + }); + + it('does not duplicate project already in pnpm workspace', async () => { + await createFile( + 'pnpm-workspace.yaml', + `packages:\n - 'apps/*'\n - common/parser\n`, + ); + await createFile('apps/web/package.json', '{}'); + await createFile('common/parser/package.json', '{}'); + await createFile( + 'common/parser/pyproject.toml', + '[project]\nname = "parser"', + ); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.memberDirs).toHaveLength(2); + }); + + it('does not add root as supplemental when root is a formal member', async () => { + await createFile( + 'pnpm-workspace.yaml', + `packages:\n - '.'\n - 'apps/*'\n`, + ); + await createFile('manage.py', '#!/usr/bin/env python\nimport django'); + await createFile('pyproject.toml', '[project]\nname = "myapp"'); + await createFile('package.json', '{}'); + await createFile('apps/web/package.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + const rootOccurrences = result!.memberDirs.filter( + (d) => path.resolve(d) === path.resolve(tmpDir), + ); + expect(rootOccurrences).toHaveLength(1); + }); + + it('discovers non-JS project even when root is a workspace member via dot', async () => { + // PostHog-like scenario: root '.' is a pnpm member (Django), and a + // Python service lives outside the workspace config + await createFile( + 'pnpm-workspace.yaml', + `packages:\n - '.'\n - 'apps/*'\n`, + ); + await createFile('package.json', '{}'); + await createFile('manage.py', '#!/usr/bin/env python\nimport django'); + await createFile('apps/web/package.json', '{}'); + await createFile( + 'services/api/pyproject.toml', + '[project]\nname = "api"\ndependencies = ["fastapi"]', + ); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + // root (via '.') + apps/web + services/api (supplemental) + expect(result!.memberDirs).toHaveLength(3); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'services/api')); + }); + + it('does not add subdirectory of existing member', async () => { + await createFile('pnpm-workspace.yaml', `packages:\n - 'apps/*'\n`); + await createFile('apps/backend/package.json', '{}'); + // Nested Python config inside an existing JS member + await createFile( + 'apps/backend/scripts/pyproject.toml', + '[project]\nname = "scripts"', + ); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.memberDirs).toHaveLength(1); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'apps/backend')); + }); + + it('discovers PHP project alongside JS workspace', async () => { + await createFile('pnpm-workspace.yaml', `packages:\n - 'frontend/*'\n`); + await createFile('frontend/web/package.json', '{}'); + await createFile('api/composer.json', '{}'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.memberDirs).toHaveLength(2); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'api')); + }); + + it('discovers Android project alongside JS workspace', async () => { + await createFile('pnpm-workspace.yaml', `packages:\n - 'packages/*'\n`); + await createFile('packages/shared/package.json', '{}'); + await createFile('android/build.gradle.kts', ''); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.memberDirs).toHaveLength(2); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'android')); + }); + + it('supplements npm workspaces too, not just pnpm', async () => { + await createFile( + 'package.json', + JSON.stringify({ workspaces: ['apps/*'] }), + ); + await createFile('apps/web/package.json', '{}'); + await createFile('services/api/requirements.txt', 'fastapi\n'); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.memberDirs).toHaveLength(2); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'services/api')); + }); + + it('discovers multiple non-JS projects at depth 1 and 2', async () => { + await createFile('pnpm-workspace.yaml', `packages:\n - 'apps/*'\n`); + await createFile('apps/web/package.json', '{}'); + await createFile('backend/requirements.txt', 'django\n'); + await createFile('services/ml/pyproject.toml', '[project]\nname = "ml"'); + await createFile('mobile/build.gradle.kts', ''); + + const result = await detectWorkspaces(tmpDir); + + expect(result).not.toBeNull(); + expect(result!.memberDirs).toHaveLength(4); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'backend')); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'services/ml')); + expect(result!.memberDirs).toContain(path.resolve(tmpDir, 'mobile')); + }); +}); diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index a2835eea..5302073e 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -41,6 +41,15 @@ export class Analytics { this.tags[key] = value; } + /** Remove all tags whose keys start with `prefix`. */ + clearTagsWithPrefix(prefix: string) { + for (const key of Object.keys(this.tags)) { + if (key.startsWith(prefix)) { + delete this.tags[key]; + } + } + } + captureException(error: Error, properties: Record = {}) { this.client.captureException(error, this.distinctId ?? this.anonymousId, { team: ANALYTICS_TEAM_TAG, diff --git a/src/utils/app-detection.ts b/src/utils/app-detection.ts new file mode 100644 index 00000000..c932b4d3 --- /dev/null +++ b/src/utils/app-detection.ts @@ -0,0 +1,167 @@ +import fs from 'fs'; +import path from 'path'; +import { Integration } from '../lib/constants'; + +/** Generic language integrations that need extra app-vs-library filtering. */ +const LANGUAGE_FALLBACK_INTEGRATIONS = new Set([ + Integration.python, + Integration.javascript_web, + Integration.javascriptNode, +]); + +/** Check whether a project is likely an app (vs a library). Framework-specific integrations always pass. */ +export async function isLikelyApp( + dir: string, + integration: Integration, +): Promise { + if (!LANGUAGE_FALLBACK_INTEGRATIONS.has(integration)) { + return true; + } + + if (integration === Integration.python) { + return isLikelyPythonApp(dir); + } + + if ( + integration === Integration.javascript_web || + integration === Integration.javascriptNode + ) { + return isLikelyJsApp(dir); + } + + return true; +} + +const PYTHON_APP_ENTRY_POINTS = [ + 'main.py', + 'app.py', + 'wsgi.py', + 'asgi.py', + 'server.py', + '__main__.py', + 'cli.py', + 'manage.py', +]; + +async function isLikelyPythonApp(dir: string): Promise { + // Check for common app entry point files + for (const entryPoint of PYTHON_APP_ENTRY_POINTS) { + try { + await fs.promises.access(path.join(dir, entryPoint)); + return true; + } catch { + // continue + } + } + + // Check pyproject.toml for script entry points + try { + const content = await fs.promises.readFile( + path.join(dir, 'pyproject.toml'), + 'utf-8', + ); + if ( + content.includes('[project.scripts]') || + content.includes('[tool.poetry.scripts]') || + content.includes('[project.gui-scripts]') + ) { + return true; + } + } catch { + // File doesn't exist or can't be read + } + + return false; +} + +/** Min dependency count for a JS package to qualify as an app by scripts alone. */ +const MIN_JS_APP_DEPENDENCY_COUNT = 8; + +/** Dependency prefixes that indicate a dev tool (Storybook, Playwright, etc.). */ +const DEV_TOOL_DEPENDENCY_PREFIXES = [ + '@storybook/', + 'chromatic', + '@playwright/', + 'playwright', + '@cypress/', + 'cypress', +]; + +function hasDevToolDependencies(pkg: Record): boolean { + const allDeps = [ + ...Object.keys((pkg.dependencies as Record) ?? {}), + ...Object.keys((pkg.devDependencies as Record) ?? {}), + ]; + return DEV_TOOL_DEPENDENCY_PREFIXES.some((prefix) => + allDeps.some((dep) => dep === prefix || dep.startsWith(prefix)), + ); +} + +/** Check whether a JS/TS directory looks like an app rather than a library or utility. */ +async function isLikelyJsApp(dir: string): Promise { + // Check for index.html (web app entry point) — strong signal + const htmlPaths = ['index.html', 'public/index.html', 'src/index.html']; + for (const htmlPath of htmlPaths) { + try { + await fs.promises.access(path.join(dir, htmlPath)); + return true; + } catch { + // continue + } + } + + // Check for common server entry points — strong signal + const serverEntryPoints = [ + 'server.ts', + 'server.js', + 'app.ts', + 'app.js', + 'src/server.ts', + 'src/server.js', + 'src/app.ts', + 'src/app.js', + ]; + for (const entryPoint of serverEntryPoints) { + try { + await fs.promises.access(path.join(dir, entryPoint)); + return true; + } catch { + // continue + } + } + + // Check package.json for app-like scripts — weaker signal. + // In monorepos, many utility packages have start/dev scripts for build + // watchers. Require a minimum dependency count to filter out tiny tools. + try { + const content = await fs.promises.readFile( + path.join(dir, 'package.json'), + 'utf-8', + ); + const pkg = JSON.parse(content); + + // Filter out dev tool environments (Storybook, Playwright, Cypress, etc.) + if (hasDevToolDependencies(pkg)) { + return false; + } + + // Filter out library packages "exports", "main", or "module" fields + // indicate a package that publishes code for consumption, not a runnable app. + if (pkg.exports || pkg.main || pkg.module) { + return false; + } + + const scripts = pkg.scripts ?? {}; + const appScripts = ['start', 'dev', 'serve', 'preview']; + if (appScripts.some((s) => s in scripts)) { + const depCount = + Object.keys(pkg.dependencies ?? {}).length + + Object.keys(pkg.devDependencies ?? {}).length; + return depCount >= MIN_JS_APP_DEPENDENCY_COUNT; + } + } catch { + // No package.json or invalid JSON + } + + return false; +} diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index 67898252..05ba4be9 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -424,9 +424,15 @@ export async function ensurePackageIsInstalled( packageJson: PackageDotJson, packageId: string, packageName: string, + alternatePackageIds?: string[], ): Promise { return traceStep('ensure-package-installed', async () => { - const installed = hasPackageInstalled(packageId, packageJson); + const installed = + hasPackageInstalled(packageId, packageJson) || + (alternatePackageIds?.some((id) => + hasPackageInstalled(id, packageJson), + ) ?? + false); analytics.setTag(`${packageName.toLowerCase()}-installed`, installed); @@ -481,13 +487,45 @@ export async function getPackageDotJson({ */ export async function tryGetPackageJson({ installDir, -}: Pick): Promise { + workspaceRootDir, +}: Pick< + WizardOptions, + 'installDir' | 'workspaceRootDir' +>): Promise { try { const packageJsonFileContents = await fs.promises.readFile( join(installDir, 'package.json'), 'utf8', ); - return JSON.parse(packageJsonFileContents) as PackageDotJson; + const localPkg = JSON.parse(packageJsonFileContents) as PackageDotJson; + + // In Nx monorepos, all deps are hoisted to the root package.json. + // Per-project package.json files are stubs with zero deps. When a + // workspace root is available and the local file has no deps, merge + // the root's dependencies so framework detectors can match. + if (workspaceRootDir && workspaceRootDir !== installDir) { + const localDepCount = + Object.keys(localPkg.dependencies ?? {}).length + + Object.keys(localPkg.devDependencies ?? {}).length; + if (localDepCount === 0) { + try { + const rootRaw = await fs.promises.readFile( + join(workspaceRootDir, 'package.json'), + 'utf8', + ); + const rootPkg = JSON.parse(rootRaw) as PackageDotJson; + return { + ...localPkg, + dependencies: { ...rootPkg.dependencies }, + devDependencies: { ...rootPkg.devDependencies }, + }; + } catch { + // Root package.json missing or invalid — fall through + } + } + } + + return localPkg; } catch { return null; } diff --git a/src/utils/clack.ts b/src/utils/clack.ts index 7f117440..0a55e66a 100644 --- a/src/utils/clack.ts +++ b/src/utils/clack.ts @@ -1,4 +1,93 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; // @ts-ignore - clack is ESM and TS complains about that. It works though -import clack from '@clack/prompts'; +import realClack from '@clack/prompts'; + +/** When active, output functions become no-ops and interactive prompts throw. */ +const silentStore = new AsyncLocalStorage<{ silent: true }>(); + +/** Output-only methods on clack.log that should be silenced */ +const SILENT_LOG_METHODS = new Set([ + 'info', + 'warn', + 'error', + 'success', + 'step', + 'message', +]); + +/** Interactive prompts that must not run in silent mode */ +const INTERACTIVE_METHODS = new Set([ + 'select', + 'multiselect', + 'confirm', + 'text', + 'password', + 'groupMultiselect', +]); + +/** Top-level output methods that should be silenced */ +const SILENT_TOP_LEVEL = new Set(['intro', 'outro', 'note']); + +/** No-op spinner returned in silent mode */ +function createNoopSpinner() { + return { + start: () => { + /* noop */ + }, + stop: () => { + /* noop */ + }, + message: () => { + /* noop */ + }, + }; +} + +/** Proxy that no-ops output and throws on prompts when silent mode is active. */ +const clack: typeof realClack = new Proxy(realClack, { + get(target, prop, receiver) { + const isSilent = !!silentStore.getStore(); + + if (prop === 'log' && isSilent) { + // Return a proxy for log.* that no-ops output methods + return new Proxy(target.log, { + get(logTarget, logProp) { + if (typeof logProp === 'string' && SILENT_LOG_METHODS.has(logProp)) { + return () => { + /* noop */ + }; + } + return Reflect.get(logTarget, logProp); + }, + }); + } + + if (prop === 'spinner' && isSilent) { + return () => createNoopSpinner(); + } + + if (typeof prop === 'string' && SILENT_TOP_LEVEL.has(prop) && isSilent) { + return () => { + /* noop */ + }; + } + + if (typeof prop === 'string' && INTERACTIVE_METHODS.has(prop) && isSilent) { + return () => { + throw new Error( + `Interactive prompt clack.${prop}() called in silent/concurrent mode. ` + + `This is a bug — interactive prompts must run in the sequential pre-flight phase.`, + ); + }; + } + + return Reflect.get(target, prop, receiver); + }, +}); + +/** Run `fn` with clack output silenced. */ +export function withSilentOutput(fn: () => Promise): Promise { + return silentStore.run({ silent: true }, fn); +} export default clack; diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 14435ed7..11e77caf 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import chalk from 'chalk'; import { appendFileSync } from 'fs'; import { prepareMessage } from './logging'; @@ -7,14 +8,15 @@ let debugEnabled = false; let logFilePath = '/tmp/posthog-wizard.log'; let logEnabled = true; +/** Per-project log file scoping via withLogFile(). */ +const logFileStore = new AsyncLocalStorage<{ logFilePath: string }>(); + +/** Get the effective log file path (scoped or global). */ export function getLogFilePath(): string { - return logFilePath; + return logFileStore.getStore()?.logFilePath ?? logFilePath; } -/** - * Configure the log file path and enable/disable state. - * Call before initLogFile() to override defaults. - */ +/** Configure the log file path and enable/disable state. */ export function configureLogFile(opts: { path?: string; enabled?: boolean; @@ -23,39 +25,38 @@ export function configureLogFile(opts: { if (opts.enabled !== undefined) logEnabled = opts.enabled; } -/** - * Initialize the log file with a run header. - * Call this at the start of each wizard run. - * Fails silently to avoid crashing the wizard. - */ +/** Write a run header to the log file. Fails silently. */ export function initLogFile() { if (!logEnabled) return; try { + const effectivePath = getLogFilePath(); const header = `\n${'='.repeat( 60, )}\nPostHog Wizard Run: ${new Date().toISOString()}\n${'='.repeat(60)}\n`; - appendFileSync(logFilePath, header); + appendFileSync(effectivePath, header); } catch { // Silently ignore - logging is non-critical } } -/** - * Log a message to the log file. - * Always writes regardless of debug flag (when logging is enabled). - * Fails silently to avoid masking errors in catch blocks. - */ +/** Append a message to the log file. Fails silently. */ export function logToFile(...args: unknown[]) { if (!logEnabled) return; try { + const effectivePath = getLogFilePath(); const timestamp = new Date().toISOString(); const msg = args.map((a) => prepareMessage(a)).join(' '); - appendFileSync(logFilePath, `[${timestamp}] ${msg}\n`); + appendFileSync(effectivePath, `[${timestamp}] ${msg}\n`); } catch { // Silently ignore logging failures to avoid masking original errors } } +/** Run `fn` with a scoped log file path. */ +export function withLogFile(path: string, fn: () => Promise): Promise { + return logFileStore.run({ logFilePath: path }, fn); +} + export function debug(...args: unknown[]) { if (!debugEnabled) { return; diff --git a/src/utils/js-detection.ts b/src/utils/js-detection.ts new file mode 100644 index 00000000..ea80a27d --- /dev/null +++ b/src/utils/js-detection.ts @@ -0,0 +1,33 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { PackageDotJson } from './package-json'; + +const LOCKFILES = [ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'bun.lockb', + 'bun.lock', +]; + +/** Check whether a JS project has a lockfile or real dependencies. */ +export function hasLockfileOrDeps( + installDir: string, + packageJson: PackageDotJson, +): boolean { + const hasLockfile = LOCKFILES.some((lockfile) => + fs.existsSync(path.join(installDir, lockfile)), + ); + + if (hasLockfile) { + return true; + } + + const hasDeps = + (packageJson.dependencies && + Object.keys(packageJson.dependencies).length > 0) || + (packageJson.devDependencies && + Object.keys(packageJson.devDependencies).length > 0); + + return !!hasDeps; +} diff --git a/src/utils/posthog-detection.ts b/src/utils/posthog-detection.ts new file mode 100644 index 00000000..6cfb811f --- /dev/null +++ b/src/utils/posthog-detection.ts @@ -0,0 +1,81 @@ +import fs from 'fs'; +import path from 'path'; +import { hasPackageInstalled } from './package-json'; +import { tryGetPackageJson } from './clack-utils'; + +/** PostHog package names to check in Python dependency files. */ +const PYTHON_POSTHOG_PATTERN = /^posthog([<>=~!\s[]|$)/im; + +/** Check if a PostHog SDK is installed in a project directory. */ +export async function hasPostHogInstalled(dir: string): Promise { + // Check JS/TS projects via package.json + const packageJson = await tryGetPackageJson({ installDir: dir }); + if (packageJson) { + const posthogPackages = [ + 'posthog-js', + 'posthog-node', + 'posthog-react-native', + ]; + for (const pkg of posthogPackages) { + if (hasPackageInstalled(pkg, packageJson)) { + return true; + } + } + } + + // Check Python projects via requirements*.txt and pyproject.toml + if ( + (await fileMatchesPattern( + path.join(dir, 'requirements.txt'), + PYTHON_POSTHOG_PATTERN, + )) || + (await fileMatchesPattern( + path.join(dir, 'requirements-dev.txt'), + PYTHON_POSTHOG_PATTERN, + )) || + (await fileMatchesPattern( + path.join(dir, 'pyproject.toml'), + PYTHON_POSTHOG_PATTERN, + )) + ) { + return true; + } + + // Check PHP/Laravel projects via composer.json + if (await composerHasPosthog(path.join(dir, 'composer.json'))) { + return true; + } + + return false; +} + +async function fileMatchesPattern( + filePath: string, + pattern: RegExp, +): Promise { + try { + const content = await fs.promises.readFile(filePath, 'utf-8'); + return pattern.test(content); + } catch { + return false; + } +} + +async function composerHasPosthog(filePath: string): Promise { + try { + const content = await fs.promises.readFile(filePath, 'utf-8'); + const json = JSON.parse(content) as { + require?: Record; + 'require-dev'?: Record; + }; + const allDeps = { + ...(json.require ?? {}), + ...(json['require-dev'] ?? {}), + }; + return Object.keys(allDeps).some( + (dep) => dep === 'posthog/posthog-php' || dep.startsWith('posthog/'), + ); + } catch { + return false; + } +} diff --git a/src/utils/types.ts b/src/utils/types.ts index 7f4601ed..349a5820 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -62,6 +62,12 @@ export type WizardOptions = { */ menu: boolean; + /** + * Root directory of the monorepo workspace. When set, detection merges + * root package.json deps for projects with hoisted dependencies (e.g. Nx). + */ + workspaceRootDir?: string; + /** * Whether to run in benchmark mode with per-phase token tracking. * When enabled, the wizard runs each workflow phase as a separate agent call diff --git a/src/utils/workspace-detection.ts b/src/utils/workspace-detection.ts new file mode 100644 index 00000000..ba773eed --- /dev/null +++ b/src/utils/workspace-detection.ts @@ -0,0 +1,427 @@ +import fs from 'fs'; +import path from 'path'; +import fg from 'fast-glob'; + +export type WorkspaceType = + | 'pnpm' + | 'yarn' + | 'npm' + | 'nx' + | 'turbo' + | 'lerna' + | 'heuristic'; + +export type WorkspaceInfo = { + type: WorkspaceType; + rootDir: string; + memberDirs: string[]; +}; + +const IGNORE_DIRS = [ + 'node_modules', + 'dist', + 'build', + '.venv', + 'venv', + 'env', + '__pycache__', + '.git', + '.next', + '.nuxt', + '.output', + 'vendor', + 'Pods', + 'DerivedData', + '.build', + '.gradle', +]; + +const IGNORE_PATTERNS = IGNORE_DIRS.map((d) => `**/${d}/**`); + +/** Non-JS project indicator file globs at depth 1-2. */ +const POLYGLOT_FILE_INDICATORS = [ + // Python (Django, Flask, FastAPI, generic) + '*/pyproject.toml', + '*/*/pyproject.toml', + '*/manage.py', + '*/*/manage.py', + '*/requirements.txt', + '*/*/requirements.txt', + // PHP/Laravel + '*/composer.json', + '*/*/composer.json', + '*/artisan', + '*/*/artisan', + // Android + '*/build.gradle', + '*/*/build.gradle', + '*/build.gradle.kts', + '*/*/build.gradle.kts', + // Swift/iOS + '*/Package.swift', + '*/*/Package.swift', +]; + +/** Xcode project directory globs at depth 1-2. */ +const XCODEPROJ_GLOBS = ['*/*.xcodeproj', '*/*/*.xcodeproj']; + +/** Detect workspace root and resolve member directories. Returns null if not a monorepo. */ +export async function detectWorkspaces( + rootDir: string, +): Promise { + // Try formal workspace detection first + const formal = + (await detectPnpmWorkspace(rootDir)) ?? + (await detectNpmOrYarnWorkspace(rootDir)) ?? + (await detectLernaWorkspace(rootDir)) ?? + (await detectNxWorkspace(rootDir)); + + if (formal) { + // Supplement with non-JS project roots that live outside the formal workspace + return supplementWithPolyglotProjects(formal); + } + + // Fall back to heuristic scan + return detectHeuristic(rootDir); +} + +/** + * Detect pnpm workspaces from pnpm-workspace.yaml + */ +async function detectPnpmWorkspace( + rootDir: string, +): Promise { + const workspaceFile = path.join(rootDir, 'pnpm-workspace.yaml'); + + let content: string; + try { + content = await fs.promises.readFile(workspaceFile, 'utf-8'); + } catch { + return null; + } + + const patterns = parsePnpmWorkspaceYaml(content); + if (patterns.length === 0) { + return null; + } + + // Check if turbo.json exists — if so, label as turbo + const type: WorkspaceType = (await fileExists( + path.join(rootDir, 'turbo.json'), + )) + ? 'turbo' + : 'pnpm'; + + const memberDirs = await resolveGlobPatterns(rootDir, patterns); + if (memberDirs.length === 0) { + return null; + } + + return { type, rootDir, memberDirs }; +} + +/** + * Detect npm/yarn workspaces from package.json "workspaces" field + */ +async function detectNpmOrYarnWorkspace( + rootDir: string, +): Promise { + const packageJsonPath = path.join(rootDir, 'package.json'); + + let content: string; + try { + content = await fs.promises.readFile(packageJsonPath, 'utf-8'); + } catch { + return null; + } + + let packageJson: { workspaces?: string[] | { packages?: string[] } }; + try { + packageJson = JSON.parse(content); + } catch { + return null; + } + + // workspaces can be an array or an object with a "packages" field (yarn classic) + let patterns: string[]; + if (Array.isArray(packageJson.workspaces)) { + patterns = packageJson.workspaces; + } else if (Array.isArray(packageJson.workspaces?.packages)) { + patterns = packageJson.workspaces.packages; + } else { + return null; + } + + if (patterns.length === 0) { + return null; + } + + // Check if turbo.json exists — if so, label as turbo + const hasTurbo = await fileExists(path.join(rootDir, 'turbo.json')); + // Detect yarn vs npm by lockfile + const hasYarnLock = await fileExists(path.join(rootDir, 'yarn.lock')); + + const type: WorkspaceType = hasTurbo ? 'turbo' : hasYarnLock ? 'yarn' : 'npm'; + + const memberDirs = await resolveGlobPatterns(rootDir, patterns); + if (memberDirs.length === 0) { + return null; + } + + return { type, rootDir, memberDirs }; +} + +/** + * Detect Lerna workspaces from lerna.json + */ +async function detectLernaWorkspace( + rootDir: string, +): Promise { + const lernaPath = path.join(rootDir, 'lerna.json'); + + let content: string; + try { + content = await fs.promises.readFile(lernaPath, 'utf-8'); + } catch { + return null; + } + + let lernaConfig: { packages?: string[] }; + try { + lernaConfig = JSON.parse(content); + } catch { + return null; + } + + const patterns = lernaConfig.packages; + if (!Array.isArray(patterns) || patterns.length === 0) { + return null; + } + + const memberDirs = await resolveGlobPatterns(rootDir, patterns); + if (memberDirs.length === 0) { + return null; + } + + return { type: 'lerna', rootDir, memberDirs }; +} + +/** + * Detect Nx workspaces from nx.json + */ +async function detectNxWorkspace( + rootDir: string, +): Promise { + const nxPath = path.join(rootDir, 'nx.json'); + + if (!(await fileExists(nxPath))) { + return null; + } + + // Nx projects can be defined via project.json files in subdirectories + const projectJsonPaths = await fg(['*/project.json', '*/*/project.json'], { + cwd: rootDir, + ignore: IGNORE_PATTERNS, + onlyFiles: true, + }); + + if (projectJsonPaths.length === 0) { + // Nx might use package.json workspaces — fall through to that detector + return null; + } + + const memberDirs = projectJsonPaths.map((p) => + path.resolve(rootDir, path.dirname(p)), + ); + + return { type: 'nx', rootDir, memberDirs: [...new Set(memberDirs)] }; +} + +/** Heuristic fallback: scan for 2+ project roots at depth 1-2. */ +async function detectHeuristic(rootDir: string): Promise { + // Find project indicators at depth 1 and 2 for ALL supported frameworks + const fileIndicators = await fg( + [ + // JS/TS + '*/package.json', + '*/*/package.json', + ...POLYGLOT_FILE_INDICATORS, + ], + { + cwd: rootDir, + ignore: IGNORE_PATTERNS, + onlyFiles: true, + }, + ); + + const xcodeprojDirs = await fg(XCODEPROJ_GLOBS, { + cwd: rootDir, + ignore: IGNORE_PATTERNS, + onlyDirectories: true, + }); + + // Combine all indicators — get parent directories + const allIndicators = [ + ...fileIndicators.map((f) => path.dirname(f)), + // For .xcodeproj, the project root is the parent of the .xcodeproj dir + ...xcodeprojDirs.map((d) => path.dirname(d)), + ]; + + // Deduplicate to unique directories + const candidateDirs = [ + ...new Set(allIndicators.map((rel) => path.resolve(rootDir, rel))), + ]; + + // Filter out the root itself (don't count root-level indicators) + const nonRootDirs = candidateDirs.filter( + (dir) => path.resolve(dir) !== path.resolve(rootDir), + ); + + // Only trigger if 2+ candidate dirs found + if (nonRootDirs.length < 2) { + return null; + } + + return { type: 'heuristic', rootDir, memberDirs: nonRootDirs }; +} + +/** Parse pnpm-workspace.yaml to extract package glob patterns. */ +export function parsePnpmWorkspaceYaml(content: string): string[] { + const patterns: string[] = []; + const lines = content.split('\n'); + + let inPackages = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip comments and empty lines + if (trimmed.startsWith('#') || trimmed === '') { + continue; + } + + if (trimmed === 'packages:') { + inPackages = true; + continue; + } + + // If we hit a new top-level key, stop reading packages + if (inPackages && !line.startsWith(' ') && !line.startsWith('\t')) { + break; + } + + if (inPackages && trimmed.startsWith('-')) { + // Extract pattern: remove leading "- " and surrounding quotes + let pattern = trimmed.slice(1).trim(); + pattern = pattern.replace(/^['"]|['"]$/g, ''); + + // Skip negated patterns (e.g., "!packages/internal") + if (pattern.startsWith('!')) { + continue; + } + + if (pattern) { + patterns.push(pattern); + } + } + } + + return patterns; +} + +/** + * Resolve workspace glob patterns to actual directory paths. + */ +async function resolveGlobPatterns( + rootDir: string, + patterns: string[], +): Promise { + const dirs: string[] = []; + + for (const pattern of patterns) { + const matches = await fg(pattern, { + cwd: rootDir, + onlyDirectories: true, + ignore: IGNORE_PATTERNS, + }); + + for (const match of matches) { + dirs.push(path.resolve(rootDir, match)); + } + } + + return [...new Set(dirs)]; +} + +/** Add non-JS project roots (Python, PHP, mobile) that live outside JS workspace configs. */ +async function supplementWithPolyglotProjects( + workspace: WorkspaceInfo, +): Promise { + const { rootDir, memberDirs } = workspace; + + // Scan for non-JS project indicators at depth 1 and 2 + const fileIndicators = await fg(POLYGLOT_FILE_INDICATORS, { + cwd: rootDir, + ignore: IGNORE_PATTERNS, + onlyFiles: true, + }); + + const xcodeprojDirs = await fg(XCODEPROJ_GLOBS, { + cwd: rootDir, + ignore: IGNORE_PATTERNS, + onlyDirectories: true, + }); + + // Get parent directories of each indicator + const candidateDirs = new Set([ + ...fileIndicators.map((f) => path.resolve(rootDir, path.dirname(f))), + ...xcodeprojDirs.map((d) => path.resolve(rootDir, path.dirname(d))), + ]); + + const resolvedRoot = path.resolve(rootDir); + const existingMemberSet = new Set(memberDirs.map((d) => path.resolve(d))); + + const supplementalDirs: string[] = []; + + for (const candidate of candidateDirs) { + // Skip the root dir itself + if (candidate === resolvedRoot) { + continue; + } + + // Skip if already a formal workspace member + if (existingMemberSet.has(candidate)) { + continue; + } + + // Skip if this candidate is a subdirectory of an existing member + // (but exclude root from this check — everything is a subdir of root) + const isSubdirOfMember = [...existingMemberSet].some( + (member) => + member !== resolvedRoot && candidate.startsWith(member + path.sep), + ); + if (isSubdirOfMember) { + continue; + } + + supplementalDirs.push(candidate); + } + + if (supplementalDirs.length === 0) { + return workspace; + } + + return { + ...workspace, + memberDirs: [...memberDirs, ...supplementalDirs], + }; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.promises.access(filePath); + return true; + } catch { + return false; + } +}