diff --git a/.eslintrc.js b/.eslintrc.js index 0a0b662c9..81479c3cc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -49,6 +49,8 @@ module.exports = { assertFunctionNames: [ 'expect', 'checkFileContents', + 'checkFileDoesNotContain', + 'checkFileDoesNotExist', 'checkSentryProperties', 'checkPackageJson', 'checkIfBuilds', diff --git a/bin.ts b/bin.ts index 6692c1e47..fcf46773a 100644 --- a/bin.ts +++ b/bin.ts @@ -160,6 +160,43 @@ const argv = yargs(hideBin(process.argv), process.cwd()) 'Enable Spotlight for local development. This does not require a Sentry account or project.', type: 'boolean', }, + 'non-interactive': { + default: false, + describe: + '[NextJS only] Run in non-interactive mode. Skips all prompts and uses environment variable placeholders for auth.', + type: 'boolean', + }, + tracing: { + describe: + '[NextJS only] Enable performance/tracing monitoring. When set, skips the tracing prompt.', + type: 'boolean', + }, + replay: { + describe: + '[NextJS only] Enable Session Replay. When set, skips the replay prompt.', + type: 'boolean', + }, + logs: { + describe: + '[NextJS only] Enable Sentry Logs. When set, skips the logs prompt.', + type: 'boolean', + }, + 'tunnel-route': { + describe: + '[NextJS only] Enable tunnel route for ad-blocker circumvention. When set, skips the tunnel route prompt.', + type: 'boolean', + }, + 'example-page': { + describe: + '[NextJS only] Create an example page to test Sentry. When set, skips the example page prompt.', + type: 'boolean', + }, + mcp: { + describe: + '[NextJS only] Add MCP (Model Context Protocol) config for specified IDE(s). Options: cursor, vscode, claude, opencode, jetbrains', + type: 'array', + choices: ['cursor', 'vscode', 'claude', 'opencode', 'jetbrains'], + }, 'xcode-project-dir': xcodeProjectDirOption, ...PRESELECTED_PROJECT_OPTIONS, }) diff --git a/e2e-tests/tests/help-message.test.ts b/e2e-tests/tests/help-message.test.ts index 9fc84bffc..f83006d3c 100644 --- a/e2e-tests/tests/help-message.test.ts +++ b/e2e-tests/tests/help-message.test.ts @@ -54,6 +54,24 @@ describe('--help command', () => { --spotlight Enable Spotlight for local development. This does not require a Sentry account or project. [boolean] [default: false] + --non-interactive [NextJS only] Run in non-interactive mode. Skips all + prompts and uses environment variable placeholders + for auth. [boolean] [default: false] + --tracing [NextJS only] Enable performance/tracing monitoring. + When set, skips the tracing prompt. [boolean] + --replay [NextJS only] Enable Session Replay. When set, skips + the replay prompt. [boolean] + --logs [NextJS only] Enable Sentry Logs. When set, skips + the logs prompt. [boolean] + --tunnel-route [NextJS only] Enable tunnel route for ad-blocker + circumvention. When set, skips the tunnel route + prompt. [boolean] + --example-page [NextJS only] Create an example page to test Sentry. + When set, skips the example page prompt. [boolean] + --mcp [NextJS only] Add MCP (Model Context Protocol) + config for specified IDE(s). Options: cursor, + vscode, claude, opencode, jetbrains + [array] [choices: "cursor", "vscode", "claude", "opencode", "jetbrains"] --version Show version number [boolean] " `); diff --git a/e2e-tests/tests/nextjs-non-interactive.test.ts b/e2e-tests/tests/nextjs-non-interactive.test.ts new file mode 100644 index 000000000..e8997651e --- /dev/null +++ b/e2e-tests/tests/nextjs-non-interactive.test.ts @@ -0,0 +1,364 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Integration } from '../../lib/Constants'; +import { + checkFileContents, + checkFileDoesNotExist, + checkFileExists, + checkIfBuilds, + checkPackageJson, + createFile, + createIsolatedTestEnv, +} from '../utils'; +import { describe, beforeAll, afterAll, test, expect } from 'vitest'; + +//@ts-expect-error - clifty is ESM only +import { withEnv } from 'clifty'; + +/** + * Helper to get the wizard binary path + */ +function getWizardBinPath(): string { + const binName = process.env.SENTRY_WIZARD_E2E_TEST_BIN + ? ['dist-bin', `sentry-wizard-${process.platform}-${process.arch}`] + : ['dist', 'bin.js']; + return path.join(__dirname, '..', '..', ...binName); +} + +/** + * Create a minimal package-lock.json to ensure npm is auto-detected as the package manager. + * This is necessary for non-interactive mode since we can't prompt for package manager selection. + */ +function createPackageLockForNpm(projectDir: string): void { + const packageLock = { + name: 'nextjs-test-app', + lockfileVersion: 3, + requires: true, + packages: {}, + }; + createFile( + path.join(projectDir, 'package-lock.json'), + JSON.stringify(packageLock, null, 2), + ); +} + +describe('NextJS Non-Interactive Mode', () => { + const integration = Integration.nextjs; + let wizardExitCode: number; + + const { projectDir, cleanup } = createIsolatedTestEnv('nextjs-15-test-app'); + + beforeAll(async () => { + // Create package-lock.json so npm is auto-detected (avoids package manager prompt) + createPackageLockForNpm(projectDir); + + const binPath = getWizardBinPath(); + + // Run wizard in non-interactive mode with all features enabled + const command = [ + binPath, + '-i', + integration, + '--non-interactive', + '--tracing', + '--replay', + '--logs', + '--example-page', + '--disable-telemetry', + '--ignore-git-changes', + ].join(' '); + + wizardExitCode = await withEnv({ + cwd: projectDir, + debug: true, + }) + .defineInteraction() + .expectOutput('Running in non-interactive mode') + .expectOutput('Successfully scaffolded the Sentry Next.js SDK!', { + timeout: 240_000, // npm install can take a while in CI + }) + .run(command); + }); + + afterAll(() => { + cleanup(); + }); + + test('exits with exit code 0', () => { + expect(wizardExitCode).toBe(0); + }); + + test('package.json is updated correctly', () => { + checkPackageJson(projectDir, '@sentry/nextjs'); + }); + + test('.env.example is created with placeholder values', () => { + checkFileExists(`${projectDir}/.env.example`); + checkFileContents(`${projectDir}/.env.example`, [ + 'NEXT_PUBLIC_SENTRY_DSN=', + 'SENTRY_ORG=', + 'SENTRY_PROJECT=', + 'SENTRY_AUTH_TOKEN=', + ]); + }); + + test('.env.sentry-build-plugin should NOT exist in non-interactive mode', () => { + checkFileDoesNotExist(`${projectDir}/.env.sentry-build-plugin`); + }); + + test('instrumentation file is created', () => { + const hasJs = fs.existsSync(`${projectDir}/src/instrumentation.js`); + const hasTs = fs.existsSync(`${projectDir}/src/instrumentation.ts`); + expect(hasJs || hasTs).toBe(true); + }); + + test('instrumentation-client file is created and uses env var for DSN', () => { + const hasJs = fs.existsSync(`${projectDir}/src/instrumentation-client.js`); + const hasTs = fs.existsSync(`${projectDir}/src/instrumentation-client.ts`); + expect(hasJs || hasTs).toBe(true); + + const filePath = hasTs + ? `${projectDir}/src/instrumentation-client.ts` + : `${projectDir}/src/instrumentation-client.js`; + checkFileContents(filePath, 'process.env.NEXT_PUBLIC_SENTRY_DSN'); + }); + + test('sentry.server.config is created and uses env var for DSN', () => { + const hasJs = fs.existsSync(`${projectDir}/sentry.server.config.js`); + const hasTs = fs.existsSync(`${projectDir}/sentry.server.config.ts`); + expect(hasJs || hasTs).toBe(true); + + const filePath = hasTs + ? `${projectDir}/sentry.server.config.ts` + : `${projectDir}/sentry.server.config.js`; + checkFileContents(filePath, 'process.env.NEXT_PUBLIC_SENTRY_DSN'); + }); + + test('sentry.edge.config is created and uses env var for DSN', () => { + const hasJs = fs.existsSync(`${projectDir}/sentry.edge.config.js`); + const hasTs = fs.existsSync(`${projectDir}/sentry.edge.config.ts`); + expect(hasJs || hasTs).toBe(true); + + const filePath = hasTs + ? `${projectDir}/sentry.edge.config.ts` + : `${projectDir}/sentry.edge.config.js`; + checkFileContents(filePath, 'process.env.NEXT_PUBLIC_SENTRY_DSN'); + }); + + test('next.config uses env vars for org and project', () => { + const hasJs = fs.existsSync(`${projectDir}/next.config.js`); + const hasMjs = fs.existsSync(`${projectDir}/next.config.mjs`); + const hasTs = fs.existsSync(`${projectDir}/next.config.ts`); + expect(hasJs || hasMjs || hasTs).toBe(true); + + const filePath = hasTs + ? `${projectDir}/next.config.ts` + : hasMjs + ? `${projectDir}/next.config.mjs` + : `${projectDir}/next.config.js`; + checkFileContents(filePath, [ + 'process.env.SENTRY_ORG', + 'process.env.SENTRY_PROJECT', + ]); + }); + + test('example page is created', () => { + const hasExample = + fs.existsSync(`${projectDir}/app/sentry-example-page`) || + fs.existsSync(`${projectDir}/src/app/sentry-example-page`); + expect(hasExample).toBe(true); + }); + + test('instrumentation-client contains tracing config (--tracing flag)', () => { + const filePath = fs.existsSync( + `${projectDir}/src/instrumentation-client.ts`, + ) + ? `${projectDir}/src/instrumentation-client.ts` + : `${projectDir}/src/instrumentation-client.js`; + checkFileContents(filePath, 'tracesSampleRate'); + }); + + test('instrumentation-client contains replay config (--replay flag)', () => { + const filePath = fs.existsSync( + `${projectDir}/src/instrumentation-client.ts`, + ) + ? `${projectDir}/src/instrumentation-client.ts` + : `${projectDir}/src/instrumentation-client.js`; + checkFileContents(filePath, 'replayIntegration'); + }); + + test('instrumentation-client contains logs config (--logs flag)', () => { + const filePath = fs.existsSync( + `${projectDir}/src/instrumentation-client.ts`, + ) + ? `${projectDir}/src/instrumentation-client.ts` + : `${projectDir}/src/instrumentation-client.js`; + checkFileContents(filePath, 'enableLogs: true'); + }); + + test('builds correctly', async () => { + await checkIfBuilds(projectDir); + }); +}); + +describe('NextJS Non-Interactive Mode with MCP', () => { + const integration = Integration.nextjs; + let wizardExitCode: number; + + const { projectDir, cleanup } = createIsolatedTestEnv('nextjs-15-test-app'); + + beforeAll(async () => { + // Create package-lock.json so npm is auto-detected (avoids package manager prompt) + createPackageLockForNpm(projectDir); + + const binPath = getWizardBinPath(); + + // Run wizard in non-interactive mode with MCP configuration + const command = [ + binPath, + '-i', + integration, + '--non-interactive', + '--tracing', + '--mcp', + 'cursor', + '--mcp', + 'opencode', + '--disable-telemetry', + '--ignore-git-changes', + ].join(' '); + + wizardExitCode = await withEnv({ + cwd: projectDir, + debug: true, + }) + .defineInteraction() + .expectOutput('Running in non-interactive mode') + .expectOutput('Adding MCP configurations', { + timeout: 240_000, // npm install can take a while in CI + }) + .expectOutput('Successfully scaffolded the Sentry Next.js SDK!') + .run(command); + }); + + afterAll(() => { + cleanup(); + }); + + test('exits with exit code 0', () => { + expect(wizardExitCode).toBe(0); + }); + + test('Cursor MCP config is created with base URL (no org/project scope)', () => { + checkFileExists(`${projectDir}/.cursor/mcp.json`); + checkFileContents(`${projectDir}/.cursor/mcp.json`, [ + '"mcpServers"', + '"Sentry"', + 'https://mcp.sentry.dev/mcp', + ]); + }); + + test('OpenCode MCP config is created with base URL (no org/project scope)', () => { + // OpenCode uses opencode.json at project root, not .opencode/mcp.json + checkFileExists(`${projectDir}/opencode.json`); + checkFileContents(`${projectDir}/opencode.json`, [ + '"mcp"', + '"Sentry"', + 'https://mcp.sentry.dev/mcp', + ]); + }); +}); + +describe('NextJS Non-Interactive Mode - Minimal', () => { + const integration = Integration.nextjs; + let wizardExitCode: number; + + const { projectDir, cleanup } = createIsolatedTestEnv('nextjs-15-test-app'); + + beforeAll(async () => { + // Create package-lock.json so npm is auto-detected (avoids package manager prompt) + createPackageLockForNpm(projectDir); + + const binPath = getWizardBinPath(); + + // Run wizard in non-interactive mode with NO feature flags + // This tests the default behavior where features are disabled + const command = [ + binPath, + '-i', + integration, + '--non-interactive', + '--disable-telemetry', + '--ignore-git-changes', + ].join(' '); + + wizardExitCode = await withEnv({ + cwd: projectDir, + debug: true, + }) + .defineInteraction() + .expectOutput('Running in non-interactive mode') + .expectOutput('Successfully scaffolded the Sentry Next.js SDK!', { + timeout: 240_000, // npm install can take a while in CI + }) + .run(command); + }); + + afterAll(() => { + cleanup(); + }); + + test('exits with exit code 0', () => { + expect(wizardExitCode).toBe(0); + }); + + test('package.json is updated correctly', () => { + checkPackageJson(projectDir, '@sentry/nextjs'); + }); + + test('instrumentation-client does NOT contain tracing config when --tracing not provided', () => { + const filePath = fs.existsSync( + `${projectDir}/src/instrumentation-client.ts`, + ) + ? `${projectDir}/src/instrumentation-client.ts` + : `${projectDir}/src/instrumentation-client.js`; + + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).not.toContain('tracesSampleRate'); + }); + + test('instrumentation-client does NOT contain replay config when --replay not provided', () => { + const filePath = fs.existsSync( + `${projectDir}/src/instrumentation-client.ts`, + ) + ? `${projectDir}/src/instrumentation-client.ts` + : `${projectDir}/src/instrumentation-client.js`; + + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).not.toContain('replayIntegration'); + }); + + test('instrumentation-client does NOT contain logs config when --logs not provided', () => { + const filePath = fs.existsSync( + `${projectDir}/src/instrumentation-client.ts`, + ) + ? `${projectDir}/src/instrumentation-client.ts` + : `${projectDir}/src/instrumentation-client.js`; + + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).not.toContain('enableLogs'); + }); + + test('example page is NOT created when --example-page not provided', () => { + const hasExample = + fs.existsSync(`${projectDir}/app/sentry-example-page`) || + fs.existsSync(`${projectDir}/src/app/sentry-example-page`); + expect(hasExample).toBe(false); + }); + + test('no MCP config files created when --mcp not provided', () => { + expect(fs.existsSync(`${projectDir}/.cursor/mcp.json`)).toBe(false); + expect(fs.existsSync(`${projectDir}/.vscode/mcp.json`)).toBe(false); + expect(fs.existsSync(`${projectDir}/opencode.json`)).toBe(false); + }); +}); diff --git a/src/nextjs/nextjs-wizard.ts b/src/nextjs/nextjs-wizard.ts index a86750d5f..5b68cbc05 100644 --- a/src/nextjs/nextjs-wizard.ts +++ b/src/nextjs/nextjs-wizard.ts @@ -31,7 +31,14 @@ import { } from '../utils/clack'; import { getPackageVersion, hasPackageInstalled } from '../utils/package-json'; import type { SentryProjectData, WizardOptions } from '../utils/types'; -import { offerProjectScopedMcpConfig } from '../utils/clack/mcp-config'; +import { + offerProjectScopedMcpConfig, + addCursorMcpConfig, + addVsCodeMcpConfig, + addClaudeCodeMcpConfig, + addOpenCodeMcpConfig, + showJetBrainsMcpConfig, +} from '../utils/clack/mcp-config'; import { getFullUnderscoreErrorCopyPasteSnippet, getGlobalErrorCopyPasteSnippet, @@ -54,6 +61,7 @@ import { getInstrumentationClientHookCopyPasteSnippet, getRootLayoutWithGenerateMetadata, getGenerateMetadataSnippet, + getSentryEnvExampleContents, } from './templates'; import { getMaybeAppDirLocation, @@ -77,7 +85,7 @@ export function runNextjsWizard(options: WizardOptions) { export async function runNextjsWizardWithTelemetry( options: WizardOptions, ): Promise { - const { promoCode, telemetryEnabled, forceInstall } = options; + const { promoCode, telemetryEnabled, forceInstall, nonInteractive } = options; printWelcome({ wizardName: 'Sentry Next.js Wizard', @@ -99,37 +107,29 @@ export async function runNextjsWizardWithTelemetry( const nextVersion = getPackageVersion('next', packageJson); Sentry.setTag('nextjs-version', getNextJsVersionBucket(nextVersion)); - const projectData = await getOrAskForProjectData( - options, - 'javascript-nextjs', - ); - - const sdkAlreadyInstalled = hasPackageInstalled( - '@sentry/nextjs', - packageJson, - ); - Sentry.setTag('sdk-already-installed', sdkAlreadyInstalled); - - const { packageManager: packageManagerFromInstallStep } = - await installPackage({ - packageName: '@sentry/nextjs@^10', - packageNameDisplayLabel: '@sentry/nextjs', - alreadyInstalled: !!packageJson?.dependencies?.['@sentry/nextjs'], - forceInstall, - }); - + // In non-interactive mode, we don't need to authenticate with Sentry + // We'll use environment variable placeholders instead let selectedProject: SentryProjectData; let authToken: string; let selfHosted: boolean; let sentryUrl: string; let spotlight: boolean; + let useEnvVars: boolean; + + if (nonInteractive) { + Sentry.setTag('non-interactive-mode', true); + clack.log.info( + chalk.cyan( + 'Running in non-interactive mode. Environment variable placeholders will be used.', + ), + ); - if (projectData.spotlight) { - // Spotlight mode: use empty DSN and skip auth - spotlight = true; + // Skip auth mode: use placeholder data with env vars + spotlight = false; selfHosted = false; - sentryUrl = ''; + sentryUrl = 'https://sentry.io/'; authToken = ''; + useEnvVars = true; // Create a minimal project structure for type compatibility selectedProject = { id: '', @@ -138,13 +138,52 @@ export async function runNextjsWizardWithTelemetry( keys: [{ dsn: { public: '' } }], }; } else { - spotlight = false; - ({ selectedProject, authToken, selfHosted, sentryUrl } = projectData); + useEnvVars = false; + const projectData = await getOrAskForProjectData( + options, + 'javascript-nextjs', + ); + + if (projectData.spotlight) { + // Spotlight mode: use empty DSN and skip auth + spotlight = true; + selfHosted = false; + sentryUrl = ''; + authToken = ''; + // Create a minimal project structure for type compatibility + selectedProject = { + id: '', + slug: '', + organization: { id: '', slug: '', name: '' }, + keys: [{ dsn: { public: '' } }], + }; + } else { + spotlight = false; + ({ selectedProject, authToken, selfHosted, sentryUrl } = projectData); + } } - const { logsEnabled } = await traceStep('configure-sdk', async () => { - const tunnelRoute = await askShouldSetTunnelRoute(); + const sdkAlreadyInstalled = hasPackageInstalled( + '@sentry/nextjs', + packageJson, + ); + Sentry.setTag('sdk-already-installed', sdkAlreadyInstalled); + const { packageManager: packageManagerFromInstallStep } = + await installPackage({ + packageName: '@sentry/nextjs@^10', + packageNameDisplayLabel: '@sentry/nextjs', + alreadyInstalled: !!packageJson?.dependencies?.['@sentry/nextjs'], + forceInstall, + }); + + // Determine tunnel route setting - use CLI flag if provided, otherwise prompt + // In non-interactive mode, default to false if not explicitly set + const tunnelRoute = + options.tunnelRoute ?? + (nonInteractive ? false : await askShouldSetTunnelRoute()); + + const { logsEnabled } = await traceStep('configure-sdk', async () => { return await createOrMergeNextJsFiles( selectedProject, selfHosted, @@ -153,6 +192,8 @@ export async function runNextjsWizardWithTelemetry( tunnelRoute, }, spotlight, + options, + useEnvVars, ); }); @@ -377,7 +418,11 @@ export async function runNextjsWizardWithTelemetry( } }); - const shouldCreateExamplePage = await askShouldCreateExamplePage(); + // Example page - use CLI flag if provided, otherwise prompt (skip in skip-auth mode if not explicitly enabled) + const shouldCreateExamplePage = + options.examplePage ?? + (nonInteractive ? false : await askShouldCreateExamplePage()); + if (shouldCreateExamplePage) { await traceStep('create-example-page', async () => createExamplePage( @@ -390,28 +435,72 @@ export async function runNextjsWizardWithTelemetry( ); } - if (!spotlight) { + // In skip-auth mode, create .env.example instead of .env.sentry-build-plugin + if (nonInteractive) { + await traceStep('create-env-example', async () => { + const envExamplePath = path.join(process.cwd(), '.env.example'); + const envExampleExists = fs.existsSync(envExamplePath); + + if (envExampleExists) { + // Append Sentry env vars to existing .env.example + const existingContent = fs.readFileSync(envExamplePath, 'utf8'); + if (!existingContent.includes('SENTRY_DSN')) { + await fs.promises.appendFile( + envExamplePath, + '\n' + getSentryEnvExampleContents(), + 'utf8', + ); + clack.log.success( + `Added Sentry environment variables to ${chalk.cyan( + '.env.example', + )}.`, + ); + } else { + clack.log.info( + `${chalk.cyan( + '.env.example', + )} already contains Sentry configuration.`, + ); + } + } else { + await fs.promises.writeFile( + envExamplePath, + getSentryEnvExampleContents(), + { encoding: 'utf8', flag: 'w' }, + ); + clack.log.success(`Created ${chalk.cyan('.env.example')}.`); + } + }); + } else if (!spotlight) { await addDotEnvSentryBuildPluginFile(authToken); } + // Turbopack warning - log in skip-auth mode, prompt otherwise const isLikelyUsingTurbopack = await checkIfLikelyIsUsingTurbopack(); if (isLikelyUsingTurbopack || isLikelyUsingTurbopack === null) { - await abortIfCancelled( - clack.select({ - message: - 'Warning: The Sentry SDK is only compatible with Turbopack on Next.js version 15.4.1 or later.', - options: [ - { - label: 'I understand.', - hint: 'press enter', - value: true, - }, - ], - initialValue: true, - }), - ); + if (nonInteractive) { + clack.log.warn( + 'The Sentry SDK is only compatible with Turbopack on Next.js version 15.4.1 or later.', + ); + } else { + await abortIfCancelled( + clack.select({ + message: + 'Warning: The Sentry SDK is only compatible with Turbopack on Next.js version 15.4.1 or later.', + options: [ + { + label: 'I understand.', + hint: 'press enter', + value: true, + }, + ], + initialValue: true, + }), + ); + } } + // CI setup - log in skip-auth mode, prompt/setup otherwise const mightBeUsingVercel = fs.existsSync( path.join(process.cwd(), 'vercel.json'), ); @@ -421,6 +510,15 @@ export async function runNextjsWizardWithTelemetry( "▲ It seems like you're using Vercel. We recommend using the Sentry Vercel \ integration to set up an auth token for Vercel deployments: https://vercel.com/integrations/sentry", ); + } else if (nonInteractive) { + clack.log.info( + `To upload source maps in CI, set ${chalk.cyan( + 'SENTRY_AUTH_TOKEN', + )} environment variable. ` + + `Create a token at ${chalk.cyan( + 'https://sentry.io/orgredirect/organizations/:orgslug/settings/auth-tokens/', + )}`, + ); } else if (!spotlight) { await setupCI('nextjs', authToken, options.comingFrom); } @@ -428,27 +526,80 @@ export async function runNextjsWizardWithTelemetry( const packageManagerForOutro = packageManagerFromInstallStep ?? (await getPackageManager()); - // Offer optional project-scoped MCP config for Sentry with org and project scope - await offerProjectScopedMcpConfig( - selectedProject.organization.slug, - selectedProject.slug, - ); + // Handle MCP config - if --mcp flag provided, use it; otherwise offer interactive selection + if (options.mcp && options.mcp.length > 0) { + // Use CLI-provided MCP providers + // In skip-auth mode, use base MCP URL without org/project scope + const orgSlug = nonInteractive + ? undefined + : selectedProject.organization.slug; + const projectSlug = nonInteractive ? undefined : selectedProject.slug; + + clack.log.info('Adding MCP configurations...'); + + if (options.mcp.includes('cursor')) { + await addCursorMcpConfig(orgSlug, projectSlug); + } + if (options.mcp.includes('vscode')) { + await addVsCodeMcpConfig(orgSlug, projectSlug); + } + if (options.mcp.includes('claude')) { + await addClaudeCodeMcpConfig(orgSlug, projectSlug); + } + if (options.mcp.includes('opencode')) { + await addOpenCodeMcpConfig(orgSlug, projectSlug); + } + if (options.mcp.includes('jetbrains')) { + await showJetBrainsMcpConfig(orgSlug, projectSlug); + } + } else if (!nonInteractive) { + // Offer optional project-scoped MCP config for Sentry with org and project scope + await offerProjectScopedMcpConfig( + selectedProject.organization.slug, + selectedProject.slug, + ); + } // Run formatters as the last step to fix any formatting issues in generated/modified files await runFormatters({ cwd: undefined }); - clack.outro(` + // Different outro message for skip-auth mode + if (nonInteractive) { + clack.outro(` +${chalk.green('Successfully scaffolded the Sentry Next.js SDK!')} + +${chalk.yellow('Next steps:')} +1. Copy ${chalk.cyan('.env.example')} to ${chalk.cyan('.env.local')} +2. Fill in your Sentry DSN, org, project, and auth token from ${chalk.cyan( + 'https://sentry.io', + )} +3. Restart your dev environment (e.g. ${chalk.cyan( + `${packageManagerForOutro.runScriptCommand} dev`, + )}) + +${chalk.cyan('Environment variables needed:')} + - NEXT_PUBLIC_SENTRY_DSN + - SENTRY_ORG + - SENTRY_PROJECT + - SENTRY_AUTH_TOKEN + +${chalk.dim( + 'If you encounter any issues, let us know here: https://github.com/getsentry/sentry-javascript/issues', +)}`); + } else { + clack.outro(` ${chalk.green('Successfully installed the Sentry Next.js SDK!')} ${ - shouldCreateExamplePage - ? `\n\nYou can validate your setup by (re)starting your dev environment (e.g. ${chalk.cyan( - `${packageManagerForOutro.runScriptCommand} dev`, - )}) and visiting ${chalk.cyan('"/sentry-example-page"')}` - : '' - } + shouldCreateExamplePage + ? `\n\nYou can validate your setup by (re)starting your dev environment (e.g. ${chalk.cyan( + `${packageManagerForOutro.runScriptCommand} dev`, + )}) and visiting ${chalk.cyan('"/sentry-example-page"')}` + : '' + } ${chalk.dim( 'If you encounter any issues, let us know here: https://github.com/getsentry/sentry-javascript/issues', )}`); + } } type SDKConfigOptions = { @@ -461,31 +612,78 @@ async function createOrMergeNextJsFiles( sentryUrl: string, sdkConfigOptions: SDKConfigOptions, spotlight = false, + wizardOptions: WizardOptions, + useEnvVars = false, ): Promise<{ logsEnabled: boolean }> { const dsn = selectedProject.keys[0].dsn.public; - const selectedFeatures = await featureSelectionPrompt([ - { - id: 'performance', - prompt: `Do you want to enable ${chalk.bold( - 'Tracing', - )} to track the performance of your application?`, - enabledHint: 'recommended', - }, - { - id: 'replay', - prompt: `Do you want to enable ${chalk.bold( - 'Session Replay', - )} to get a video-like reproduction of errors during a user session?`, - enabledHint: 'recommended, but increases bundle size', - }, - { - id: 'logs', - prompt: `Do you want to enable ${chalk.bold( - 'Logs', - )} to send your application logs to Sentry?`, - enabledHint: 'recommended', - }, - ] as const); + + // Build list of features to prompt for (only those not provided via CLI) + // In non-interactive mode, skip prompting entirely - unprovided flags default to false + const featuresToPrompt: Array<{ + id: 'performance' | 'replay' | 'logs'; + prompt: string; + enabledHint: string; + }> = []; + + if (!wizardOptions.nonInteractive) { + if (wizardOptions.tracing === undefined) { + featuresToPrompt.push({ + id: 'performance', + prompt: `Do you want to enable ${chalk.bold( + 'Tracing', + )} to track the performance of your application?`, + enabledHint: 'recommended', + }); + } + + if (wizardOptions.replay === undefined) { + featuresToPrompt.push({ + id: 'replay', + prompt: `Do you want to enable ${chalk.bold( + 'Session Replay', + )} to get a video-like reproduction of errors during a user session?`, + enabledHint: 'recommended, but increases bundle size', + }); + } + + if (wizardOptions.logs === undefined) { + featuresToPrompt.push({ + id: 'logs', + prompt: `Do you want to enable ${chalk.bold( + 'Logs', + )} to send your application logs to Sentry?`, + enabledHint: 'recommended', + }); + } + } + + // Prompt for features not provided via CLI (empty in non-interactive mode) + const promptedFeatures = + featuresToPrompt.length > 0 + ? await featureSelectionPrompt(featuresToPrompt) + : { performance: false, replay: false, logs: false }; + + // Merge CLI-provided flags with prompted values + const selectedFeatures = { + performance: wizardOptions.tracing ?? promptedFeatures.performance, + replay: wizardOptions.replay ?? promptedFeatures.replay, + logs: wizardOptions.logs ?? promptedFeatures.logs, + }; + + // Log selected features when any were provided via CLI + if (featuresToPrompt.length < 3) { + clack.log.info( + `Features enabled: ${chalk.cyan( + [ + selectedFeatures.performance && 'Tracing', + selectedFeatures.replay && 'Session Replay', + selectedFeatures.logs && 'Logs', + ] + .filter(Boolean) + .join(', ') || 'None', + )}`, + ); + } const typeScriptDetected = isUsingTypeScript(); @@ -546,6 +744,7 @@ async function createOrMergeNextJsFiles( configVariant, selectedFeatures, spotlight, + useEnvVars, ), { encoding: 'utf8', flag: 'w' }, ); @@ -713,7 +912,12 @@ async function createOrMergeNextJsFiles( const successfullyCreated = await createNewConfigFile( newInstrumentationClientHookPath, - getInstrumentationClientFileContents(dsn, selectedFeatures, spotlight), + getInstrumentationClientFileContents( + dsn, + selectedFeatures, + spotlight, + useEnvVars, + ), ); if (!successfullyCreated) { @@ -751,6 +955,7 @@ async function createOrMergeNextJsFiles( selfHosted, sentryUrl, tunnelRoute: sdkConfigOptions.tunnelRoute, + useEnvVars, }); const nextConfigPossibleFilesMap = { diff --git a/src/nextjs/templates.ts b/src/nextjs/templates.ts index 08ee1338a..23d1fd51a 100644 --- a/src/nextjs/templates.ts +++ b/src/nextjs/templates.ts @@ -1,12 +1,39 @@ import chalk from 'chalk'; import { makeCodeSnippet } from '../utils/clack'; +/** + * Generates the content for .env.example file with Sentry environment variables. + * Used in --skip-auth mode to document required environment variables. + */ +export function getSentryEnvExampleContents(): string { + return `# Sentry Configuration +# These environment variables are required for Sentry to work properly. +# Copy this file to .env.local and fill in the values from your Sentry project. + +# Your Sentry DSN (from Project Settings > Client Keys) +# The NEXT_PUBLIC_ prefix exposes this to the browser for client-side error reporting +NEXT_PUBLIC_SENTRY_DSN= + +# Your Sentry organization slug (from Organization Settings) +SENTRY_ORG= + +# Your Sentry project slug (from Project Settings) +SENTRY_PROJECT= + +# Auth token for source map uploads (from Organization Settings > Auth Tokens) +# Required for source map uploads during build +# Create a token with org:read and project:releases scopes +SENTRY_AUTH_TOKEN= +`; +} + type WithSentryConfigOptions = { orgSlug: string; projectSlug: string; selfHosted: boolean; sentryUrl: string; tunnelRoute: boolean; + useEnvVars?: boolean; }; export function getWithSentryConfigOptionsTemplate({ @@ -15,15 +42,19 @@ export function getWithSentryConfigOptionsTemplate({ selfHosted, tunnelRoute, sentryUrl, + useEnvVars = false, }: WithSentryConfigOptions): string { + const orgValue = useEnvVars ? 'process.env.SENTRY_ORG' : `"${orgSlug}"`; + const projectValue = useEnvVars + ? 'process.env.SENTRY_PROJECT' + : `"${projectSlug}"`; + return `{ // For all available options, see: // https://www.npmjs.com/package/@sentry/webpack-plugin#options - org: "${orgSlug}", - project: "${projectSlug}",${ - selfHosted ? `\n sentryUrl: "${sentryUrl}",` : '' - } + org: ${orgValue}, + project: ${projectValue},${selfHosted ? `\n sentryUrl: "${sentryUrl}",` : ''} // Only print logs for uploading source maps in CI silent: !process.env.CI, @@ -138,6 +169,7 @@ export function getSentryServersideConfigContents( logs: boolean; }, spotlight = false, + useEnvVars = false, ): string { let primer = ''; if (config === 'server') { @@ -169,13 +201,18 @@ export function getSentryServersideConfigContents( const spotlightOptions = getSpotlightOption(spotlight); + // Use NEXT_PUBLIC_SENTRY_DSN for consistency - it works on both server and client + const dsnValue = useEnvVars + ? 'process.env.NEXT_PUBLIC_SENTRY_DSN' + : `"${dsn}"`; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return `${primer} import * as Sentry from "@sentry/nextjs"; Sentry.init({ - dsn: "${dsn}",${performanceOptions}${logsOptions} + dsn: ${dsnValue},${performanceOptions}${logsOptions} // Enable sending user PII (Personally Identifiable Information) // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii @@ -192,6 +229,7 @@ export function getInstrumentationClientFileContents( logs: boolean; }, spotlight = false, + useEnvVars = false, ): string { const integrationsOptions = getClientIntegrationsSnippet({ replay: selectedFeaturesMap.replay, @@ -228,6 +266,11 @@ export function getInstrumentationClientFileContents( const spotlightOptions = getSpotlightOption(spotlight); + // Client-side needs NEXT_PUBLIC_ prefix for env vars + const dsnValue = useEnvVars + ? 'process.env.NEXT_PUBLIC_SENTRY_DSN' + : `"${dsn}"`; + return `// This file configures the initialization of Sentry on the client. // The added config here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ @@ -235,7 +278,7 @@ export function getInstrumentationClientFileContents( import * as Sentry from "@sentry/nextjs"; Sentry.init({ - dsn: "${dsn}",${integrationsOptions}${performanceOptions}${logsOptions}${replayOptions} + dsn: ${dsnValue},${integrationsOptions}${performanceOptions}${logsOptions}${replayOptions} // Enable sending user PII (Personally Identifiable Information) // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii diff --git a/src/run.ts b/src/run.ts index c3304b08e..1fbaf63a5 100644 --- a/src/run.ts +++ b/src/run.ts @@ -68,6 +68,15 @@ type Args = { comingFrom?: string; ignoreGitChanges?: boolean; xcodeProjectDir?: string; + + // Headless mode options + nonInteractive?: boolean; + tracing?: boolean; + replay?: boolean; + logs?: boolean; + tunnelRoute?: boolean; + examplePage?: boolean; + mcp?: string[]; }; function preSelectedProjectArgsToObject( @@ -156,6 +165,14 @@ export async function run(argv: Args) { comingFrom: finalArgs.comingFrom, ignoreGitChanges: finalArgs.ignoreGitChanges, spotlight: finalArgs.spotlight, + // Headless mode options + nonInteractive: finalArgs.nonInteractive, + tracing: finalArgs.tracing, + replay: finalArgs.replay, + logs: finalArgs.logs, + tunnelRoute: finalArgs.tunnelRoute, + examplePage: finalArgs.examplePage, + mcp: finalArgs.mcp, }; switch (integration) { diff --git a/src/utils/clack/mcp-config.ts b/src/utils/clack/mcp-config.ts index 99c0655d3..4c97d738d 100644 --- a/src/utils/clack/mcp-config.ts +++ b/src/utils/clack/mcp-config.ts @@ -143,7 +143,7 @@ function getGenericMcpJsonSnippet( return JSON.stringify(obj, null, 2); } -async function addCursorMcpConfig( +export async function addCursorMcpConfig( orgSlug?: string, projectSlug?: string, ): Promise { @@ -172,7 +172,7 @@ async function addCursorMcpConfig( } } -async function addVsCodeMcpConfig( +export async function addVsCodeMcpConfig( orgSlug?: string, projectSlug?: string, ): Promise { @@ -202,7 +202,7 @@ async function addVsCodeMcpConfig( } } -async function addClaudeCodeMcpConfig( +export async function addClaudeCodeMcpConfig( orgSlug?: string, projectSlug?: string, ): Promise { @@ -229,7 +229,7 @@ async function addClaudeCodeMcpConfig( } } -async function addOpenCodeMcpConfig( +export async function addOpenCodeMcpConfig( orgSlug?: string, projectSlug?: string, ): Promise { @@ -296,7 +296,7 @@ async function copyToClipboard(text: string): Promise { /** * Shows MCP configuration for JetBrains IDEs with copy-to-clipboard option */ -async function showJetBrainsMcpConfig( +export async function showJetBrainsMcpConfig( orgSlug?: string, projectSlug?: string, ): Promise { diff --git a/src/utils/types.ts b/src/utils/types.ts index 9f4e2dd49..dd8f779d7 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -89,6 +89,56 @@ export type WizardOptions = { * This can be passed via the `--spotlight` arg. */ spotlight?: boolean; + + /** + * Run in non-interactive mode. Skips all prompts and uses environment variable + * placeholders for authentication. Enables fully headless CLI operation. + * This can be passed via the `--non-interactive` arg. + */ + nonInteractive?: boolean; + + /** + * Enable performance/tracing monitoring. + * When set, skips the tracing prompt. + * This can be passed via the `--tracing` arg. + */ + tracing?: boolean; + + /** + * Enable Session Replay. + * When set, skips the replay prompt. + * This can be passed via the `--replay` arg. + */ + replay?: boolean; + + /** + * Enable Sentry Logs. + * When set, skips the logs prompt. + * This can be passed via the `--logs` arg. + */ + logs?: boolean; + + /** + * Enable tunnel route for ad-blocker circumvention. + * When set, skips the tunnel route prompt. + * This can be passed via the `--tunnel-route` arg. + */ + tunnelRoute?: boolean; + + /** + * Create an example page to test Sentry. + * When set, skips the example page prompt. + * This can be passed via the `--example-page` arg. + */ + examplePage?: boolean; + + /** + * MCP (Model Context Protocol) providers to configure. + * Options: cursor, vscode, claude, opencode, jetbrains + * This can be passed via the `--mcp` arg. + * Example: `--mcp cursor --mcp vscode` or `--mcp cursor,vscode` + */ + mcp?: string[]; }; export interface Feature {