UNPKG

@sentry/wizard

Version:

Sentry wizard helping you to configure your project

591 lines (517 loc) 16.9 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 { setInterval } from 'timers'; import { URL } from 'url'; import { promisify } from 'util'; import * as Sentry from '@sentry/node'; import { windowedSelect } from './vendor/clack-custom-select'; const SAAS_URL = 'https://sentry.io/'; interface WizardProjectData { apiKeys: { token: string; }; projects: SentryProjectData[]; } export type PackageDotJson = { scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string>; }; export interface SentryProjectData { id: string; slug: string; name: string; organization: { slug: string; }; keys: [{ dsn: { public: string } }]; } 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; }): 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 || 'This Wizard will help you to set up Sentry for your application.\nThank you for using Sentry :)'; if (options.promoCode) { welcomeText += `\n\nUsing promo-code: ${options.promoCode}`; } if (wizardPackage.version) { welcomeText += `\n\nVersion: ${wizardPackage.version}`; } clack.note(welcomeText); } export async function confirmContinueEvenThoughNoGitRepo(): Promise<void> { try { childProcess.execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore', }); } catch { const continueWithoutGit = await abortIfCancelled( clack.confirm({ message: 'You are not inside a git repository. The wizard will create and update files. Do you still want to continue?', }), ); Sentry.setTag('continue-without-git', continueWithoutGit); if (!continueWithoutGit) { await abort(undefined, 0); } } } export async function askForWizardLogin(options: { url: string; promoCode?: string; platform?: 'javascript-nextjs' | 'javascript-sveltekit'; }): 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 { if (options.url !== SAAS_URL) { clack.log.error('Loading Wizard failed. Did you provide the right URL?'); 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.'); 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); } clack.log.info( `${chalk.bold( `Please open the following link in your browser to ${ hasSentryAccount ? 'log' : 'sign' } into Sentry:`, )}\n\n${chalk.cyan(loginUrl.toString())}`, ); const loginSpinner = clack.spinner(); loginSpinner.start( 'Waiting for you to click the link above 👆. Take your time.', ); const data = await new Promise<WizardProjectData>((resolve) => { const pollingInterval = setInterval(() => { axios .get<WizardProjectData>(`${options.url}api/0/wizard/${wizardHash}/`) .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; } export async function askForProjectSelection( projects: SentryProjectData[], ): Promise<SentryProjectData> { const selection: SentryProjectData | symbol = await abortIfCancelled( windowedSelect({ maxItems: 12, message: 'Select your Sentry project.', options: projects.map((project) => { return { value: project, label: `${project.organization.slug}/${project.slug}`, }; }), }), ); Sentry.setTag('project', selection.slug); Sentry.setUser({ id: selection.organization.slug }); return selection; } export async function installPackage({ packageName, alreadyInstalled, }: { packageName: string; alreadyInstalled: boolean; }): Promise<void> { if (alreadyInstalled) { 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)}.`, ); try { if (packageManager === 'yarn') { await promisify(childProcess.exec)(`yarn add ${packageName}@latest`); } else if (packageManager === 'pnpm') { await promisify(childProcess.exec)(`pnpm add ${packageName}@latest`); } else if (packageManager === 'npm') { await promisify(childProcess.exec)(`npm install ${packageName}@latest`); } } 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)}.`, ); } export async function askForSelfHosted(): Promise<{ url: string; selfHosted: boolean; }> { 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; while (validUrl === undefined) { const url = await abortIfCancelled( clack.text({ message: 'Please enter the URL of your self-hosted Sentry instance.', placeholder: 'https://sentry.io/', }), ); 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 "http://sentry.mydomain.com/")', ); } } Sentry.setTag('url', validUrl); Sentry.setTag('self-hosted', true); return { url: validUrl, selfHosted: true }; } export async function addSentryCliRc(authToken: string): Promise<void> { const clircExists = fs.existsSync(path.join(process.cwd(), '.sentryclirc')); if (clircExists) { const clircContents = fs.readFileSync( path.join(process.cwd(), '.sentryclirc'), 'utf8', ); const likelyAlreadyHasAuthToken = !!( clircContents.includes('[auth]') && clircContents.match(/token=./g) ); if (likelyAlreadyHasAuthToken) { clack.log.warn( `${chalk.bold( '.sentryclirc', )} already has auth token. Will not add one.`, ); } else { try { await fs.promises.writeFile( path.join(process.cwd(), '.sentryclirc'), `${clircContents}\n[auth]\ntoken=${authToken}\n`, { encoding: 'utf8', flag: 'w' }, ); clack.log.success( `Added auth token to ${chalk.bold( '.sentryclirc', )} for you to test uploading source maps locally.`, ); } catch { clack.log.warning( `Failed to add auth token to ${chalk.bold( '.sentryclirc', )}. Uploading source maps during build will likely not work locally.`, ); } } } else { try { await fs.promises.writeFile( path.join(process.cwd(), '.sentryclirc'), `[auth]\ntoken=${authToken}\n`, { encoding: 'utf8', flag: 'w' }, ); clack.log.success( `Created ${chalk.bold( '.sentryclirc', )} with auth token for you to test uploading source maps locally.`, ); } catch { clack.log.warning( `Failed to create ${chalk.bold( '.sentryclirc', )} with auth token. Uploading source maps during build will likely not work locally.`, ); } } await addAuthTokenFileToGitIgnore('.sentryclirc'); } export async function addDotEnvSentryBuildPluginFile( authToken: string, ): Promise<void> { const DOT_ENV_FILE = '.env.sentry-build-plugin'; 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(), 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(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(DOT_ENV_FILE)}`); } catch { clack.log.warning( `Failed to add auth token to ${chalk.bold( 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( DOT_ENV_FILE, )} with auth token for you to test source map uploading locally.`, ); } catch { clack.log.warning( `Failed to create ${chalk.bold( DOT_ENV_FILE, )} with auth token. Uploading source maps during build will likely not work locally.`, ); } } await addAuthTokenFileToGitIgnore(DOT_ENV_FILE); } async function addAuthTokenFileToGitIgnore(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 Auth Token\n${filename}\n`, { encoding: 'utf8' }, ); clack.log.success( `Added ${chalk.bold(filename)} to ${chalk.bold('.gitignore')}.`, ); } catch { clack.log.error( `Failed adding ${chalk.bold(filename)} to ${chalk.bold( '.gitignore', )}. Please add it manually!`, ); } } export async function ensurePackageIsInstalled( packageJson: PackageDotJson, packageId: string, packageName: string, ) { if (!hasPackageInstalled(packageId, packageJson)) { 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 package.json. Make sure it has a valid format!', ); await abort(); } return packageJson || {}; } export function hasPackageInstalled( packageName: string, packageJson: PackageDotJson, ): boolean { return ( !!packageJson?.dependencies?.[packageName] || !!packageJson?.devDependencies?.[packageName] ); } async function getPackageManager(): Promise<string> { let detectedPackageManager; if (fs.existsSync(path.join(process.cwd(), 'yarn.lock'))) { detectedPackageManager = 'yarn'; } else if (fs.existsSync(path.join(process.cwd(), 'package-lock.json'))) { detectedPackageManager = 'npm'; } else if (fs.existsSync(path.join(process.cwd(), 'pnpm-lock.yaml'))) { detectedPackageManager = 'pnpm'; } if (detectedPackageManager) { return detectedPackageManager; } const selectedPackageManager: string | symbol = await abortIfCancelled( clack.select({ message: 'Please select your package manager.', options: [ { value: 'npm', label: 'Npm' }, { value: 'yarn', label: 'Yarn' }, { value: 'pnpm', label: 'Pnpm' }, ], }), ); Sentry.setTag('package-manager', selectedPackageManager); return selectedPackageManager; }