@sentry/wizard
Version:
Sentry wizard helping you to configure your project
1,074 lines (1,068 loc) • 62.8 kB
JavaScript
"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) {