UNPKG

@sentry/wizard

Version:

Sentry wizard helping you to configure your project

1,243 lines (1,100 loc) 36.4 kB
// @ts-ignore - clack is ESM and TS complains about that. It works though import * as clack from '@clack/prompts'; import axios from 'axios'; import chalk from 'chalk'; import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { setInterval } from 'timers'; import { URL } from 'url'; import * as Sentry from '@sentry/node'; import { hasPackageInstalled, PackageDotJson } from './package-json'; import { SentryProjectData, WizardOptions } from './types'; import { traceStep } from '../telemetry'; import { detectPackageManger, PackageManager, installPackageWithPackageManager, packageManagers, } from './package-manager'; import { debug } from './debug'; import { fulfillsVersionRange } from './semver'; const opn = require('opn') as ( url: string, ) => Promise<childProcess.ChildProcess>; export const SENTRY_DOT_ENV_FILE = '.env.sentry-build-plugin'; export const SENTRY_CLI_RC_FILE = '.sentryclirc'; export const SENTRY_PROPERTIES_FILE = 'sentry.properties'; const SAAS_URL = 'https://sentry.io/'; const DUMMY_AUTH_TOKEN = '_YOUR_SENTRY_AUTH_TOKEN_'; interface WizardProjectData { apiKeys?: { token?: string; }; projects?: SentryProjectData[]; } export interface CliSetupConfig { filename: string; name: string; gitignore: boolean; likelyAlreadyHasAuthToken(contents: string): boolean; tokenContent(authToken: string): string; likelyAlreadyHasOrgAndProject(contents: string): boolean; orgAndProjContent(org: string, project: string): string; likelyAlreadyHasUrl?(contents: string): boolean; urlContent?(url: string): string; } export interface CliSetupConfigContent { authToken: string; org?: string; project?: string; url?: string; } export const rcCliSetupConfig: CliSetupConfig = { filename: SENTRY_CLI_RC_FILE, name: 'source maps', gitignore: true, likelyAlreadyHasAuthToken: function (contents: string): boolean { return !!(contents.includes('[auth]') && contents.match(/token=./g)); }, tokenContent: function (authToken: string): string { return `[auth]\ntoken=${authToken}`; }, likelyAlreadyHasOrgAndProject: function (contents: string): boolean { return !!( contents.includes('[defaults]') && contents.match(/org=./g) && contents.match(/project=./g) ); }, orgAndProjContent: function (org: string, project: string): string { return `[defaults]\norg=${org}\nproject=${project}`; }, }; export const propertiesCliSetupConfig: Required<CliSetupConfig> = { filename: SENTRY_PROPERTIES_FILE, gitignore: true, name: 'debug files', likelyAlreadyHasAuthToken(contents: string): boolean { return !!contents.match(/auth\.token=./g); }, tokenContent(authToken: string): string { return `auth.token=${authToken}`; }, likelyAlreadyHasOrgAndProject(contents: string): boolean { return !!( contents.match(/defaults\.org=./g) && contents.match(/defaults\.project=./g) ); }, orgAndProjContent(org: string, project: string): string { return `defaults.org=${org}\ndefaults.project=${project}`; }, likelyAlreadyHasUrl(contents: string): boolean { return !!contents.match(/defaults\.url=./g); }, urlContent(url: string): string { return `defaults.url=${url}`; }, }; export async function abort(message?: string, status?: number): Promise<never> { clack.outro(message ?? 'Wizard setup cancelled.'); const sentryHub = Sentry.getCurrentHub(); const sentryTransaction = sentryHub.getScope().getTransaction(); sentryTransaction?.setStatus('aborted'); sentryTransaction?.finish(); const sentrySession = sentryHub.getScope().getSession(); if (sentrySession) { sentrySession.status = status === 0 ? 'abnormal' : 'crashed'; sentryHub.captureSession(true); } await Sentry.flush(3000); return process.exit(status ?? 1); } export async function abortIfCancelled<T>( input: T | Promise<T>, ): Promise<Exclude<T, symbol>> { if (clack.isCancel(await input)) { clack.cancel('Wizard setup cancelled.'); const sentryHub = Sentry.getCurrentHub(); const sentryTransaction = sentryHub.getScope().getTransaction(); sentryTransaction?.setStatus('cancelled'); sentryTransaction?.finish(); sentryHub.captureSession(true); await Sentry.flush(3000); process.exit(0); } else { return input as Exclude<T, symbol>; } } export function printWelcome(options: { wizardName: string; promoCode?: string; message?: string; telemetryEnabled?: boolean; }): void { let wizardPackage: { version?: string } = {}; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment wizardPackage = require(path.join( path.dirname(require.resolve('@sentry/wizard')), '..', 'package.json', )); } catch { // We don't need to have this } // eslint-disable-next-line no-console console.log(''); clack.intro(chalk.inverse(` ${options.wizardName} `)); let welcomeText = options.message || `The ${options.wizardName} will help you set up Sentry for your application.\nThank you for using Sentry :)`; if (options.promoCode) { welcomeText = `${welcomeText}\n\nUsing promo-code: ${options.promoCode}`; } if (wizardPackage.version) { welcomeText = `${welcomeText}\n\nVersion: ${wizardPackage.version}`; } if (options.telemetryEnabled) { welcomeText = `${welcomeText} This wizard sends telemetry data and crash reports to Sentry. This helps us improve the Wizard. You can turn this off at any time by running ${chalk.cyanBright( 'sentry-wizard --disable-telemetry', )}.`; } clack.note(welcomeText); } export async function confirmContinueIfNoOrDirtyGitRepo(): Promise<void> { return traceStep('check-git-status', async () => { if (!isInGitRepo()) { const continueWithoutGit = await abortIfCancelled( clack.confirm({ message: 'You are not inside a git repository. The wizard will create and update files. Do you want to continue anyway?', }), ); Sentry.setTag('continue-without-git', continueWithoutGit); if (!continueWithoutGit) { await abort(undefined, 0); } } const uncommittedOrUntrackedFiles = getUncommittedOrUntrackedFiles(); if (uncommittedOrUntrackedFiles.length) { clack.log.warn( `You have uncommitted or untracked files in your repo: ${uncommittedOrUntrackedFiles.join('\n')} The wizard will create and update files.`, ); const continueWithDirtyRepo = await abortIfCancelled( clack.confirm({ message: 'Do you want to continue anyway?', }), ); Sentry.setTag('continue-with-dirty-repo', continueWithDirtyRepo); if (!continueWithDirtyRepo) { await abort(undefined, 0); } } }); } function isInGitRepo() { try { childProcess.execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore', }); return true; } catch { return false; } } function getUncommittedOrUntrackedFiles(): string[] { try { const gitStatus = childProcess .execSync('git status --porcelain=v1') .toString(); const files = gitStatus .split(os.EOL) .map((line) => line.trim()) .filter(Boolean) .map((f) => `- ${f.split(/\s+/)[1]}`); return files; } catch { return []; } } export async function askToInstallSentryCLI(): Promise<boolean> { return await abortIfCancelled( clack.confirm({ message: "You don't have Sentry CLI installed. Do you want to install it?", }), ); } export async function askForItemSelection( items: string[], message: string, ): Promise<{ value: string; index: number }> { const selection: { value: string; index: number } | symbol = await abortIfCancelled( clack.select({ maxItems: 12, message: message, options: items.map((item, index) => { return { value: { value: item, index: index }, label: item, }; }), }), ); return selection; } export async function confirmContinueIfPackageVersionNotSupported({ packageId, packageName, packageVersion, acceptableVersions, note, }: { packageId: string; packageName: string; packageVersion: string; acceptableVersions: string; note?: string; }): Promise<void> { return traceStep(`check-package-version`, async () => { Sentry.setTag(`${packageName.toLowerCase()}-version`, packageVersion); const isSupportedVersion = fulfillsVersionRange({ acceptableVersions, version: packageVersion, canBeLatest: true, }); if (isSupportedVersion) { Sentry.setTag(`${packageName.toLowerCase()}-supported`, true); return; } clack.log.warn( `You have an unsupported version of ${packageName} installed: ${packageId}@${packageVersion}`, ); clack.note( note ?? `Please upgrade to ${acceptableVersions} if you wish to use the Sentry Wizard.`, ); const continueWithUnsupportedVersion = await abortIfCancelled( clack.confirm({ message: 'Do you want to continue anyway?', }), ); Sentry.setTag( `${packageName.toLowerCase()}-continue-with-unsupported-version`, continueWithUnsupportedVersion, ); if (!continueWithUnsupportedVersion) { await abort(undefined, 0); } }); } /** * Installs or updates a package with the user's package manager. * * IMPORTANT: This function modifies the `package.json`! Be sure to re-read * it if you make additional modifications to it after calling this function! */ export async function installPackage({ packageName, alreadyInstalled, askBeforeUpdating = true, }: { packageName: string; alreadyInstalled: boolean; askBeforeUpdating?: boolean; }): Promise<void> { return traceStep('install-package', async () => { if (alreadyInstalled && askBeforeUpdating) { const shouldUpdatePackage = await abortIfCancelled( clack.confirm({ message: `The ${chalk.bold.cyan( packageName, )} package is already installed. Do you want to update it to the latest version?`, }), ); if (!shouldUpdatePackage) { return; } } const sdkInstallSpinner = clack.spinner(); const packageManager = await getPackageManager(); sdkInstallSpinner.start( `${alreadyInstalled ? 'Updating' : 'Installing'} ${chalk.bold.cyan( packageName, )} with ${chalk.bold(packageManager.label)}.`, ); try { await installPackageWithPackageManager(packageManager, packageName); } catch (e) { sdkInstallSpinner.stop('Installation failed.'); clack.log.error( `${chalk.red( 'Encountered the following error during installation:', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions )}\n\n${e}\n\n${chalk.dim( 'If you think this issue is caused by the Sentry wizard, let us know here:\nhttps://github.com/getsentry/sentry-wizard/issues', )}`, ); await abort(); } sdkInstallSpinner.stop( `${alreadyInstalled ? 'Updated' : 'Installed'} ${chalk.bold.cyan( packageName, )} with ${chalk.bold(packageManager.label)}.`, ); }); } export async function addSentryCliConfig( { authToken, org, project, url }: CliSetupConfigContent, setupConfig: CliSetupConfig = rcCliSetupConfig, ): Promise<void> { return traceStep('add-sentry-cli-config', async () => { const configPath = path.join(process.cwd(), setupConfig.filename); const configExists = fs.existsSync(configPath); let configContents = (configExists && fs.readFileSync(configPath, 'utf8')) || ''; configContents = addAuthTokenToSentryConfig( configContents, authToken, setupConfig, ); configContents = addOrgAndProjectToSentryConfig( configContents, org, project, setupConfig, ); configContents = addUrlToSentryConfig(configContents, url, setupConfig); try { await fs.promises.writeFile(configPath, configContents, { encoding: 'utf8', flag: 'w', }); clack.log.success( `${configExists ? 'Saved' : 'Created'} ${chalk.cyan( setupConfig.filename, )}.`, ); } catch { clack.log.warning( `Failed to add auth token to ${chalk.cyan( setupConfig.filename, )}. Uploading ${ setupConfig.name } during build will likely not work locally.`, ); } if (setupConfig.gitignore) { await addCliConfigFileToGitIgnore(setupConfig.filename); } else { clack.log.warn( chalk.yellow('DO NOT commit auth token to your repository!'), ); } }); } function addAuthTokenToSentryConfig( configContents: string, authToken: string | undefined, setupConfig: CliSetupConfig, ): string { if (!authToken) { return configContents; } if (setupConfig.likelyAlreadyHasAuthToken(configContents)) { clack.log.warn( `${chalk.cyan( setupConfig.filename, )} already has auth token. Will not add one.`, ); return configContents; } const newContents = `${configContents}\n${setupConfig.tokenContent( authToken, )}\n`; clack.log.success( `Added auth token to ${chalk.cyan( setupConfig.filename, )} for you to test uploading ${setupConfig.name} locally.`, ); return newContents; } function addOrgAndProjectToSentryConfig( configContents: string, org: string | undefined, project: string | undefined, setupConfig: CliSetupConfig, ): string { if (!org || !project) { return configContents; } if (setupConfig.likelyAlreadyHasOrgAndProject(configContents)) { clack.log.warn( `${chalk.cyan( setupConfig.filename, )} already has org and project. Will not add them.`, ); return configContents; } const newContents = `${configContents}\n${setupConfig.orgAndProjContent( org, project, )}\n`; clack.log.success( `Added default org and project to ${chalk.cyan( setupConfig.filename, )} for you to test uploading ${setupConfig.name} locally.`, ); return newContents; } function addUrlToSentryConfig( configContents: string, url: string | undefined, setupConfig: CliSetupConfig, ): string { if (!url || !setupConfig.urlContent || !setupConfig.likelyAlreadyHasUrl) { return configContents; } if (setupConfig.likelyAlreadyHasUrl(configContents)) { clack.log.warn( `${chalk.cyan(setupConfig.filename)} already has url. Will not add one.`, ); return configContents; } const newContents = `${configContents}\n${setupConfig.urlContent(url)}\n`; clack.log.success( `Added default url to ${chalk.cyan( setupConfig.filename, )} for you to test uploading ${setupConfig.name} locally.`, ); return newContents; } export async function addDotEnvSentryBuildPluginFile( authToken: string, ): Promise<void> { const envVarContent = `# DO NOT commit this file to your repository! # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. # It's used for authentication when uploading source maps. # You can also set this env variable in your own \`.env\` files and remove this file. SENTRY_AUTH_TOKEN=${authToken} `; const dotEnvFilePath = path.join(process.cwd(), SENTRY_DOT_ENV_FILE); const dotEnvFileExists = fs.existsSync(dotEnvFilePath); if (dotEnvFileExists) { const dotEnvFileContent = fs.readFileSync(dotEnvFilePath, 'utf8'); const hasAuthToken = !!dotEnvFileContent.match( /^\s*SENTRY_AUTH_TOKEN\s*=/g, ); if (hasAuthToken) { clack.log.warn( `${chalk.bold( SENTRY_DOT_ENV_FILE, )} already has auth token. Will not add one.`, ); } else { try { await fs.promises.writeFile( dotEnvFilePath, `${dotEnvFileContent}\n${envVarContent}`, { encoding: 'utf8', flag: 'w', }, ); clack.log.success( `Added auth token to ${chalk.bold(SENTRY_DOT_ENV_FILE)}`, ); } catch { clack.log.warning( `Failed to add auth token to ${chalk.bold( SENTRY_DOT_ENV_FILE, )}. Uploading source maps during build will likely not work locally.`, ); } } } else { try { await fs.promises.writeFile(dotEnvFilePath, envVarContent, { encoding: 'utf8', flag: 'w', }); clack.log.success( `Created ${chalk.bold( SENTRY_DOT_ENV_FILE, )} with auth token for you to test source map uploading locally.`, ); } catch { clack.log.warning( `Failed to create ${chalk.bold( SENTRY_DOT_ENV_FILE, )} with auth token. Uploading source maps during build will likely not work locally.`, ); } } await addCliConfigFileToGitIgnore(SENTRY_DOT_ENV_FILE); } async function addCliConfigFileToGitIgnore(filename: string): Promise<void> { //TODO: Add a check to see if the file is already ignored in .gitignore try { await fs.promises.appendFile( path.join(process.cwd(), '.gitignore'), `\n# Sentry Config File\n${filename}\n`, { encoding: 'utf8' }, ); clack.log.success( `Added ${chalk.cyan(filename)} to ${chalk.cyan('.gitignore')}.`, ); } catch { clack.log.error( `Failed adding ${chalk.cyan(filename)} to ${chalk.cyan( '.gitignore', )}. Please add it manually!`, ); } } /** * Checks if @param packageId is listed as a dependency in @param packageJson. * If not, it will ask users if they want to continue without the package. * * Use this function to check if e.g. a the framework of the SDK is installed * * @param packageJson the package.json object * @param packageId the npm name of the package * @param packageName a human readable name of the package */ export async function ensurePackageIsInstalled( packageJson: PackageDotJson, packageId: string, packageName: string, ): Promise<void> { return traceStep('ensure-package-installed', async () => { const installed = hasPackageInstalled(packageId, packageJson); Sentry.setTag(`${packageName.toLowerCase()}-installed`, installed); if (!installed) { Sentry.setTag(`${packageName.toLowerCase()}-installed`, false); const continueWithoutPackage = await abortIfCancelled( clack.confirm({ message: `${packageName} does not seem to be installed. Do you still want to continue?`, initialValue: false, }), ); if (!continueWithoutPackage) { await abort(undefined, 0); } } }); } export async function getPackageDotJson(): Promise<PackageDotJson> { const packageJsonFileContents = await fs.promises .readFile(path.join(process.cwd(), 'package.json'), 'utf8') .catch(() => { clack.log.error( 'Could not find package.json. Make sure to run the wizard in the root of your app!', ); return abort(); }); let packageJson: PackageDotJson | undefined = undefined; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment packageJson = JSON.parse(packageJsonFileContents); } catch { clack.log.error( `Unable to parse your ${chalk.cyan( 'package.json', )}. Make sure it has a valid format!`, ); await abort(); } return packageJson || {}; } async function getPackageManager(): Promise<PackageManager> { const detectedPackageManager = detectPackageManger(); if (detectedPackageManager) { return detectedPackageManager; } const selectedPackageManager: PackageManager | symbol = await abortIfCancelled( clack.select({ message: 'Please select your package manager.', options: packageManagers.map((packageManager) => ({ value: packageManager, label: packageManager.label, })), }), ); Sentry.setTag('package-manager', selectedPackageManager.name); return selectedPackageManager; } export function isUsingTypeScript() { try { return fs.existsSync(path.join(process.cwd(), 'tsconfig.json')); } catch { return false; } } /** * Checks if we already got project data from a previous wizard invocation. * If yes, this data is returned. * Otherwise, we start the login flow and ask the user to select a project. * * Use this function to get project data for the wizard. * * @param options wizard options * @param platform the platform of the wizard * @returns project data (org, project, token, url) */ export async function getOrAskForProjectData( options: WizardOptions, platform?: | 'javascript-nextjs' | 'javascript-remix' | 'javascript-sveltekit' | 'apple-ios' | 'android' | 'react-native', ): Promise<{ sentryUrl: string; selfHosted: boolean; selectedProject: SentryProjectData; authToken: string; }> { if (options.preSelectedProject) { return { selfHosted: options.preSelectedProject.selfHosted, sentryUrl: options.url ?? SAAS_URL, authToken: options.preSelectedProject.authToken, selectedProject: options.preSelectedProject.project, }; } const { url: sentryUrl, selfHosted } = await traceStep( 'ask-self-hosted', () => askForSelfHosted(options.url), ); const { projects, apiKeys } = await traceStep('login', () => askForWizardLogin({ promoCode: options.promoCode, url: sentryUrl, platform: platform, }), ); if (!projects || !projects.length) { clack.log.error( 'No projects found. Please create a project in Sentry and try again.', ); Sentry.setTag('no-projects-found', true); await abort(); // This rejection won't return due to the abort call but TS doesn't know that return Promise.reject(); } const selectedProject = await traceStep('select-project', () => askForProjectSelection(projects), ); const { token } = apiKeys ?? {}; if (!token) { clack.log.error(`Didn't receive an auth token. This shouldn't happen :( Please let us know if you think this is a bug in the wizard: ${chalk.cyan('https://github.com/getsentry/sentry-wizard/issues')}`); clack.log.info(`In the meantime, we'll add a dummy auth token (${chalk.cyan( `"${DUMMY_AUTH_TOKEN}"`, )}) for you to replace later. Create your auth token here: ${chalk.cyan( selfHosted ? `${sentryUrl}organizations/${selectedProject.organization.slug}/settings/auth-tokens` : `https://${selectedProject.organization.slug}.sentry.io/settings/auth-tokens`, )}`); } return { sentryUrl, selfHosted, authToken: apiKeys?.token || DUMMY_AUTH_TOKEN, selectedProject, }; } /** * Asks users if they are using SaaS or self-hosted Sentry and returns the validated URL. * * If users started the wizard with a --url arg, that URL is used as the default and we skip * the self-hosted question. However, the passed url is still validated and in case it's * invalid, users are asked to enter a new one until it is valid. * * @param urlFromArgs the url passed via the --url arg */ async function askForSelfHosted(urlFromArgs?: string): Promise<{ url: string; selfHosted: boolean; }> { if (!urlFromArgs) { const choice: 'saas' | 'self-hosted' | symbol = await abortIfCancelled( clack.select({ message: 'Are you using Sentry SaaS or self-hosted Sentry?', options: [ { value: 'saas', label: 'Sentry SaaS (sentry.io)' }, { value: 'self-hosted', label: 'Self-hosted/on-premise/single-tenant', }, ], }), ); if (choice === 'saas') { Sentry.setTag('url', SAAS_URL); Sentry.setTag('self-hosted', false); return { url: SAAS_URL, selfHosted: false }; } } let validUrl: string | undefined; let tmpUrlFromArgs = urlFromArgs; while (validUrl === undefined) { const url = tmpUrlFromArgs || (await abortIfCancelled( clack.text({ message: `Please enter the URL of your ${ urlFromArgs ? '' : 'self-hosted ' }Sentry instance.`, placeholder: 'https://sentry.io/', }), )); tmpUrlFromArgs = undefined; try { validUrl = new URL(url).toString(); // We assume everywhere else that the URL ends in a slash if (!validUrl.endsWith('/')) { validUrl += '/'; } } catch { clack.log.error( 'Please enter a valid URL. (It should look something like "https://sentry.mydomain.com/")', ); } } const isSelfHostedUrl = new URL(validUrl).host !== new URL(SAAS_URL).host; Sentry.setTag('url', validUrl); Sentry.setTag('self-hosted', isSelfHostedUrl); return { url: validUrl, selfHosted: true }; } async function askForWizardLogin(options: { url: string; promoCode?: string; platform?: | 'javascript-nextjs' | 'javascript-remix' | 'javascript-sveltekit' | 'apple-ios' | 'android' | 'react-native'; }): Promise<WizardProjectData> { Sentry.setTag('has-promo-code', !!options.promoCode); let hasSentryAccount = await clack.confirm({ message: 'Do you already have a Sentry account?', }); hasSentryAccount = await abortIfCancelled(hasSentryAccount); Sentry.setTag('already-has-sentry-account', hasSentryAccount); let wizardHash: string; try { wizardHash = ( await axios.get<{ hash: string }>(`${options.url}api/0/wizard/`) ).data.hash; } catch (e: unknown) { if (options.url !== SAAS_URL) { clack.log.error('Loading Wizard failed. Did you provide the right URL?'); clack.log.info(JSON.stringify(e, null, 2)); await abort( chalk.red( 'Please check your configuration and try again.\n\n Let us know if you think this is an issue with the wizard or Sentry: https://github.com/getsentry/sentry-wizard/issues', ), ); } else { clack.log.error('Loading Wizard failed.'); clack.log.info(JSON.stringify(e, null, 2)); await abort( chalk.red( 'Please try again in a few minutes and let us know if this issue persists: https://github.com/getsentry/sentry-wizard/issues', ), ); } } const loginUrl = new URL( `${options.url}account/settings/wizard/${wizardHash!}/`, ); if (!hasSentryAccount) { loginUrl.searchParams.set('signup', '1'); if (options.platform) { loginUrl.searchParams.set('project_platform', options.platform); } } if (options.promoCode) { loginUrl.searchParams.set('code', options.promoCode); } const urlToOpen = loginUrl.toString(); clack.log.info( `${chalk.bold( `If the browser window didn't open automatically, please open the following link to ${ hasSentryAccount ? 'log' : 'sign' } into Sentry:`, )}\n\n${chalk.cyan(urlToOpen)}`, ); opn(urlToOpen).catch(() => { // opn throws in environments that don't have a browser (e.g. remote shells) so we just noop here }); const loginSpinner = clack.spinner(); loginSpinner.start('Waiting for you to log in using the link above'); const data = await new Promise<WizardProjectData>((resolve) => { const pollingInterval = setInterval(() => { axios .get<WizardProjectData>(`${options.url}api/0/wizard/${wizardHash}/`, { headers: { 'Accept-Encoding': 'deflate', }, }) .then((result) => { resolve(result.data); clearTimeout(timeout); clearInterval(pollingInterval); void axios.delete(`${options.url}api/0/wizard/${wizardHash}/`); }) .catch(() => { // noop - just try again }); }, 500); const timeout = setTimeout(() => { clearInterval(pollingInterval); loginSpinner.stop( 'Login timed out. No worries - it happens to the best of us.', ); Sentry.setTag('opened-wizard-link', false); void abort('Please restart the Wizard and log in to complete the setup.'); }, 180_000); }); loginSpinner.stop('Login complete.'); Sentry.setTag('opened-wizard-link', true); return data; } async function askForProjectSelection( projects: SentryProjectData[], ): Promise<SentryProjectData> { const label = (project: SentryProjectData): string => { return `${project.organization.slug}/${project.slug}`; }; const sortedProjects = [...projects]; sortedProjects.sort((a: SentryProjectData, b: SentryProjectData) => { return label(a).localeCompare(label(b)); }); const selection: SentryProjectData | symbol = await abortIfCancelled( clack.select({ maxItems: 12, message: 'Select your Sentry project.', options: sortedProjects.map((project) => { return { value: project, label: label(project), }; }), }), ); Sentry.setTag('project', selection.slug); Sentry.setUser({ id: selection.organization.slug }); return selection; } /** * Asks users if they have a config file for @param tool (e.g. Vite). * If yes, asks users to specify the path to their config file. * * Use this helper function as a fallback mechanism if the lookup for * a config file with its most usual location/name fails. * * @param toolName Name of the tool for which we're looking for the config file * @param configFileName Name of the most common config file name (e.g. vite.config.js) * * @returns a user path to the config file or undefined if the user doesn't have a config file */ export async function askForToolConfigPath( toolName: string, configFileName: string, ): Promise<string | undefined> { const hasConfig = await abortIfCancelled( clack.confirm({ message: `Do you have a ${toolName} config file (e.g. ${chalk.cyan( configFileName, )})?`, initialValue: true, }), ); if (!hasConfig) { return undefined; } return await abortIfCancelled( clack.text({ message: `Please enter the path to your ${toolName} config file:`, placeholder: path.join('.', configFileName), validate: (value) => { if (!value) { return 'Please enter a path.'; } try { fs.accessSync(value); } catch { return 'Could not access the file at this path.'; } }, }), ); } /** * Prints copy/paste-able instructions to the console. * Afterwards asks the user if they added the code snippet to their file. * * While there's no point in providing a "no" answer here, it gives users time to fulfill the * task before the wizard continues with additional steps. * * Use this function if you want to show users instructions on how to add/modify * code in their file. This is helpful if automatic insertion failed or is not possible/feasible. * * @param filename the name of the file to which the code snippet should be applied. * If a path is provided, only the filename will be used. * * @param codeSnippet the snippet to be printed. Use {@link makeCodeSnippet} to create the * diff-like format for visually highlighting unchanged or modified lines of code. * * @param hint (optional) a hint to be printed after the main instruction to add * the code from @param codeSnippet to their @param filename. * * More guidelines on copy/paste instructions: * @see {@link https://develop.sentry.dev/sdk/setup-wizards/#copy--paste-snippets} * * TODO: refactor copy paste instructions across different wizards to use this function. * this might require adding a custom message parameter to the function */ export async function showCopyPasteInstructions( filename: string, codeSnippet: string, hint?: string, ): Promise<void> { clack.log.step( `Add the following code to your ${chalk.cyan( path.basename(filename), )} file:${hint ? chalk.dim(` (${chalk.dim(hint)})`) : ''}`, ); // Padding the code snippet to be printed with a \n at the beginning and end // This makes it easier to distinguish the snippet from the rest of the output // Intentionally logging directly to console here so that the code can be copied/pasted directly // eslint-disable-next-line no-console console.log(`\n${codeSnippet}\n`); await abortIfCancelled( clack.select({ message: 'Did you apply the snippet above?', options: [{ label: 'Yes, continue!', value: true }], initialValue: true, }), ); } /** * Callback that exposes formatting helpers for a code snippet. * @param unchanged - Formats text as old code. * @param plus - Formats text as new code. * @param minus - Formats text as removed code. */ type CodeSnippetFormatter = ( unchanged: (txt: string) => string, plus: (txt: string) => string, minus: (txt: string) => string, ) => string; /** * Crafts a code snippet that can be used to e.g. * - print copy/paste instructions to the console * - create a new config file. * * @param colors set this to true if you want the final snippet to be colored. * This is useful for printing the snippet to the console as part of copy/paste instructions. * * @param callback the callback that returns the formatted code snippet. * It exposes takes the helper functions for marking code as unchanged, new or removed. * These functions no-op if no special formatting should be applied * and otherwise apply the appropriate formatting/coloring. * (@see {@link CodeSnippetFormatter}) * * @see {@link showCopyPasteInstructions} for the helper with which to display the snippet in the console. * * @returns a string containing the final, formatted code snippet. */ export function makeCodeSnippet( colors: boolean, callback: CodeSnippetFormatter, ): string { const unchanged = (txt: string) => (colors ? chalk.grey(txt) : txt); const plus = (txt: string) => (colors ? chalk.greenBright(txt) : txt); const minus = (txt: string) => (colors ? chalk.redBright(txt) : txt); return callback(unchanged, plus, minus); } /** * Creates a new config file with the given @param filepath and @param codeSnippet. * * Use this function to create a new config file for users. This is useful * when users answered that they don't yet have a config file for a tool. * * (This doesn't mean that they don't yet have some other way of configuring * their tool but we can leave it up to them to figure out how to merge configs * here.) * * @param filepath absolute path to the new config file * @param codeSnippet the snippet to be inserted into the file * @param moreInformation (optional) the message to be printed after the file was created * For example, this can be a link to more information about configuring the tool. * * @returns true on success, false otherwise */ export async function createNewConfigFile( filepath: string, codeSnippet: string, moreInformation?: string, ): Promise<boolean> { if (!path.isAbsolute(filepath)) { debug(`createNewConfigFile: filepath is not absolute: ${filepath}`); return false; } const prettyFilename = chalk.cyan(path.relative(process.cwd(), filepath)); try { await fs.promises.writeFile(filepath, codeSnippet); clack.log.success(`Added new ${prettyFilename} file.`); if (moreInformation) { clack.log.info(chalk.gray(moreInformation)); } return true; } catch (e) { debug(e); clack.log.warn( `Could not create a new ${prettyFilename} file. Please create one manually and follow the instructions below.`, ); } return false; } export async function askShouldCreateExamplePage( customRoute?: string, ): Promise<boolean> { const route = chalk.cyan(customRoute ?? '/sentry-example-page'); return traceStep('ask-create-example-page', () => abortIfCancelled( clack.select({ message: `Do you want to create an example page ("${route}") to test your Sentry setup?`, options: [ { value: true, label: 'Yes', hint: 'Recommended - Check your git status before committing!', }, { value: false, label: 'No' }, ], }), ), ); }