UNPKG

@posthog/wizard

Version:

The PostHog wizard helps you to configure your project

671 lines (667 loc) β€’ 29.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 () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __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.abort = abort; exports.abortIfCancelled = abortIfCancelled; exports.printWelcome = printWelcome; exports.confirmContinueIfNoOrDirtyGitRepo = confirmContinueIfNoOrDirtyGitRepo; exports.isInGitRepo = isInGitRepo; exports.getUncommittedOrUntrackedFiles = getUncommittedOrUntrackedFiles; exports.askForItemSelection = askForItemSelection; exports.confirmContinueIfPackageVersionNotSupported = confirmContinueIfPackageVersionNotSupported; exports.isReact19Installed = isReact19Installed; exports.installPackage = installPackage; exports.ensurePackageIsInstalled = ensurePackageIsInstalled; exports.getPackageDotJson = getPackageDotJson; exports.updatePackageDotJson = updatePackageDotJson; exports.getPackageManager = getPackageManager; exports.isUsingTypeScript = isUsingTypeScript; exports.getOrAskForProjectData = getOrAskForProjectData; exports.askForToolConfigPath = askForToolConfigPath; exports.showCopyPasteInstructions = showCopyPasteInstructions; exports.makeCodeSnippet = makeCodeSnippet; exports.createNewConfigFile = createNewConfigFile; exports.featureSelectionPrompt = featureSelectionPrompt; exports.askShouldInstallPackage = askShouldInstallPackage; exports.askShouldAddPackageOverride = askShouldAddPackageOverride; exports.askForAIConsent = askForAIConsent; exports.askForCloudRegion = askForCloudRegion; const childProcess = __importStar(require("node:child_process")); const fs = __importStar(require("node:fs")); const os = __importStar(require("node:os")); const node_path_1 = require("node:path"); const node_timers_1 = require("node:timers"); const node_url_1 = require("node:url"); const axios_1 = __importDefault(require("axios")); const chalk_1 = __importDefault(require("chalk")); const opn_1 = __importDefault(require("opn")); const telemetry_1 = require("../telemetry"); const debug_1 = require("./debug"); const package_json_1 = require("./package-json"); const package_manager_1 = require("./package-manager"); const semver_1 = require("./semver"); const package_json_2 = require("./package-json"); const constants_1 = require("../lib/constants"); const analytics_1 = require("./analytics"); const clack_1 = __importDefault(require("./clack")); const urls_1 = require("./urls"); const config_1 = require("../lib/config"); async function abort(message, status) { await analytics_1.analytics.shutdown('cancelled'); clack_1.default.outro(message ?? 'Wizard setup cancelled.'); return process.exit(status ?? 1); } async function abortIfCancelled(input, integration) { await analytics_1.analytics.shutdown('cancelled'); if (clack_1.default.isCancel(await input)) { const docsUrl = integration ? config_1.INTEGRATION_CONFIG[integration].docsUrl : 'https://posthog.com/docs'; clack_1.default.cancel(`Wizard setup cancelled. You can read the documentation for ${integration ?? 'PostHog'} at ${chalk_1.default.cyan(docsUrl)} to continue with the setup manually.`); process.exit(0); } else { return input; } } function printWelcome(options) { // eslint-disable-next-line no-console console.log(''); clack_1.default.intro(chalk_1.default.inverse(` ${options.wizardName} `)); const welcomeText = options.message || `The ${options.wizardName} will help you set up PostHog for your application.\nThank you for using PostHog :)`; clack_1.default.note(welcomeText); } async function confirmContinueIfNoOrDirtyGitRepo(options) { return (0, telemetry_1.traceStep)('check-git-status', async () => { if (!isInGitRepo()) { const continueWithoutGit = options.default ? true : await abortIfCancelled(clack_1.default.confirm({ message: 'You are not inside a git repository. The wizard will create and update files. Do you want to continue anyway?', })); analytics_1.analytics.setTag('continue-without-git', continueWithoutGit); if (!continueWithoutGit) { await abort(undefined, 0); } // return early to avoid checking for uncommitted files return; } const uncommittedOrUntrackedFiles = getUncommittedOrUntrackedFiles(); if (uncommittedOrUntrackedFiles.length) { clack_1.default.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_1.default.confirm({ message: 'Do you want to continue anyway?', })); analytics_1.analytics.setTag('continue-with-dirty-repo', continueWithDirtyRepo); if (!continueWithDirtyRepo) { await abort(undefined, 0); } } }); } function isInGitRepo() { try { childProcess.execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore', }); return true; } catch { return false; } } function getUncommittedOrUntrackedFiles() { try { const gitStatus = childProcess .execSync('git status --porcelain=v1', { // we only care about stdout stdio: ['ignore', 'pipe', 'ignore'], }) .toString(); const files = gitStatus .split(os.EOL) .map((line) => line.trim()) .filter(Boolean) .map((f) => `- ${f.split(/\s+/)[1]}`); return files; } catch { return []; } } async function askForItemSelection(items, message) { const selection = await abortIfCancelled(clack_1.default.select({ maxItems: 12, message: message, options: items.map((item, index) => { return { value: { value: item, index: index }, label: item, }; }), })); return selection; } async function confirmContinueIfPackageVersionNotSupported({ packageId, packageName, packageVersion, acceptableVersions, note, }) { return (0, telemetry_1.traceStep)(`check-package-version`, async () => { analytics_1.analytics.setTag(`${packageName.toLowerCase()}-version`, packageVersion); const isSupportedVersion = (0, semver_1.fulfillsVersionRange)({ acceptableVersions, version: packageVersion, canBeLatest: true, }); if (isSupportedVersion) { analytics_1.analytics.setTag(`${packageName.toLowerCase()}-supported`, true); return; } clack_1.default.log.warn(`You have an unsupported version of ${packageName} installed: ${packageId}@${packageVersion}`); clack_1.default.note(note ?? `Please upgrade to ${acceptableVersions} if you wish to use the PostHog wizard.`); const continueWithUnsupportedVersion = await abortIfCancelled(clack_1.default.confirm({ message: 'Do you want to continue anyway?', })); analytics_1.analytics.setTag(`${packageName.toLowerCase()}-continue-with-unsupported-version`, continueWithUnsupportedVersion); if (!continueWithUnsupportedVersion) { await abort(undefined, 0); } }); } async function isReact19Installed({ installDir, }) { try { const packageJson = await getPackageDotJson({ installDir }); const reactVersion = (0, package_json_2.getPackageVersion)('react', packageJson); if (!reactVersion) { return false; } return (0, semver_1.fulfillsVersionRange)({ version: reactVersion, acceptableVersions: '>=19.0.0', canBeLatest: true, }); } catch (error) { return false; } } /** * 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, integration, installDir, }) { return (0, telemetry_1.traceStep)('install-package', async () => { if (alreadyInstalled && askBeforeUpdating) { const shouldUpdatePackage = await abortIfCancelled(clack_1.default.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_1.default.spinner(); const pkgManager = packageManager || (await getPackageManager({ installDir })); // Most packages aren't compatible with React 19 yet, skip strict peer dependency checks if needed. const isReact19 = await isReact19Installed({ installDir }); const legacyPeerDepsFlag = isReact19 && pkgManager.name === 'npm' ? '--legacy-peer-deps' : ''; 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) => { childProcess.exec(`${pkgManager.installCommand} ${packageName} ${pkgManager.flags} ${forceInstall ? pkgManager.forceInstallFlag : ''} ${legacyPeerDepsFlag}`.trim(), { cwd: installDir }, (err, stdout, stderr) => { if (err) { // Write a log file so we can better troubleshoot issues fs.writeFileSync((0, node_path_1.join)(process.cwd(), `posthog-wizard-installation-error-${Date.now()}.log`), JSON.stringify({ stdout, stderr, }), { encoding: 'utf8' }); reject(err); } else { resolve(); } }); }); } catch (e) { sdkInstallSpinner.stop('Installation failed.'); clack_1.default.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 \`posthog-wizard-installation-error-*.log\` file. If you think this issue is caused by the PostHog wizard, create an issue on GitHub and include the log file's content:\n${constants_1.ISSUES_URL}`)}`); await abort(); } sdkInstallSpinner.stop(`${alreadyInstalled ? 'Updated' : 'Installed'} ${chalk_1.default.bold.cyan(packageNameDisplayLabel ?? packageName)} with ${chalk_1.default.bold(pkgManager.label)}.`); analytics_1.analytics.capture('wizard interaction', { action: 'package installed', package_name: packageName, package_manager: pkgManager.name, integration, }); return { packageManager: pkgManager }; }); } /** * 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); analytics_1.analytics.setTag(`${packageName.toLowerCase()}-installed`, installed); if (!installed) { const continueWithoutPackage = await abortIfCancelled(clack_1.default.confirm({ message: `${packageName} does not seem to be installed. Do you still want to continue?`, initialValue: false, })); if (!continueWithoutPackage) { await abort(undefined, 0); } } }); } async function getPackageDotJson({ installDir, }) { const packageJsonFileContents = await fs.promises .readFile((0, node_path_1.join)(installDir, 'package.json'), 'utf8') .catch(() => { clack_1.default.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_1.default.log.error(`Unable to parse your ${chalk_1.default.cyan('package.json')}. Make sure it has a valid format!`); await abort(); } return packageJson || {}; } async function updatePackageDotJson(packageDotJson, { installDir }) { try { await fs.promises.writeFile((0, node_path_1.join)(installDir, 'package.json'), // TODO: maybe figure out the original indentation JSON.stringify(packageDotJson, null, 2), { encoding: 'utf8', flag: 'w', }); } catch { clack_1.default.log.error(`Unable to update your ${chalk_1.default.cyan('package.json')}.`); await abort(); } } async function getPackageManager({ installDir, }) { const detectedPackageManagers = (0, package_manager_1.detectAllPackageManagers)({ installDir }); // If exactly one package manager detected, use it automatically if (detectedPackageManagers.length === 1) { const detectedPackageManager = detectedPackageManagers[0]; analytics_1.analytics.setTag('package-manager', detectedPackageManager.name); return detectedPackageManager; } // If multiple or no package managers detected, prompt user to select const options = detectedPackageManagers.length > 0 ? detectedPackageManagers : package_manager_1.packageManagers; const message = detectedPackageManagers.length > 1 ? 'Multiple package managers detected. Please select one:' : 'Please select your package manager.'; const selectedPackageManager = await abortIfCancelled(clack_1.default.select({ message, options: options.map((packageManager) => ({ value: packageManager, label: packageManager.label, })), })); analytics_1.analytics.setTag('package-manager', selectedPackageManager.name); return selectedPackageManager; } function isUsingTypeScript({ installDir, }) { try { return fs.existsSync((0, node_path_1.join)(installDir, 'tsconfig.json')); } catch { return false; } } /** * * Use this function to get project data for the wizard. * * @param options wizard options * @returns project data (token, url) */ async function getOrAskForProjectData(_options) { const cloudUrl = (0, urls_1.getCloudUrlFromRegion)(_options.cloudRegion); const { host, projectApiKey, wizardHash } = await (0, telemetry_1.traceStep)('login', () => askForWizardLogin({ url: cloudUrl, signup: _options.signup, })); if (!projectApiKey) { clack_1.default.log.error(`Didn't receive a project API key. This shouldn't happen :( Please let us know if you think this is a bug in the wizard: ${chalk_1.default.cyan(constants_1.ISSUES_URL)}`); clack_1.default.log .info(`In the meantime, we'll add a dummy project API key (${chalk_1.default.cyan(`"${constants_1.DUMMY_PROJECT_API_KEY}"`)}) for you to replace later. You can find your Project API key here: ${chalk_1.default.cyan(`${cloudUrl}/settings/project#variables`)}`); } return { wizardHash, host: host || constants_1.DEFAULT_HOST_URL, projectApiKey: projectApiKey || constants_1.DUMMY_PROJECT_API_KEY, }; } async function askForWizardLogin(options) { let wizardHash; try { wizardHash = (await axios_1.default.post(`${options.url}/api/wizard/initialize`)).data.hash; } catch (e) { clack_1.default.log.error('Loading wizard failed.'); clack_1.default.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: ${constants_1.ISSUES_URL}`)); throw e; } const loginUrl = new node_url_1.URL(`${options.url}/wizard?hash=${wizardHash}`); const signupUrl = new node_url_1.URL(`${options.url}/signup?next=${encodeURIComponent(`/wizard?hash=${wizardHash}`)}`); const urlToOpen = options.signup ? signupUrl.toString() : loginUrl.toString(); clack_1.default.log.info(`${chalk_1.default.bold(`If the browser window didn't open automatically, please open the following link to login into PostHog:`)}\n\n${chalk_1.default.cyan(urlToOpen)}${options.signup ? `\n\nIf you already have an account, you can use this link:\n\n${chalk_1.default.cyan(loginUrl.toString())}` : ``}`); if (process.env.NODE_ENV !== 'test') { (0, opn_1.default)(urlToOpen, { wait: false }).catch(() => { // opn throws in environments that don't have a browser (e.g. remote shells) so we just noop here }); } const loginSpinner = clack_1.default.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(`${options.url}/api/wizard/data`, { headers: { 'Accept-Encoding': 'deflate', 'X-PostHog-Wizard-Hash': wizardHash, }, }) .then((result) => { const data = { wizardHash, projectApiKey: result.data.project_api_key, host: result.data.host, distinctId: result.data.user_distinct_id, }; resolve(data); clearTimeout(timeout); clearInterval(pollingInterval); }) .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.'); analytics_1.analytics.setTag('opened-wizard-link', false); void abort('Please restart the wizard and log in to complete the setup.'); }, 180_000); }); loginSpinner.stop(`Login complete. ${options.signup ? 'Welcome to PostHog! πŸŽ‰' : ''}`); analytics_1.analytics.setTag('opened-wizard-link', true); analytics_1.analytics.setDistinctId(data.distinctId); return data; } /** * 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_1.default.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_1.default.text({ message: `Please enter the path to your ${toolName} config file:`, placeholder: (0, node_path_1.join)('.', configFileName), validate: (value) => { if (!value) { return 'Please enter a path.'; } try { fs.accessSync(value); } catch { return 'Could not access the file at this path.'; } }, })); } /** * Prints copy/paste-able instructions to the console. * Afterwards asks the user if they added the code snippet to their file. * * While there's no point in providing a "no" answer here, it gives users time to fulfill the * task before the wizard continues with additional steps. * * Use this function if you want to show users instructions on how to add/modify * code in their file. This is helpful if automatic insertion failed or is not possible/feasible. * * @param filename the name of the file to which the code snippet should be applied. * If a path is provided, only the filename will be used. * * @param codeSnippet the snippet to be printed. Use {@link makeCodeSnippet} to create the * diff-like format for visually highlighting unchanged or modified lines of code. * * @param hint (optional) a hint to be printed after the main instruction to add * the code from @param codeSnippet to their @param filename. * * TODO: refactor copy paste instructions across different wizards to use this function. * this might require adding a custom message parameter to the function */ async function showCopyPasteInstructions(filename, codeSnippet, hint) { clack_1.default.log.step(`Add the following code to your ${chalk_1.default.cyan((0, node_path_1.basename)(filename))} file:${hint ? chalk_1.default.dim(` (${chalk_1.default.dim(hint)})`) : ''}`); // Padding the code snippet to be printed with a \n at the beginning and end // This makes it easier to distinguish the snippet from the rest of the output // Intentionally logging directly to console here so that the code can be copied/pasted directly // eslint-disable-next-line no-console console.log(`\n${codeSnippet}\n`); await abortIfCancelled(clack_1.default.select({ message: 'Did you apply the snippet above?', options: [{ label: 'Yes, continue!', value: true }], initialValue: true, })); } /** * Crafts a code snippet that can be used to e.g. * - print copy/paste instructions to the console * - create a new config file. * * @param colors set this to true if you want the final snippet to be colored. * This is useful for printing the snippet to the console as part of copy/paste instructions. * * @param callback the callback that returns the formatted code snippet. * It exposes takes the helper functions for marking code as unchanged, new or removed. * These functions no-op if no special formatting should be applied * and otherwise apply the appropriate formatting/coloring. * (@see {@link CodeSnippetFormatter}) * * @see {@link showCopyPasteInstructions} for the helper with which to display the snippet in the console. * * @returns a string containing the final, formatted code snippet. */ function makeCodeSnippet(colors, callback) { const unchanged = (txt) => (colors ? chalk_1.default.grey(txt) : txt); const plus = (txt) => (colors ? chalk_1.default.greenBright(txt) : txt); const minus = (txt) => (colors ? chalk_1.default.redBright(txt) : txt); return callback(unchanged, plus, minus); } /** * Creates a new config file with the given @param filepath and @param codeSnippet. * * Use this function to create a new config file for users. This is useful * when users answered that they don't yet have a config file for a tool. * * (This doesn't mean that they don't yet have some other way of configuring * their tool but we can leave it up to them to figure out how to merge configs * here.) * * @param filepath absolute path to the new config file * @param codeSnippet the snippet to be inserted into the file * @param moreInformation (optional) the message to be printed after the file was created * For example, this can be a link to more information about configuring the tool. * * @returns true on success, false otherwise */ async function createNewConfigFile(filepath, codeSnippet, { installDir }, moreInformation) { if (!(0, node_path_1.isAbsolute)(filepath)) { (0, debug_1.debug)(`createNewConfigFile: filepath is not absolute: ${filepath}`); return false; } const prettyFilename = chalk_1.default.cyan((0, node_path_1.relative)(installDir, filepath)); try { await fs.promises.writeFile(filepath, codeSnippet); clack_1.default.log.success(`Added new ${prettyFilename} file.`); if (moreInformation) { clack_1.default.log.info(chalk_1.default.gray(moreInformation)); } return true; } catch (e) { (0, debug_1.debug)(e); clack_1.default.log.warn(`Could not create a new ${prettyFilename} file. Please create one manually and follow the instructions below.`); } return false; } async function featureSelectionPrompt(features) { return (0, telemetry_1.traceStep)('feature-selection', async () => { const selectedFeatures = {}; for (const feature of features) { const selected = await abortIfCancelled(clack_1.default.select({ message: feature.prompt, initialValue: true, options: [ { value: true, label: 'Yes', hint: feature.enabledHint, }, { value: false, label: 'No', hint: feature.disabledHint, }, ], })); selectedFeatures[feature.id] = selected; } return selectedFeatures; }); } async function askShouldInstallPackage(pkgName) { return (0, telemetry_1.traceStep)(`ask-install-package`, () => abortIfCancelled(clack_1.default.confirm({ message: `Do you want to install ${chalk_1.default.cyan(pkgName)}?`, }))); } async function askShouldAddPackageOverride(pkgName, pkgVersion) { return (0, telemetry_1.traceStep)(`ask-add-package-override`, () => abortIfCancelled(clack_1.default.confirm({ message: `Do you want to add an override for ${chalk_1.default.cyan(pkgName)} version ${chalk_1.default.cyan(pkgVersion)}?`, }))); } async function askForAIConsent(options) { return await (0, telemetry_1.traceStep)('ask-for-ai-consent', async () => { const aiConsent = options.default ? true : await abortIfCancelled(clack_1.default.select({ message: 'This setup wizard uses AI, are you happy to continue? ✨', options: [ { label: 'Yes', value: true, hint: 'We will use AI to help you setup PostHog quickly', }, { label: 'No', value: false, hint: "I don't like AI", }, ], initialValue: true, })); return aiConsent; }); } async function askForCloudRegion() { return await (0, telemetry_1.traceStep)('ask-for-cloud-region', async () => { const cloudRegion = await abortIfCancelled(clack_1.default.select({ message: 'Select your PostHog Cloud region', options: [ { label: 'US πŸ‡ΊπŸ‡Έ', value: 'us', hint: 'Your data will be stored in the US', }, { label: 'EU πŸ‡ͺπŸ‡Ί', value: 'eu', hint: 'Your data will be stored in the EU', }, ], })); return cloudRegion; }); } //# sourceMappingURL=clack-utils.js.map