UNPKG

@sentry/wizard

Version:

Sentry wizard helping you to configure your project

1,074 lines (1,068 loc) 62.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.askToRunBuildOrEnterPathOrProceed = exports.artifactsExist = exports.askShouldAddPackageOverride = exports.askShouldInstallPackage = exports.featureSelectionPrompt = exports.askShouldCreateExampleComponent = exports.askShouldCreateExamplePage = exports.createNewConfigFile = exports.makeCodeSnippet = exports.showCopyPasteInstructions = exports.askForToolConfigPath = exports.askForWizardLogin = exports.getOrAskForProjectData = exports.isUsingTypeScript = exports.getPackageManager = exports.updatePackageDotJson = exports.getPackageDotJson = exports.ensurePackageIsInstalled = exports.runBiomeIfInstalled = exports.runPrettierIfInstalled = exports.runFormatters = exports.addDotEnvSentryBuildPluginFile = exports.addSentryCliConfig = exports.installPackage = exports.confirmContinueIfPackageVersionNotSupported = exports.askForItemSelection = exports.askToInstallSentryCLI = exports.confirmContinueIfNoOrDirtyGitRepo = exports.printWelcome = exports.abortIfCancelled = exports.abort = exports.propertiesCliSetupConfig = exports.rcCliSetupConfig = exports.SENTRY_PROPERTIES_FILE = exports.SENTRY_CLI_RC_FILE = exports.SENTRY_DOT_ENV_FILE = void 0; const childProcess = __importStar(require("node:child_process")); const fs = __importStar(require("node:fs")); const node_child_process_1 = require("node:child_process"); const node_path_1 = require("node:path"); const node_timers_1 = require("node:timers"); const node_url_1 = require("node:url"); const path = __importStar(require("path")); const package_manager_1 = require("../../utils/package-manager"); // @ts-expect-error - clack is ESM and TS complains about that. It works though const clack = __importStar(require("@clack/prompts")); const Sentry = __importStar(require("@sentry/node")); const axios_1 = __importDefault(require("axios")); const chalk_1 = __importDefault(require("chalk")); const opn_1 = __importDefault(require("opn")); const telemetry_1 = require("../../telemetry"); const version_1 = require("../../version"); const debug_1 = require("../debug"); const package_json_1 = require("../package-json"); const package_manager_2 = require("../package-manager"); const semver_1 = require("../semver"); const git_1 = require("../git"); exports.SENTRY_DOT_ENV_FILE = '.env.sentry-build-plugin'; exports.SENTRY_CLI_RC_FILE = '.sentryclirc'; exports.SENTRY_PROPERTIES_FILE = 'sentry.properties'; const SAAS_URL = 'https://sentry.io/'; const DUMMY_AUTH_TOKEN = '_YOUR_SENTRY_AUTH_TOKEN_'; exports.rcCliSetupConfig = { filename: exports.SENTRY_CLI_RC_FILE, name: 'source maps', gitignore: true, likelyAlreadyHasAuthToken: function (contents) { return !!(contents.includes('[auth]') && contents.match(/token=./g)); }, tokenContent: function (authToken) { return `[auth]\ntoken=${authToken}`; }, likelyAlreadyHasOrgAndProject: function (contents) { return !!(contents.includes('[defaults]') && contents.match(/org=./g) && contents.match(/project=./g)); }, orgAndProjContent: function (org, project) { return `[defaults]\norg=${org}\nproject=${project}`; }, }; exports.propertiesCliSetupConfig = { filename: exports.SENTRY_PROPERTIES_FILE, gitignore: true, name: 'debug files', likelyAlreadyHasAuthToken(contents) { return !!contents.match(/auth\.token=./g); }, tokenContent(authToken) { return `auth.token=${authToken}`; }, likelyAlreadyHasOrgAndProject(contents) { return !!(contents.match(/defaults\.org=./g) && contents.match(/defaults\.project=./g)); }, orgAndProjContent(org, project) { return `defaults.org=${org}\ndefaults.project=${project}`; }, likelyAlreadyHasUrl(contents) { return !!contents.match(/defaults\.url=./g); }, urlContent(url) { return `defaults.url=${url}`; }, }; /** * Aborts the wizard and sets the Sentry transaction status to `cancelled` or `aborted`. * * @param message The message to display to the user. * @param status The status to set on the Sentry transaction. Defaults to `1`. */ async function abort(message, status) { clack.outro(message ?? 'Wizard setup cancelled.'); const activeSpan = Sentry.getActiveSpan(); const rootSpan = activeSpan ? Sentry.getRootSpan(activeSpan) : undefined; // 'cancelled' doesn't increase the `failureRate()` shown in the Sentry UI // 'aborted' increases the failure rate // see: https://docs.sentry.io/product/insights/overview/metrics/#failure-rate if (rootSpan) { rootSpan.setStatus({ code: status === 0 ? 1 : 2, message: status === 0 ? 'cancelled' : 'aborted', }); rootSpan.end(); } const sentrySession = Sentry.getCurrentScope().getSession(); if (sentrySession) { sentrySession.status = status === 0 ? 'abnormal' : 'crashed'; Sentry.captureSession(true); } await Sentry.flush(3000).catch(() => { // Ignore flush errors during abort }); return process.exit(status ?? 1); } exports.abort = abort; async function abortIfCancelled(input) { if (clack.isCancel(await input)) { clack.cancel('Wizard setup cancelled.'); const activeSpan = Sentry.getActiveSpan(); const rootSpan = activeSpan ? Sentry.getRootSpan(activeSpan) : undefined; if (rootSpan) { rootSpan.setStatus({ code: 1, message: 'cancelled' }); rootSpan.end(); } Sentry.captureSession(true); await Sentry.flush(3000).catch(() => { // Ignore flush errors during abort }); process.exit(0); } else { return input; } } exports.abortIfCancelled = abortIfCancelled; function printWelcome(options) { // eslint-disable-next-line no-console console.log(''); clack.intro(chalk_1.default.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}`; } welcomeText = `${welcomeText}\n\nVersion: ${version_1.WIZARD_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_1.default.cyanBright('sentry-wizard --disable-telemetry')}.`; } clack.note(welcomeText); } exports.printWelcome = printWelcome; /** * Confirms if the user wants to continue with the wizard if the project is not a git repository. * * @param options.ignoreGitChanges If true, the wizard will not check if the project is a git repository. * @param options.cwd The directory of the project. If undefined, the current process working directory will be used. */ async function confirmContinueIfNoOrDirtyGitRepo(options) { return (0, telemetry_1.traceStep)('check-git-status', async () => { if (!(0, git_1.isInGitRepo)({ cwd: options.cwd, }) && options.ignoreGitChanges !== true) { 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); } // return early to avoid checking for uncommitted files return; } const uncommittedOrUntrackedFiles = (0, git_1.getUncommittedOrUntrackedFiles)(); if (uncommittedOrUntrackedFiles.length && options.ignoreGitChanges !== true) { 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); } } }); } exports.confirmContinueIfNoOrDirtyGitRepo = confirmContinueIfNoOrDirtyGitRepo; async function askToInstallSentryCLI() { return await abortIfCancelled(clack.confirm({ message: "You don't have Sentry CLI installed. Do you want to install it?", })); } exports.askToInstallSentryCLI = askToInstallSentryCLI; async function askForItemSelection(items, message) { const selection = await abortIfCancelled(clack.select({ maxItems: 12, message: message, options: items.map((item, index) => { return { value: { value: item, index: index }, label: item, }; }), })); return selection; } exports.askForItemSelection = askForItemSelection; async function confirmContinueIfPackageVersionNotSupported({ packageId, packageName, packageVersion, acceptableVersions, note, }) { return (0, telemetry_1.traceStep)(`check-package-version`, async () => { Sentry.setTag(`${packageName.toLowerCase()}-version`, packageVersion); const isSupportedVersion = (0, semver_1.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); } }); } exports.confirmContinueIfPackageVersionNotSupported = confirmContinueIfPackageVersionNotSupported; /** * 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! */ async function installPackage({ packageName, alreadyInstalled, askBeforeUpdating = true, packageNameDisplayLabel, packageManager, forceInstall = false, devDependency = false, }) { return (0, telemetry_1.traceStep)('install-package', async () => { if (alreadyInstalled && askBeforeUpdating) { const shouldUpdatePackage = await abortIfCancelled(clack.confirm({ message: `The ${chalk_1.default.bold.cyan(packageNameDisplayLabel ?? packageName)} package is already installed. Do you want to update it to the latest version?`, })); if (!shouldUpdatePackage) { return {}; } } const sdkInstallSpinner = clack.spinner(); const pkgManager = packageManager || (await getPackageManager()); sdkInstallSpinner.start(`${alreadyInstalled ? 'Updating' : 'Installing'} ${chalk_1.default.bold.cyan(packageNameDisplayLabel ?? packageName)} with ${chalk_1.default.bold(pkgManager.label)}.`); try { await new Promise((resolve, reject) => { const installArgs = [ pkgManager.installCommand, ...(devDependency ? ['-D'] : []), pkgManager.registry ? `${pkgManager.registry}:${packageName}` : packageName, ...(pkgManager.flags ? pkgManager.flags.split(' ') : []), ...(forceInstall ? [pkgManager.forceInstallFlag] : []), ]; const stringifiedInstallCmd = `${pkgManager.name} ${installArgs.join(' ')}`; function handleErrorAndReject(code, cause, type) { // Write a log file so we can better troubleshoot issues fs.writeFileSync((0, node_path_1.join)(process.cwd(), `sentry-wizard-installation-error-${Date.now()}.log`), stderr, { encoding: 'utf8' }); Sentry.captureException('Package Installation Error', { tags: { 'install-command': stringifiedInstallCmd, 'package-manager': pkgManager.name, 'package-name': packageName, 'error-type': type, }, }); reject(new Error(`Installation command ${chalk_1.default.cyan(stringifiedInstallCmd)} exited with code ${code ?? 'null'}.`, { cause, })); } const installProcess = childProcess.spawn(pkgManager.name, installArgs, { shell: true, // Ignoring `stdout` to prevent certain node + yarn v4 (observed on ubuntu + snap) // combinations from crashing here. See #851 stdio: ['pipe', 'ignore', 'pipe'], }); let stderr = ''; // Defining data as unknown to avoid TS and ESLint errors because of `any` type installProcess.stderr.on('data', (data) => { if (data && data.toString && typeof data.toString === 'function') { stderr += data.toString(); } }); installProcess.on('error', (err) => { handleErrorAndReject(null, err, 'spawn_error'); }); installProcess.on('close', (code) => { if (code !== 0) { handleErrorAndReject(code, stderr, 'process_error'); } else { resolve(); } }); }); } catch (e) { sdkInstallSpinner.stop('Installation failed.'); clack.log.error(`${chalk_1.default.red('Encountered the following error during installation:')}\n\n${e}\n\n${chalk_1.default.dim("The wizard has created a `sentry-wizard-installation-error-*.log` file. If you think this issue is caused by the Sentry wizard, create an issue on GitHub and include the log file's content:\nhttps://github.com/getsentry/sentry-wizard/issues")}`); await abort(); } sdkInstallSpinner.stop(`${alreadyInstalled ? 'Updated' : 'Installed'} ${chalk_1.default.bold.cyan(packageNameDisplayLabel ?? packageName)} with ${chalk_1.default.bold(pkgManager.label)}.`); return { packageManager: pkgManager }; }); } exports.installPackage = installPackage; async function addSentryCliConfig({ authToken, org, project, url }, setupConfig = exports.rcCliSetupConfig) { return (0, telemetry_1.traceStep)('add-sentry-cli-config', async () => { const configPath = (0, node_path_1.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_1.default.cyan(setupConfig.filename)}.`); } catch { clack.log.warning(`Failed to add auth token to ${chalk_1.default.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_1.default.yellow('DO NOT commit auth token to your repository!')); } }); } exports.addSentryCliConfig = addSentryCliConfig; function addAuthTokenToSentryConfig(configContents, authToken, setupConfig) { if (!authToken) { return configContents; } if (setupConfig.likelyAlreadyHasAuthToken(configContents)) { clack.log.warn(`${chalk_1.default.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_1.default.cyan(setupConfig.filename)} for you to test uploading ${setupConfig.name} locally.`); return newContents; } function addOrgAndProjectToSentryConfig(configContents, org, project, setupConfig) { if (!org || !project) { return configContents; } if (setupConfig.likelyAlreadyHasOrgAndProject(configContents)) { clack.log.warn(`${chalk_1.default.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_1.default.cyan(setupConfig.filename)} for you to test uploading ${setupConfig.name} locally.`); return newContents; } function addUrlToSentryConfig(configContents, url, setupConfig) { if (!url || !setupConfig.urlContent || !setupConfig.likelyAlreadyHasUrl) { return configContents; } if (setupConfig.likelyAlreadyHasUrl(configContents)) { clack.log.warn(`${chalk_1.default.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_1.default.cyan(setupConfig.filename)} for you to test uploading ${setupConfig.name} locally.`); return newContents; } async function addDotEnvSentryBuildPluginFile(authToken) { 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 = (0, node_path_1.join)(process.cwd(), exports.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_1.default.bold.cyan(exports.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_1.default.bold.cyan(exports.SENTRY_DOT_ENV_FILE)}`); } catch { clack.log.warning(`Failed to add auth token to ${chalk_1.default.bold.cyan(exports.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_1.default.bold.cyan(exports.SENTRY_DOT_ENV_FILE)} with auth token for you to test source map uploading locally.`); } catch { clack.log.warning(`Failed to create ${chalk_1.default.bold.cyan(exports.SENTRY_DOT_ENV_FILE)} with auth token. Uploading source maps during build will likely not work locally.`); } } await addCliConfigFileToGitIgnore(exports.SENTRY_DOT_ENV_FILE); } exports.addDotEnvSentryBuildPluginFile = addDotEnvSentryBuildPluginFile; async function addCliConfigFileToGitIgnore(filename) { const gitignorePath = (0, node_path_1.join)(process.cwd(), '.gitignore'); try { const gitignoreContent = await fs.promises.readFile(gitignorePath, 'utf8'); if (gitignoreContent.split(/\r?\n/).includes(filename)) { clack.log.info(`${chalk_1.default.bold('.gitignore')} already has ${chalk_1.default.bold(filename)}. Will not add it again.`); return; } await fs.promises.appendFile(gitignorePath, `\n# Sentry Config File\n${filename}\n`, { encoding: 'utf8' }); clack.log.success(`Added ${chalk_1.default.cyan(filename)} to ${chalk_1.default.cyan('.gitignore')}.`); } catch { clack.log.error(`Failed adding ${chalk_1.default.cyan(filename)} to ${chalk_1.default.cyan('.gitignore')}. Please add it manually!`); } } /** * Helper function to get the list of changed or untracked files for formatting. * @returns Space-separated string of file paths, or null if not in git repo or no files changed. */ function getFormatterTargetFiles() { if (!(0, git_1.isInGitRepo)({ cwd: undefined })) { return null; } const changedOrUntrackedFiles = (0, git_1.getUncommittedOrUntrackedFiles)() .map((filename) => { return filename.startsWith('- ') ? filename.slice(2) : filename; }) .join(' '); return changedOrUntrackedFiles.length ? changedOrUntrackedFiles : null; } /** * Runs available formatters (Prettier and/or Biome) on the changed or untracked files in the project. * This function provides a unified interface for running multiple formatters with a single user prompt. * * @param _opts The directory of the project. If undefined, the current process working directory will be used. */ async function runFormatters(_opts) { return (0, telemetry_1.traceStep)('run-formatters', async () => { const targetFiles = getFormatterTargetFiles(); if (!targetFiles) { return; } const packageJson = await getPackageDotJson(); const prettierInstalled = (0, package_json_1.hasPackageInstalled)('prettier', packageJson); const biomeInstalled = (0, package_json_1.hasPackageInstalled)('@biomejs/biome', packageJson); Sentry.setTag('prettier-installed', prettierInstalled); Sentry.setTag('biome-installed', biomeInstalled); if (!prettierInstalled && !biomeInstalled) { return; } // Determine prompt message based on what's installed const formattersAvailable = []; if (prettierInstalled) formattersAvailable.push('Prettier'); if (biomeInstalled) formattersAvailable.push('Biome'); const message = formattersAvailable.length === 1 ? `Looks like you have ${formattersAvailable[0]} in your project. Do you want to run it on your files?` : `Looks like you have ${formattersAvailable.join(' and ')} in your project. Do you want to run them on your files?`; const shouldRun = await abortIfCancelled(clack.confirm({ message })); if (!shouldRun) { return; } const spinner = clack.spinner(); spinner.start('Running formatters on your files.'); try { // Run Prettier first if installed (handles general formatting) if (prettierInstalled) { await new Promise((resolve, reject) => { childProcess.exec(`npx prettier --ignore-unknown --write ${targetFiles}`, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } // Run Biome if installed (handles linting + additional formatting) if (biomeInstalled) { // Format first await new Promise((resolve) => { childProcess.exec(`npx @biomejs/biome format --write ${targetFiles}`, () => { // Ignore errors, just continue resolve(); }); }); // Then lint with fixes (using --unsafe for auto-fixable issues) // See: https://biomejs.dev/linter/#unsafe-fixes // The --unsafe flag applies potentially behavior-changing fixes like removing unused imports. // This is acceptable for wizard-generated code which may have fixable issues. await new Promise((resolve) => { childProcess.exec(`npx @biomejs/biome check --write --unsafe ${targetFiles}`, () => { // Ignore errors, Biome exits non-zero if there are remaining issues resolve(); }); }); } spinner.stop('Formatters have processed your files.'); } catch (e) { spinner.stop('Formatting encountered an issue.'); clack.log.warn('Formatting encountered an issue. There may be formatting or linting issues in your updated files.'); } }); } exports.runFormatters = runFormatters; /** * Runs prettier on the changed or untracked files in the project. * * @param options.cwd The directory of the project. If undefined, the current process working directory will be used. */ async function runPrettierIfInstalled(opts) { return (0, telemetry_1.traceStep)('run-prettier', async () => { if (!(0, git_1.isInGitRepo)({ cwd: opts.cwd })) { // We only run formatting on changed files. If we're not in a git repo, we can't find // changed files. So let's early-return without showing any formatting-related messages. return; } const changedOrUntrackedFiles = (0, git_1.getUncommittedOrUntrackedFiles)() .map((filename) => { return filename.startsWith('- ') ? filename.slice(2) : filename; }) .join(' '); if (!changedOrUntrackedFiles.length) { // Likewise, if we can't find changed or untracked files, there's no point in running Prettier. return; } const packageJson = await getPackageDotJson(); const prettierInstalled = (0, package_json_1.hasPackageInstalled)('prettier', packageJson); Sentry.setTag('prettier-installed', prettierInstalled); if (!prettierInstalled) { return; } // prompt the user if they want to run prettier const shouldRunPrettier = await abortIfCancelled(clack.confirm({ message: 'Looks like you have Prettier in your project. Do you want to run it on your files?', })); if (!shouldRunPrettier) { return; } const prettierSpinner = clack.spinner(); prettierSpinner.start('Running Prettier on your files.'); try { await new Promise((resolve, reject) => { childProcess.exec(`npx prettier --ignore-unknown --write ${changedOrUntrackedFiles}`, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } catch (e) { prettierSpinner.stop('Prettier failed to run.'); clack.log.warn('Prettier failed to run. There may be formatting issues in your updated files.'); return; } prettierSpinner.stop('Prettier has formatted your files.'); }); } exports.runPrettierIfInstalled = runPrettierIfInstalled; /** * Runs Biome on the changed or untracked files in the project. * * @param options.cwd The directory of the project. If undefined, the current process working directory will be used. */ async function runBiomeIfInstalled(opts) { return (0, telemetry_1.traceStep)('run-biome', async () => { if (!(0, git_1.isInGitRepo)({ cwd: opts.cwd })) { // We only run formatting on changed files. If we're not in a git repo, we can't find // changed files. So let's early-return without showing any formatting-related messages. return; } const changedOrUntrackedFiles = (0, git_1.getUncommittedOrUntrackedFiles)() .map((filename) => { return filename.startsWith('- ') ? filename.slice(2) : filename; }) .join(' '); if (!changedOrUntrackedFiles.length) { // Likewise, if we can't find changed or untracked files, there's no point in running Biome. return; } const packageJson = await getPackageDotJson(); const biomeInstalled = (0, package_json_1.hasPackageInstalled)('@biomejs/biome', packageJson); Sentry.setTag('biome-installed', biomeInstalled); if (!biomeInstalled) { return; } // prompt the user if they want to run biome const shouldRunBiome = await abortIfCancelled(clack.confirm({ message: 'Looks like you have Biome in your project. Do you want to run it on your files?', })); if (!shouldRunBiome) { return; } const biomeSpinner = clack.spinner(); biomeSpinner.start('Running Biome on your files.'); try { // Use biome format --write for formatting (always succeeds if it can format) // Then biome check --write for lint fixes // We ignore exit codes because Biome exits non-zero if there are unfixable issues await new Promise((resolve) => { childProcess.exec(`npx @biomejs/biome format --write ${changedOrUntrackedFiles}`, () => { // Ignore errors, just continue resolve(); }); }); await new Promise((resolve) => { childProcess.exec(`npx @biomejs/biome check --write --unsafe ${changedOrUntrackedFiles}`, () => { // Ignore errors, Biome exits non-zero if there are remaining issues resolve(); }); }); } catch (e) { biomeSpinner.stop('Biome encountered an issue.'); clack.log.warn('Biome encountered an issue. There may be formatting or linting issues in your updated files.'); return; } biomeSpinner.stop('Biome has formatted your files.'); }); } exports.runBiomeIfInstalled = runBiomeIfInstalled; /** * 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 */ async function ensurePackageIsInstalled(packageJson, packageId, packageName) { return (0, telemetry_1.traceStep)('ensure-package-installed', async () => { const installed = (0, package_json_1.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); } } }); } exports.ensurePackageIsInstalled = ensurePackageIsInstalled; async function getPackageDotJson() { const packageJsonFileContents = await fs.promises .readFile((0, node_path_1.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 = undefined; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment packageJson = JSON.parse(packageJsonFileContents); } catch { clack.log.error(`Unable to parse your ${chalk_1.default.cyan('package.json')}. Make sure it has a valid format!`); await abort(); } return packageJson || {}; } exports.getPackageDotJson = getPackageDotJson; async function updatePackageDotJson(packageDotJson) { try { await fs.promises.writeFile((0, node_path_1.join)(process.cwd(), 'package.json'), // TODO: maybe figure out the original indentation JSON.stringify(packageDotJson, null, 2), { encoding: 'utf8', flag: 'w', }); } catch { clack.log.error(`Unable to update your ${chalk_1.default.cyan('package.json')}.`); await abort(); } } exports.updatePackageDotJson = updatePackageDotJson; /** * Use this function to get the used JS Package manager. * * This function: * - attempts to auto-detect the used package manager and return it * - if unsuccessful, returns the passed fallback package manager * - if no fallback is passed, it asks the user to select a package manager * * The result is cached on the first invocation to avoid asking the user multiple times. * * @param fallback the package manager to use if auto-detection fails and you don't want to * ask the user. This is useful in cases where asking users would be too intrusive/low in value * and where it's okay to fall back to a default package manager. Use this with caution. */ async function getPackageManager(fallback) { const globalWithSentryWizard = global; if (globalWithSentryWizard.__sentry_wizard_cached_package_manager) { return globalWithSentryWizard.__sentry_wizard_cached_package_manager; } const detectedPackageManager = (0, package_manager_2._detectPackageManger)(); if (detectedPackageManager) { globalWithSentryWizard.__sentry_wizard_cached_package_manager = detectedPackageManager; return detectedPackageManager; } if (fallback) { // explicitly avoiding to cache the fallback in case this function // gets called again without a fallback (or a different fallback) // later on in the wizard flow. return fallback; } const selectedPackageManager = await abortIfCancelled(clack.select({ message: 'Please select your package manager.', options: package_manager_2.packageManagers.map((packageManager) => ({ value: packageManager, label: packageManager.label, })), })); globalWithSentryWizard.__sentry_wizard_cached_package_manager = selectedPackageManager; Sentry.setTag('package-manager', selectedPackageManager.name); return selectedPackageManager; } exports.getPackageManager = getPackageManager; function isUsingTypeScript() { try { return fs.existsSync((0, node_path_1.join)(process.cwd(), 'tsconfig.json')); } catch { return false; } } exports.isUsingTypeScript = isUsingTypeScript; /** * 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) */ async function getOrAskForProjectData(options, platform) { // Spotlight mode: Skip authentication and use local development setup if (options.spotlight) { clack.log.info(`Spotlight mode enabled! Setting up for local development without Sentry account needed.\n Note: Your app will only send data to the local Spotlight debugger, not to Sentry.`); return { spotlight: true }; } if (options.preSelectedProject) { return { selfHosted: options.preSelectedProject.selfHosted, sentryUrl: options.url ?? SAAS_URL, authToken: options.preSelectedProject.authToken, selectedProject: options.preSelectedProject.project, spotlight: false, }; } const { url: sentryUrl, selfHosted } = await (0, telemetry_1.traceStep)('ask-self-hosted', () => askForSelfHosted(options.url, options.saas)); const { projects, apiKeys } = await (0, telemetry_1.traceStep)('login', () => askForWizardLogin({ promoCode: options.promoCode, url: sentryUrl, platform: platform, orgSlug: options.orgSlug, projectSlug: options.projectSlug, comingFrom: options.comingFrom, })); 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 (0, telemetry_1.traceStep)('select-project', () => askForProjectSelection(projects, options.orgSlug, options.projectSlug)); 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_1.default.cyan('https://github.com/getsentry/sentry-wizard/issues')}`); clack.log.info(`In the meantime, we'll add a dummy auth token (${chalk_1.default.cyan(`"${DUMMY_AUTH_TOKEN}"`)}) for you to replace later. Create your auth token here: ${chalk_1.default.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, spotlight: false, }; } exports.getOrAskForProjectData = getOrAskForProjectData; /** * 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, saas) { if (saas) { Sentry.setTag('url', SAAS_URL); Sentry.setTag('self-hosted', false); return { url: SAAS_URL, selfHosted: false }; } if (!urlFromArgs) { const choice = 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; 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 node_url_1.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/", got ${url})`); } } const isSelfHostedUrl = new node_url_1.URL(validUrl).host !== new node_url_1.URL(SAAS_URL).host; Sentry.setTag('url', validUrl); Sentry.setTag('self-hosted', isSelfHostedUrl); return { url: validUrl, selfHosted: true }; } /** * Exported for testing */ async function askForWizardLogin(options) { const { orgSlug, projectSlug, url, platform, promoCode, comingFrom } = options; Sentry.setTag('has-promo-code', !!promoCode); const projectAndOrgPreselected = !!(orgSlug && projectSlug); const hasSentryAccount = projectAndOrgPreselected || (await askHasSentryAccount()); Sentry.setTag('already-has-sentry-account', hasSentryAccount); const wizardHash = await makeInitialWizardHashRequest(url); const loginUrl = new node_url_1.URL(`${url}account/settings/wizard/${wizardHash}/`); if (orgSlug) { loginUrl.searchParams.set('org_slug', orgSlug); } if (projectSlug) { loginUrl.searchParams.set('project_slug', projectSlug); } if (!hasSentryAccount) { loginUrl.searchParams.set('signup', '1'); } if (platform) { loginUrl.searchParams.set('project_platform', platform); } if (promoCode) { loginUrl.searchParams.set('code', promoCode); } if (comingFrom) { loginUrl.searchParams.set('partner', comingFrom); } const urlToOpen = loginUrl.toString(); clack.log.info(`${chalk_1.default.bold(`If the browser window didn't open automatically, please open the following link to ${hasSentryAccount ? 'log' : 'sign'} into Sentry:`)}\n\n${chalk_1.default.cyan(urlToOpen)}`); // opn throws in environments that don't have a browser (e.g. remote shells) so we just noop here const noop = () => { }; // eslint-disable-line @typescript-eslint/no-empty-function (0, opn_1.default)(urlToOpen, { wait: false }).then((cp) => cp.on('error', noop), noop); const loginSpinner = clack.spinner(); loginSpinner.start('Waiting for you to log in using the link above'); const data = await new Promise((resolve) => { const pollingInterval = (0, node_timers_1.setInterval)(() => { axios_1.default .get(`${url}api/0/wizard/${wizardHash}/`, { headers: { 'Accept-Encoding': 'deflate', }, }) .then((result) => { resolve(result.data); clearTimeout(timeout); clearInterval(pollingInterval); void axios_1.default.delete(`${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.'); }, 180000); }); loginSpinner.stop('Login complete.'); Sentry.setTag('opened-wizard-link', true); return data; } exports.askForWizardLogin = askForWizardLogin; /** * This first request to Sentry creates a cache on the Sentry backend whose key is returned. * We use this key later on to poll for the actual project data. */ async function makeInitialWizardHashRequest(url) { const reqUrl = `${url}api/0/wizard/`; try { return (await axios_1.default.get(reqUrl)).data.hash; } catch (e) { if (url !== SAAS_URL) { clack.log.error(`Loading Wizard failed. Did you provide the right URL? (url: ${reqUrl})`); clack.log.info(JSON.stringify(e, null, 2)); await abort(chalk_1.default.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_1.default.red('Please try again in a few minutes and let us know if this issue persists: https://github.com/getsentry/sentry-wizard/issues')); } } // We don't get here as we abort in an error case but TS doesn't know that return 'invalid hash'; } async function askHasSentryAccount() { const hasSentryAccount = await clack.confirm({ message: 'Do you already have a Sentry account?', }); return abortIfCancelled(hasSentryAccount); } async function askForProjectSelection(projects, orgSlug, projectSlug) { const label = (project) => { return `${project.organization.slug}/${project.slug}`; }; const filteredProjects = filterProjectsBySlugs(projects, orgSlug, projectSlug); if (filteredProjects.length === 1) { const selection = filteredProjects[0]; Sentry.setTag('project', selection.slug); Sentry.setUser({ id: selection.organization.slug }); clack.log.step(`Selected project ${label(selection)}`); return selection; } if (filteredProjects.length === 0) { clack.log.warn('Could not find a project with the provided slugs.'); } const sortedProjects = filteredProjects.length ? filteredProjects : projects; sortedProjects.sort((a, b) => { return label(a).localeCompare(label(b)); }); const selection = 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; } function filterProjectsBySlugs(projects, orgSlug, projectSlug) { if (!orgSlug && !projectSlug) { return projects; } if (orgSlug && !projectSlug) { return projects.filter((p) => p.organization.slug === orgSlug); } if (!orgSlug && projectSlug) { return projects.filter((p) => p.slug === projectSlug); } return projects.filter((p) => p.organization.slug === orgSlug && p.slug === projectSlug); } /** * 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 */ async function askForToolConfigPath(toolName, configFileName) { const hasConfig = await abortIfCancelled(clack.confirm({ message: `Do you have a ${toolName} config file (e.g. ${chalk_1.default.cyan(configFileName)})?`, initialValue: true, })); if (!hasConfig) { return undefined; } return await abortIfCancelled(clack.text({ message: `Please enter the path to your ${toolName} config file:`, placeholder: (0, node_path_1.join)('.', configFileName), validate: (value) => { if (!value) {