diff --git a/package.json b/package.json index 58a5b3a9..468c16e9 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "uuid": "^11.1.0", "xcode": "3.0.1", "xml-js": "^1.6.11", + "yaml": "^2.8.2", "yargs": "^16.2.0", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49a8c9e5..f785fed6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: xml-js: specifier: ^1.6.11 version: 1.6.11 + yaml: + specifier: ^2.8.2 + version: 2.8.2 yargs: specifier: ^16.2.0 version: 16.2.0 @@ -1651,11 +1654,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -2813,9 +2817,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.7.1: - resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} - engines: {node: '>= 14'} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} hasBin: true yargs-parser@20.2.9: @@ -5052,7 +5056,7 @@ snapshots: micromatch: 4.0.8 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.7.1 + yaml: 2.8.2 transitivePeerDependencies: - supports-color @@ -5732,7 +5736,7 @@ snapshots: yallist@3.1.1: {} - yaml@2.7.1: {} + yaml@2.8.2: {} yargs-parser@20.2.9: {} diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index c0fdf439..597e7f0a 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -17,7 +17,7 @@ import { } from '../utils/clack-utils'; import type { PackageDotJson } from '../utils/package-json'; import { analytics } from '../utils/analytics'; -import { WIZARD_INTERACTION_EVENT_NAME } from './constants'; +import { WIZARD_INTERACTION_EVENT_NAME, Integration } from './constants'; import clack from '../utils/clack'; import { initializeAgent, @@ -31,6 +31,7 @@ import * as semver from 'semver'; import { addMCPServerToClientsStep, uploadEnvironmentVariablesStep, + updatePreCommitConfigStep, } from '../steps'; import { checkAnthropicStatusWithPrompt } from '../utils/anthropic-status'; import { enableDebugLogs } from '../utils/debug'; @@ -304,6 +305,23 @@ Please report this error to: ${chalk.cyan('wizard@posthog.com')}`; ci: options.ci, }); + // Update pre-commit config for Python frameworks + const pythonIntegrations = [ + Integration.django, + Integration.flask, + Integration.fastapi, + Integration.python, + ]; + + let preCommitUpdated = false; + if (pythonIntegrations.includes(config.metadata.integration)) { + const preCommitResult = await updatePreCommitConfigStep({ + installDir: options.installDir, + integration: config.metadata.integration, + }); + preCommitUpdated = preCommitResult.updated; + } + // Build outro message const continueUrl = options.signup ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` @@ -317,6 +335,7 @@ Please report this error to: ${chalk.cyan('wizard@posthog.com')}`; uploadedEnvVars.length > 0 ? `Uploaded environment variables to your hosting provider` : '', + preCommitUpdated ? `Updated pre-commit config with posthog dependency` : '', ].filter(Boolean); const nextSteps = [ diff --git a/src/steps/__tests__/update-pre-commit-config.test.ts b/src/steps/__tests__/update-pre-commit-config.test.ts new file mode 100644 index 00000000..e9eb886a --- /dev/null +++ b/src/steps/__tests__/update-pre-commit-config.test.ts @@ -0,0 +1,363 @@ +import * as fs from 'fs'; +import path from 'path'; +import { updatePreCommitConfigStep } from '../update-pre-commit-config'; +import { Integration } from '../../lib/constants'; + +jest.mock('fs', () => ({ + existsSync: jest.fn(), + promises: { + readFile: jest.fn(), + writeFile: jest.fn(), + }, +})); + +jest.mock('../../utils/analytics', () => ({ + analytics: { + capture: jest.fn(), + setTag: jest.fn(), + }, +})); + +jest.mock('../../utils/clack', () => ({ + log: { + warn: jest.fn(), + success: jest.fn(), + }, +})); + +describe('updatePreCommitConfigStep', () => { + const mockOptions = { + installDir: '/test/project', + integration: Integration.django, + }; + + const existsSyncMock = fs.existsSync as jest.Mock; + const readFileMock = fs.promises.readFile as jest.Mock; + const writeFileMock = fs.promises.writeFile as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('config file discovery', () => { + it('returns updated: false when no config file exists', async () => { + existsSyncMock.mockReturnValue(false); + + const result = await updatePreCommitConfigStep(mockOptions); + + expect(result).toEqual({ updated: false }); + }); + + it.each([ + ['.pre-commit-config.yaml', '.pre-commit-config.yaml'], + ['.pre-commit-config.yml', '.pre-commit-config.yml'], + ])('reads %s when it exists', async (existingFile, expectedFile) => { + existsSyncMock.mockImplementation((p: string) => + p.endsWith(existingFile), + ); + readFileMock.mockResolvedValue('repos: []'); + + await updatePreCommitConfigStep(mockOptions); + + expect(readFileMock).toHaveBeenCalledWith( + path.join('/test/project', expectedFile), + 'utf8', + ); + }); + + it('prefers .yaml over .yml when both exist', async () => { + existsSyncMock.mockReturnValue(true); // Both exist + readFileMock.mockResolvedValue('repos: []'); + + await updatePreCommitConfigStep(mockOptions); + + expect(readFileMock).toHaveBeenCalledWith( + path.join('/test/project', '.pre-commit-config.yaml'), + 'utf8', + ); + }); + }); + + describe('adding posthog dependency', () => { + beforeEach(() => { + existsSyncMock.mockReturnValue(true); + writeFileMock.mockResolvedValue(undefined); + }); + + it.each(['mypy', 'pyright', 'pytype'])( + 'adds posthog to %s hook', + async (hookId) => { + readFileMock.mockResolvedValue(` +repos: + - repo: https://example.com/${hookId} + hooks: + - id: ${hookId} +`); + + const result = await updatePreCommitConfigStep(mockOptions); + + expect(result).toEqual({ updated: true }); + const writtenContent = writeFileMock.mock.calls[0][1] as string; + expect(writtenContent).toContain('posthog'); + }, + ); + + it('adds posthog to all type-checking hooks when multiple exist', async () => { + readFileMock.mockResolvedValue(` +repos: + - repo: https://github.com/pre-commit/mirrors-mypy + hooks: + - id: mypy + - repo: https://github.com/microsoft/pyright + hooks: + - id: pyright +`); + + await updatePreCommitConfigStep(mockOptions); + + const writtenContent = writeFileMock.mock.calls[0][1] as string; + expect(writtenContent.match(/posthog/g)).toHaveLength(2); + }); + + it('preserves existing additional_dependencies', async () => { + readFileMock.mockResolvedValue(` +repos: + - repo: https://github.com/pre-commit/mirrors-mypy + hooks: + - id: mypy + additional_dependencies: + - django-stubs + - types-requests +`); + + await updatePreCommitConfigStep(mockOptions); + + const writtenContent = writeFileMock.mock.calls[0][1] as string; + expect(writtenContent).toContain('django-stubs'); + expect(writtenContent).toContain('types-requests'); + expect(writtenContent).toContain('posthog'); + }); + }); + + describe('idempotency', () => { + beforeEach(() => { + existsSyncMock.mockReturnValue(true); + }); + + it.each([ + 'posthog', + 'posthog==3.0.0', + 'posthog>=2.0.0', + 'posthog<=3.0.0', + 'posthog~=2.0', + 'posthog!=1.0.0', + 'posthog[sentry]', + ])( + 'does not modify when "%s" already in dependencies', + async (existingDep) => { + readFileMock.mockResolvedValue(` +repos: + - repo: https://github.com/pre-commit/mirrors-mypy + hooks: + - id: mypy + additional_dependencies: + - ${existingDep} +`); + + const result = await updatePreCommitConfigStep(mockOptions); + + expect(result).toEqual({ updated: false }); + expect(writeFileMock).not.toHaveBeenCalled(); + }, + ); + }); + + describe('configs without type-checking hooks', () => { + beforeEach(() => { + existsSyncMock.mockReturnValue(true); + }); + + it.each([ + ['no repos key', 'default_language_version:\n python: python3'], + ['empty repos', 'repos: []'], + ['repos without hooks', 'repos:\n - repo: local'], + [ + 'only non-type-checking hooks', + `repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + hooks: + - id: trailing-whitespace`, + ], + ])('returns updated: false for %s', async (_desc, content) => { + readFileMock.mockResolvedValue(content); + + const result = await updatePreCommitConfigStep(mockOptions); + + expect(result).toEqual({ updated: false }); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + existsSyncMock.mockReturnValue(true); + }); + + it('returns updated: false on read error', async () => { + readFileMock.mockRejectedValue(new Error('Permission denied')); + + const result = await updatePreCommitConfigStep(mockOptions); + + expect(result).toEqual({ updated: false }); + }); + + it('returns updated: false on invalid YAML', async () => { + readFileMock.mockResolvedValue('invalid: yaml: content: ['); + + const result = await updatePreCommitConfigStep(mockOptions); + + expect(result).toEqual({ updated: false }); + }); + + it('returns updated: false on write error', async () => { + readFileMock.mockResolvedValue(` +repos: + - repo: https://github.com/pre-commit/mirrors-mypy + hooks: + - id: mypy +`); + writeFileMock.mockRejectedValue(new Error('Disk full')); + + const result = await updatePreCommitConfigStep(mockOptions); + + expect(result).toEqual({ updated: false }); + }); + }); + + describe('format preservation', () => { + beforeEach(() => { + existsSyncMock.mockReturnValue(true); + writeFileMock.mockResolvedValue(undefined); + }); + + it('preserves flow-style arrays', async () => { + const input = `repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + hooks: + - id: check-added-large-files + args: ['--maxkb=1000'] + - repo: https://github.com/pre-commit/mirrors-mypy + hooks: + - id: mypy + args: [--ignore-missing-imports, --disallow-untyped-defs] +`; + readFileMock.mockResolvedValue(input); + + await updatePreCommitConfigStep(mockOptions); + + const output = writeFileMock.mock.calls[0][1] as string; + expect(output).toContain("args: ['--maxkb=1000']"); + expect(output).toContain( + 'args: [--ignore-missing-imports, --disallow-untyped-defs]', + ); + }); + + it('preserves blank lines between repos', async () => { + const input = `repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + hooks: + - id: trailing-whitespace + + - repo: https://github.com/pre-commit/mirrors-mypy + hooks: + - id: mypy +`; + readFileMock.mockResolvedValue(input); + + await updatePreCommitConfigStep(mockOptions); + + const output = writeFileMock.mock.calls[0][1] as string; + expect(output).toContain('trailing-whitespace\n\n - repo:'); + }); + + it('preserves quoted strings', async () => { + const input = `repos: + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.19.1" + hooks: + - id: mypy +`; + readFileMock.mockResolvedValue(input); + + await updatePreCommitConfigStep(mockOptions); + + const output = writeFileMock.mock.calls[0][1] as string; + expect(output).toContain('rev: "v1.19.1"'); + }); + + it('preserves flow-style additional_dependencies and appends posthog', async () => { + const input = `repos: + - repo: https://github.com/pre-commit/mirrors-mypy + hooks: + - id: mypy + additional_dependencies: [django-stubs, types-requests] +`; + readFileMock.mockResolvedValue(input); + + await updatePreCommitConfigStep(mockOptions); + + const output = writeFileMock.mock.calls[0][1] as string; + expect(output).toContain('additional_dependencies:'); + expect(output).toContain('django-stubs'); + expect(output).toContain('types-requests'); + expect(output).toContain('posthog'); + }); + + it('preserves complex real-world config formatting', async () => { + const input = `repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: check-added-large-files + args: ['--maxkb=1000'] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.10 + hooks: + - id: ruff-check + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.19.1" + hooks: + - id: mypy + args: [--ignore-missing-imports] + additional_dependencies: + [ + django-stubs==5.2.8, + djangorestframework-stubs==3.14.5, + ] + + - repo: local + hooks: + - id: commitizen-check + stages: [commit-msg] +`; + readFileMock.mockResolvedValue(input); + + await updatePreCommitConfigStep(mockOptions); + + const output = writeFileMock.mock.calls[0][1] as string; + + // Verify key formatting elements are preserved + expect(output).toContain("args: ['--maxkb=1000']"); + expect(output).toContain('rev: "v1.19.1"'); + expect(output).toContain('args: [--ignore-missing-imports]'); + expect(output).toContain('stages: [commit-msg]'); + // Verify blank lines between repos are preserved + expect(output).toContain('ruff-check\n\n - repo:'); + // Verify posthog was added + expect(output).toContain('posthog'); + }); + }); +}); diff --git a/src/steps/index.ts b/src/steps/index.ts index 9761ee9d..d20e1211 100644 --- a/src/steps/index.ts +++ b/src/steps/index.ts @@ -3,3 +3,4 @@ export * from './run-prettier'; export * from './add-or-update-environment-variables'; export * from './add-mcp-server-to-clients'; export * from './upload-environment-variables'; +export * from './update-pre-commit-config'; diff --git a/src/steps/update-pre-commit-config.ts b/src/steps/update-pre-commit-config.ts new file mode 100644 index 00000000..15e527c9 --- /dev/null +++ b/src/steps/update-pre-commit-config.ts @@ -0,0 +1,154 @@ +import * as fs from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import { parseDocument, isSeq, isMap, YAMLSeq } from 'yaml'; +import type { Integration } from '../lib/constants'; +import { analytics } from '../utils/analytics'; +import clack from '../utils/clack'; +import { traceStep } from '../telemetry'; + +const TYPE_CHECKING_HOOK_IDS = ['mypy', 'pyright', 'pytype']; +const CONFIG_FILENAMES = ['.pre-commit-config.yaml', '.pre-commit-config.yml']; +// Matches 'posthog' with any version specifier (==, >=, <=, ~=, !=, etc.) or extras [...] +const POSTHOG_DEP_PATTERN = /^posthog([=<>~![]|$)/; + +function findConfigFile(installDir: string): string | null { + for (const filename of CONFIG_FILENAMES) { + const candidate = path.join(installDir, filename); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +export async function updatePreCommitConfigStep({ + installDir, + integration, +}: { + installDir: string; + integration: Integration; +}): Promise<{ updated: boolean }> { + return traceStep('update-pre-commit-config', async () => { + const configPath = findConfigFile(installDir); + + if (!configPath) { + return { updated: false }; + } + + const configFilename = path.basename(configPath); + + let content: string; + try { + content = await fs.promises.readFile(configPath, 'utf8'); + } catch (error) { + clack.log.warn(`Could not read ${chalk.bold.cyan(configFilename)}`); + analytics.capture('wizard interaction', { + action: 'failed to read pre-commit config', + integration, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return { updated: false }; + } + + let doc; + try { + doc = parseDocument(content); + } catch (error) { + clack.log.warn(`Could not parse ${chalk.bold.cyan(configFilename)}`); + analytics.capture('wizard interaction', { + action: 'failed to parse pre-commit config', + integration, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return { updated: false }; + } + + const repos = doc.get('repos'); + if (!isSeq(repos)) { + return { updated: false }; + } + + let modified = false; + let alreadyHasPosthog = false; + + for (const repo of repos.items) { + if (!isMap(repo)) continue; + + const hooks = repo.get('hooks'); + if (!isSeq(hooks)) continue; + + for (const hook of hooks.items) { + if (!isMap(hook)) continue; + + const hookId = String(hook.get('id') ?? ''); + if (!TYPE_CHECKING_HOOK_IDS.includes(hookId)) continue; + + const deps = hook.get('additional_dependencies'); + + if (isSeq(deps)) { + // Check if posthog already exists + const hasPosthog = deps.items.some((item) => + POSTHOG_DEP_PATTERN.test(String(item)), + ); + + if (!hasPosthog) { + deps.add('posthog'); + modified = true; + } else { + alreadyHasPosthog = true; + } + } else if (deps === undefined || deps === null) { + // No additional_dependencies - create new sequence + const newDeps = new YAMLSeq(); + newDeps.add('posthog'); + hook.set('additional_dependencies', newDeps); + modified = true; + } else { + clack.log.warn( + `Unexpected additional_dependencies format in ${chalk.bold.cyan( + hookId, + )} hook, skipping`, + ); + } + } + } + + if (!modified) { + if (alreadyHasPosthog) { + clack.log.success( + `${chalk.bold.cyan( + configFilename, + )} already has posthog in type-checking hook dependencies`, + ); + } + return { updated: false }; + } + + try { + const output = doc.toString({ flowCollectionPadding: false }); + await fs.promises.writeFile(configPath, output, 'utf8'); + } catch (error) { + clack.log.warn(`Could not write ${chalk.bold.cyan(configFilename)}`); + analytics.capture('wizard interaction', { + action: 'failed to write pre-commit config', + integration, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return { updated: false }; + } + + analytics.capture('wizard interaction', { + action: 'updated pre-commit config', + integration, + }); + + clack.log.success( + `Added posthog to additional_dependencies in ${chalk.bold.cyan( + configFilename, + )}`, + ); + + return { updated: true }; + }); +}