diff --git a/package.json b/package.json index 57384e368a57..615343f528c7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "pre-push": "yarn --cwd packages/eui pre-push", "preinstall": "echo \"\\x1b[K\\x1b[37;41mWarning: EUI has recently migrated to a monorepo structure. Please run EUI scripts like \\x1b[1;4myarn start\\x1b[0m\\x1b[37;41m or \\x1b[1;4myarn build\\x1b[0m\\x1b[37;41m from the \\x1b[1;4mpackages/eui\\x1b[0m\\x1b[37;41m directory instead!\n\nIf this is the first time you're running EUI after the monorepo migration, please run this first from the root repository's directory to clean up your local environment:\n \\x1b[1;4mrm -rf node_modules .cache-loader dist es lib optimize test-env types .eslintcache .loki reports docs .nyc_output eui.d.ts && yarn\\x1b[0m\\x1b[37;41m\n\nInstall process will continue in 10 seconds...\\x1b[0m\"; sleep 10", "start": "echo '\\x1b[K\\x1b[37;41mPlease run this script from the \\x1b[1;4mpackages/eui\\x1b[0m\\x1b[37;41m directory instead\\x1b[0m'; exit 1", - "build": "echo '\\x1b[K\\x1b[37;41mPlease run this script from the \\x1b[1;4mpackages/eui\\x1b[0m\\x1b[37;41m directory instead\\x1b[0m'; exit 1" + "build": "echo '\\x1b[K\\x1b[37;41mPlease run this script from the \\x1b[1;4mpackages/eui\\x1b[0m\\x1b[37;41m directory instead\\x1b[0m'; exit 1", + "release": "node scripts/release" }, "repository": { "type": "git", @@ -24,6 +25,9 @@ "devDependencies": { "pre-push": "^0.1.4" }, + "dependencies": { + "@elastic/eui-release-cli": "link:packages/release-cli" + }, "resolutions": { "prismjs": "1.27.0", "react": "^18", diff --git a/packages/eui-docgen/package.json b/packages/eui-docgen/package.json index ae00b77d96fb..949a5c991f66 100644 --- a/packages/eui-docgen/package.json +++ b/packages/eui-docgen/package.json @@ -14,6 +14,7 @@ }, "private": true, "dependencies": { + "@elastic/eui": "workspace:^", "glob": "^11.0.0", "react-docgen-typescript": "^2.2.2", "ts-node": "^10.9.2", diff --git a/packages/release-cli/.gitignore b/packages/release-cli/.gitignore new file mode 100644 index 000000000000..52f8d324771b --- /dev/null +++ b/packages/release-cli/.gitignore @@ -0,0 +1,8 @@ +# Dependencies +/node_modules + +# Production +/dist + +yarn-debug.log* +yarn-error.log* diff --git a/packages/release-cli/package.json b/packages/release-cli/package.json new file mode 100644 index 000000000000..fb9fcbf21991 --- /dev/null +++ b/packages/release-cli/package.json @@ -0,0 +1,27 @@ +{ + "name": "@elastic/eui-release-cli", + "private": true, + "version": "0.0.1", + "description": "", + "main": "dist/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc" + }, + "repository": { + "type": "git", + "url": "https://github.com/tkajtoch/eui.git", + "directory": "packages/release-cli" + }, + "devDependencies": { + "@types/prompts": "^2.4.9", + "typescript": "^5.7.3" + }, + "dependencies": { + "chalk": "^4", + "glob": "^11.0.1", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "yargs": "^17.7.2" + } +} diff --git a/packages/release-cli/src/cli.ts b/packages/release-cli/src/cli.ts new file mode 100644 index 000000000000..4de555d1964f --- /dev/null +++ b/packages/release-cli/src/cli.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import yargs from 'yargs/yargs'; +import { hideBin } from 'yargs/helpers'; +import { release, type ReleaseType } from './release'; +import { Logger } from './logger'; +import { ValidationError } from './errors'; + +export const cli = () => { + yargs(hideBin(process.argv)) + .command( + 'run [--tag] [--workspaces] [--allowCustom] [--verbose | -v]', + 'Run the release process', + (yargs) => { + return yargs + .positional('type', { + type: 'string', + describe: + 'Type of release to perform. Releases of type `official` will be tagged as `latest` in npm and are meant for official, stable builds only!', + choices: ['official', 'snapshot'] satisfies ReleaseType[], + demandOption: true, + }) + .option('tag', { + type: 'string', + describe: + 'npm tag for the release. It is forced to `latest` for official releases and defaults to `snapshot` for snapshot releases.', + }) + .option('workspaces', { + type: 'string', + array: true, + describe: + 'An optional space-separated list of workspaces to release. Defaults to all workspaces changed since the last release.', + }) + .option('allowCustom', { + type: 'boolean', + default: false, + }) + .option('verbose', { + alias: 'v', + type: 'boolean', + description: 'Enable verbose logging', + default: false, + }) + .option('skipPrompts', { + type: 'boolean', + description: + 'Skip user prompts and proceed with recommended settings. Use in CI only!', + default: false, + }) + .option('useAuthToken', { + type: 'boolean', + description: + 'Use npm auth token instead of the regular npm user authentication and one-time passwords (OTP). Use in CI only!', + default: false, + }); + }, + async (argv) => { + const { + type, + tag, + workspaces, + allowCustom, + verbose, + skipPrompts, + useAuthToken, + } = argv; + const logger = new Logger(verbose); + + try { + await release({ + type, + tag, + workspaces, + logger, + skipPrompts, + useAuthToken, + allowCustomReleases: allowCustom, + }); + } catch (err) { + if (err instanceof ValidationError) { + // ValidationErrors don't need the stacktrace printed out + logger.error(err.toString()); + } else { + logger.error(err); + } + process.exit(1); + } + } + ) + .demandCommand(1) + .parse(); +}; diff --git a/packages/release-cli/src/errors.ts b/packages/release-cli/src/errors.ts new file mode 100644 index 000000000000..9c34df7189ca --- /dev/null +++ b/packages/release-cli/src/errors.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export class ValidationError extends Error { + public helpText: string | null = null; + + constructor(message: string, helpText?: string) { + super(message); + + if (helpText !== undefined) { + this.helpText = helpText; + } + } + + toString() { + let finalHelpText = ''; + if (this.helpText) { + finalHelpText += '\n\n'; + finalHelpText += this.helpText; + } + return `${this.message}${finalHelpText}`; + } +} diff --git a/packages/release-cli/src/git_utils.ts b/packages/release-cli/src/git_utils.ts new file mode 100644 index 000000000000..a8deb7349549 --- /dev/null +++ b/packages/release-cli/src/git_utils.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { promisify } from 'node:util'; +import { exec } from 'node:child_process'; + +const execPromise = promisify(exec); + +export const getCurrentBranch = async () => { + const result = await execPromise('git rev-parse --abbrev-ref HEAD'); + + return result.stdout.trim(); +} + +export const isWorkingTreeClean = async () => { + const gitStatusResult = await execPromise('git status --porcelain'); + + return gitStatusResult.stdout === '' && gitStatusResult.stderr === ''; +} + +export const getRemoteHeadCommitHash = async (branchName: string) => { + try { + const result = await execPromise(`git ls-remote --head --exit-code upstream refs/heads/${branchName}`); + return result.stdout.split('\t')[0]; + } catch (err) { + // https://git-scm.com/docs/git-ls-remote#Documentation/git-ls-remote.txt---exit-code + if ((err as any).code === 2) { + // Remote ref not found + return ''; + } + + throw err; + } +} + +export const getLocalHeadCommitHash = async () => { + const result = await execPromise('git rev-parse HEAD'); + + return result.stdout.trim(); +}; + +export const getCommitMessage = async (commitHash: string) => { + // Well, technically this returns commit subject, but we don't care about the whole commit body + const result = await execPromise(`git log -1 --pretty=format:%s ${commitHash}`); + return result.stdout.trim(); +}; + +export const stageFiles = async (files: string[]) => { + return execPromise(`git add ${files.join(' ')}`); +}; + +export const commitFiles = async (message: string, files: string[]) => { + // This isn't the best at handling unusual formatting like messages with quotes + return execPromise(`git commit ${files.join(' ')} -m "${message}"`); +} + +export const isFileAddedToGit = async (file: string) => { + const result = await execPromise(`git ls-files --exclude-standard "${file}"`); + return result.stdout.length > 0; +} diff --git a/packages/release-cli/src/index.ts b/packages/release-cli/src/index.ts new file mode 100644 index 000000000000..25df6badd90c --- /dev/null +++ b/packages/release-cli/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { cli } from './cli'; + +cli(); diff --git a/packages/release-cli/src/logger.ts b/packages/release-cli/src/logger.ts new file mode 100644 index 000000000000..2adf1894c603 --- /dev/null +++ b/packages/release-cli/src/logger.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import chalk from 'chalk'; + +export class Logger { + private readonly PREFIX_DEBUG = chalk.gray('[debug]'); + private readonly PREFIX_INFO = chalk.white('[info]'); + private readonly PREFIX_WARNING = chalk.yellow('[warning]'); + private readonly PREFIX_ERROR = chalk.red('[error]'); + + constructor(private readonly verbose: boolean) {} + + debug(message: any, ...args: any) { + if (!this.verbose) { + return; + } + console.debug(this.PREFIX_DEBUG, message, ...args); + } + + info(message: any, ...args: any) { + console.info(this.PREFIX_INFO, message, ...args); + } + + warning(message: any, ...args: any) { + console.warn(this.PREFIX_WARNING, message, ...args); + } + + error(message: any, ...args: any) { + console.error(this.PREFIX_ERROR, message, ...args); + } +} diff --git a/packages/release-cli/src/npm_utils.ts b/packages/release-cli/src/npm_utils.ts new file mode 100644 index 000000000000..cd4ff291b3b2 --- /dev/null +++ b/packages/release-cli/src/npm_utils.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { promisify } from 'node:util'; +import { exec } from 'node:child_process'; + +const execPromise = promisify(exec); + +export const getNpmPublishedVersions = async (packageName: string) => { + try { + const result = await execPromise(`npm view ${packageName} versions --json`); + return JSON.parse(result.stdout) as string[]; + } catch (err) { + console.error(err); + } + + return []; +} diff --git a/packages/release-cli/src/release.ts b/packages/release-cli/src/release.ts new file mode 100644 index 000000000000..35a8a60c77e6 --- /dev/null +++ b/packages/release-cli/src/release.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import chalk from 'chalk'; + +import { ValidationError } from './errors'; +import type { Logger } from './logger'; +import { getYarnWorkspaces, YarnWorkspace } from './yarn_utils'; + +import { stepInitChecks } from './steps/init_checks'; +import { stepBuildPackages } from './steps/build_packages'; +import { stepUpdateVersions } from './steps/update_versions'; +import { stepCheckWorkspaces } from './steps/check_workspaces'; +import { stepPublish } from './steps/publish'; +import { stepRunPreScripts, stepRunPostScripts } from './steps/run_pre_post_scripts'; + +export const ReleaseType = ['official', 'snapshot'] as const; +export type ReleaseType = (typeof ReleaseType)[number]; + +export interface ReleaseOptions { + type: ReleaseType; + tag?: string; + workspaces?: string[]; + logger: Logger; + allowCustomReleases: boolean; + skipPrompts: boolean; + useAuthToken: boolean; +} + +export const release = async (options: ReleaseOptions) => { + const { type, logger } = options; + + // Process tag + if (type === 'official') { + if (!options.tag) { + logger.debug( + `Release tag is not set. Defaulting to tag ${chalk.underline('latest')}` + ); + options.tag = 'latest'; + } + + if (options.tag !== 'latest') { + throw new ValidationError( + 'Custom tags are not allowed for type `official`! Please use another type to perform custom releases' + ); + } + } else if (type === 'snapshot') { + if (!options.tag) { + logger.info( + `Release tag is not set. Defaulting to tag ${chalk.underline( + 'snapshot' + )}` + ); + options.tag = 'snapshot'; + } + } else { + throw new ValidationError('Unknown release type. Please choose `official` or `snapshot`'); + } + + if (options.useAuthToken) { + logger.warning('Using npmjs auth token'); + } else if (options.skipPrompts) { + throw new ValidationError('Skipping prompts is not allowed when not using auth tokens'); + } + + const allWorkspaces = await getYarnWorkspaces(); + let currentWorkspaces: Array = []; + + if (!options.workspaces) { + options.workspaces = []; + } + + if (options.workspaces.length > 0) { + for (const workspaceName of options.workspaces) { + const workspace = allWorkspaces.find( + (workspace) => workspace.name === workspaceName + ); + + if (!workspace) { + throw new ValidationError( + `Workspace \`${workspaceName}\` not found. Available workspace names: ${JSON.stringify( + allWorkspaces + )}` + ); + } + + currentWorkspaces.push(workspace); + } + } else { + currentWorkspaces = allWorkspaces; + } + + await stepInitChecks(options); + + await stepBuildPackages(options); + + const changedWorkspaces = await stepUpdateVersions(options, currentWorkspaces); + + await stepRunPreScripts(options); + + await stepCheckWorkspaces(options, changedWorkspaces); + + await stepPublish(options, changedWorkspaces); + + await stepRunPostScripts(options); +}; diff --git a/packages/release-cli/src/steps/build_packages.ts b/packages/release-cli/src/steps/build_packages.ts new file mode 100644 index 000000000000..c29aba23047b --- /dev/null +++ b/packages/release-cli/src/steps/build_packages.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { execSync } from 'node:child_process'; + +import { type ReleaseOptions } from '../release'; + +// A list of very specific packages to exclude when building +// Should probably become a configuration option at some point +const workspacesToExclude = [ + // exclude itself to prevent any unexpected behavior + '@elastic/eui-release-cli', + // the monorepo is just a "wrapper" workspace and doesn't need building + `@elastic/eui-monorepo`, + // only needed by @elastic/eui-website + '@elastic/eui-docgen', + // private and fully independent + '@elastic/eui-website', +]; + +/** + * Install dependencies and run the `build` script in all workspaces + */ +export const stepBuildPackages = async (options: ReleaseOptions) => { + const { logger } = options; + + logger.info('Installing dependencies and building packages'); + + // Install dependencies in case something's missing + execSync('yarn', { stdio: 'inherit' }); + + const cmd = [ + 'yarn workspaces foreach', + + // consider both "dependencies" and "devDependencies" package.json fields + // in topological order + '--topological-dev', + + // include all local workspaces + '--all', + + // exclude selected independent and fully private workspaces + workspacesToExclude.map((name) => `--exclude ${name}`).join(' '), + + 'run build', + ].join(' '); + execSync(cmd, { + stdio: 'inherit', + }); +}; diff --git a/packages/release-cli/src/steps/check_workspaces.ts b/packages/release-cli/src/steps/check_workspaces.ts new file mode 100644 index 000000000000..af090a3de182 --- /dev/null +++ b/packages/release-cli/src/steps/check_workspaces.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'node:path'; +import { type ReleaseOptions } from '../release'; +import { type YarnWorkspace } from '../yarn_utils'; +import { getNpmPublishedVersions } from '../npm_utils'; +import { getRootWorkspaceDir, getWorkspacePackageJson } from '../workspace'; +import { ValidationError } from '../errors'; + +/** + * Check if about to be published package versions haven't been already + * published. This is to prevent accidental same-version publishes + * that are very tricky to undo. + * + * This step doesn't establish any kind of publishing "descriptor" on workspace + * versions that would ensure that between now and the built package's push + * there won't be any other package pushes with conflicting version numbers. + * This scenario is very unlikely and npm doesn't really provide tools + * to make it safer. + */ +export const stepCheckWorkspaces = async ( + options: ReleaseOptions, + workspacesToPublish: Array +) => { + const { logger } = options; + const rootWorkspaceDir = getRootWorkspaceDir(); + + logger.info('Checking latest versions of published packages on npmjs'); + + for (const workspace of workspacesToPublish) { + const workspaceDir = path.join(rootWorkspaceDir, workspace.location); + const packageJson = await getWorkspacePackageJson(workspaceDir); + + if (packageJson.private) { + logger.debug(`[${workspace.name}] Package is private and will not be published`); + continue; + } + + const publishedVersions = await getNpmPublishedVersions(workspace.name); + if (publishedVersions.includes(packageJson.version)) { + throw new ValidationError(`${workspace.name}@${packageJson.version} is already available on npmjs and cannot be republished`); + } + } +}; diff --git a/packages/release-cli/src/steps/init_checks.ts b/packages/release-cli/src/steps/init_checks.ts new file mode 100644 index 000000000000..feb46a410e44 --- /dev/null +++ b/packages/release-cli/src/steps/init_checks.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import chalk from 'chalk'; +import prompts from 'prompts'; + +import { ValidationError } from '../errors'; +import { type ReleaseOptions } from '../release'; +import { + getCommitMessage, + getCurrentBranch, + getLocalHeadCommitHash, + getRemoteHeadCommitHash, + isWorkingTreeClean, +} from '../git_utils'; +import { getAuthenticatedUser, getYarnRegistryServer } from '../yarn_utils'; + +/** + * Check current git branch, working tree status and more to ensure + * no unreviewed data is (accidentally) published. + */ +export const stepInitChecks = async (options: ReleaseOptions) => { + const { type, logger, allowCustomReleases } = options; + + logger.info('Running initial checks'); + + // Check if releasing from main branch + const currentBranch = await getCurrentBranch(); + if (currentBranch !== 'main' && type === 'official') { + throw new ValidationError( + 'Official releases are only allowed from the `main` branch!' + ); + } + + if (!(await isWorkingTreeClean())) { + throw new ValidationError( + 'Git working tree is dirty. Please clean up your working tree from any uncommited changes and try again.', + `To clean local changes and restore the branch to remote state, please run:` + + `\n ${chalk.yellowBright( + `git reset --hard upstream/${currentBranch}` + )}\n` + + `Please note that ${chalk.underline.bold( + 'this will discard all local changes' + )}!` + ); + } + + const remoteHash = await getRemoteHeadCommitHash(currentBranch); + const localHash = await getLocalHeadCommitHash(); + logger.debug(`Remote HEAD = ${remoteHash}`); + logger.debug(`Local HEAD = ${localHash}`); + + if (localHash !== remoteHash) { + if (type === 'official') { + throw new ValidationError( + 'Local `main` branch is out of sync with the upstream branch. Please pull the latest changes and try again.' + ); + } + + if (!allowCustomReleases) { + throw new ValidationError( + 'Local HEAD does not match the remote HEAD. Use --allow-custom to create a custom non-official release ' + + 'if that really is what you were planning to do.' + ); + } else { + logger.warning('Local HEAD does not match the remote HEAD'); + } + } + + const commitMessage = await getCommitMessage(localHash); + logger.info( + `Current commit: ${localHash} (${chalk.underline.bold( + commitMessage + )}) on branch ${chalk.underline.bold(currentBranch)}` + ); + + const registryUser = await getAuthenticatedUser(); + if (!registryUser) { + throw new ValidationError( + 'Authentication to npmjs is required. Please log in before running this command again.', + `To authenticate run the following command:\n` + + ` ${chalk.yellowBright('yarn npm login')}` + ); + } + + logger.info(`Logged in to npmjs as ${registryUser}`); + + const npmRegistry = await getYarnRegistryServer(); + if (npmRegistry) { + logger.warning( + chalk.yellow( + `A custom npm registry server (${npmRegistry}) will be used!` + ) + ); + } else { + logger.info('The official npm registry server will be used'); + } + + if (!options.skipPrompts) { + const { proceed } = await prompts({ + type: 'confirm', + name: 'proceed', + message: + 'This script will build, version and publish changed ' + + 'packages in EUI monorepo. The operation should take 5-10 minutes ' + + 'and will require your personal OTP token for npmjs.\n' + + 'Do you want to proceed?', + }); + + if (!proceed) { + console.error(chalk.red(`Release cancelled`)); + process.exit(1); + } + } +}; diff --git a/packages/release-cli/src/steps/publish.ts b/packages/release-cli/src/steps/publish.ts new file mode 100644 index 000000000000..23bac19c6324 --- /dev/null +++ b/packages/release-cli/src/steps/publish.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'node:path'; +import chalk from 'chalk'; +import prompts from 'prompts'; +import { type ReleaseOptions } from '../release'; +import { getRootWorkspaceDir, getWorkspacePackageJson } from '../workspace'; +import { execPublish, YarnWorkspace } from '../yarn_utils'; + +interface PublishedWorkspace extends YarnWorkspace { + version: string; +} + +/** + * Publish changed packages to the registry + */ +export const stepPublish = async ( + options: ReleaseOptions, + workspacesToPublish: Array +) => { + const { logger } = options; + const rootWorkspaceDir = getRootWorkspaceDir(); + + const publishedWorkspaces: Array = []; + + for (const workspace of workspacesToPublish) { + const workspaceDir = path.join(rootWorkspaceDir, workspace.location); + const packageJson = await getWorkspacePackageJson(workspaceDir); + + if (packageJson.private) { + logger.debug( + `[${workspace.name}] Package is private and will not be published` + ); + continue; + } + + logger.info(`[${workspace.name}] Publishing to npm`); + + let otp: string | undefined; + if (!options.useAuthToken) { + const result = await prompts({ + type: 'password', + name: 'otp', + message: `What's your npmjs one-time password (OTP)?`, + }); + otp = result.otp; + } + + try { + // tag is always defined at this stage. See release.ts + execPublish(workspace.name, options.tag!, otp); + } catch (err) { + logger.error(err); + logger.error(chalk.red(`[${workspace.name}] Failed to publish package`)); + + if (publishedWorkspaces.length) { + const remainingPackages = workspacesToPublish.filter( + (workspaceToPublish) => { + return !!publishedWorkspaces.find( + (publishedWorkspace) => + publishedWorkspace.name === workspaceToPublish.name + ); + } + ); + + logger.error( + chalk.red( + `${publishedWorkspaces.length} out of ` + + `${workspacesToPublish.length} packages were already published.` + ) + ); + logger.error( + `If the error was caused by a network or service issue you can ` + + `Retry publishing these remaining packages:\n` + + ` ${remainingPackages.join(' ')}` + ); + logger.error( + `Otherwise, deprecate just published versions of the following ` + + `packages if they are strictly dependent on remaining ` + + `packages' updates:\n` + + ` ${publishedWorkspaces + .map((workspace) => workspace.name) + .join(' ')}` + ); + } else { + logger.error('No packages were published before this error occurred. Please try again'); + } + + return; + } + + publishedWorkspaces.push({ + ...workspace, + version: packageJson.version, + }); + logger.info( + `[${workspace.name}] Successfully published ${workspace.name}@${packageJson.version} to npmjs - https://npmjs.com/package/${workspace.name}` + ); + } + + logger.info('Publishing packages finished 🎉'); +}; diff --git a/packages/release-cli/src/steps/run_pre_post_scripts.ts b/packages/release-cli/src/steps/run_pre_post_scripts.ts new file mode 100644 index 000000000000..4d058a22def6 --- /dev/null +++ b/packages/release-cli/src/steps/run_pre_post_scripts.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type ReleaseOptions } from '../release'; +import { runScriptOnAllWorkspaces } from '../yarn_utils'; + +/** + * Runs the `prerelease` script in all workspaces that define it in their + * package.json `scripts` section. + */ +export const stepRunPreScripts = async (options: ReleaseOptions) => { + const { logger } = options; + + logger.info('Running pre release scripts'); + + await runScriptOnAllWorkspaces('prerelease'); +} + +/** + * Runs the `postrelease` script in all workspaces that define it in their + * package.json `scripts` section. + */ +export const stepRunPostScripts = async (options: ReleaseOptions) => { + const { logger } = options; + + logger.info('Running post release scripts'); + + await runScriptOnAllWorkspaces('postrelease'); +} diff --git a/packages/release-cli/src/steps/update_versions.ts b/packages/release-cli/src/steps/update_versions.ts new file mode 100644 index 000000000000..13ad1a21d953 --- /dev/null +++ b/packages/release-cli/src/steps/update_versions.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'node:path'; +import { type ReleaseOptions } from '../release'; +import { type YarnWorkspace, updateWorkspaceVersion } from '../yarn_utils'; +import { + getRootWorkspaceDir, + getWorkspacePackageJsonPath, + getWorkspacePackageVersion, +} from '../workspace'; +import { + collateChangelogFiles, + deleteObsoleteChangelogs, + updateChangelogContent, +} from '../update_changelog'; +import { getUpcomingVersion, getVersionTypeFromChangelog } from '../version'; +import { commitFiles, isFileAddedToGit, stageFiles } from '../git_utils'; + +/** + * Update version numbers and yearly changelogs based on workspace + * changelog files. + */ +export const stepUpdateVersions = async ( + options: ReleaseOptions, + workspaces: Array +) => { + const { logger } = options; + + logger.info('Updating package versions and changelogs'); + + const filesToCommit: string[] = []; + const changedWorkspaces: YarnWorkspace[] = []; + const rootWorkspaceDir = getRootWorkspaceDir(); + + for (const workspace of workspaces) { + logger.debug(`Calculating changes in ${workspace.name}`); + + const workspaceDir = path.join(rootWorkspaceDir, workspace.location); + const currentVersion = await getWorkspacePackageVersion(workspaceDir); + + const { changelogMap, changelog, hasChanges, processedChangelogFiles } = + await collateChangelogFiles(workspaceDir); + + if (!hasChanges) { + logger.debug(`[${workspace.name}] No changes detected`); + continue; + } + + const versionType = getVersionTypeFromChangelog(changelogMap); + const newVersion = getUpcomingVersion(currentVersion, versionType); + + const statsStr = Object.entries(changelogMap) + .filter(([_, items]) => items.length) + .map(([name, items]) => `${items.length} ${name.toLowerCase()}`); + + logger.info( + `[${workspace.name}] Updating version ${currentVersion} -> ${newVersion} (${versionType} update; ${statsStr})` + ); + + const updatedYearChangelogPath = await updateChangelogContent( + workspaceDir, + changelog, + newVersion + ); + await deleteObsoleteChangelogs(processedChangelogFiles); + + // Update package.json version string + await updateWorkspaceVersion(workspace.name, newVersion); + + filesToCommit.push(getWorkspacePackageJsonPath(workspaceDir)); + filesToCommit.push(updatedYearChangelogPath); + + // Only stage and commit changelog files that are added to git (versioned) + for (const file of processedChangelogFiles) { + if (await isFileAddedToGit(file)) { + filesToCommit.push(file); + } + } + + changedWorkspaces.push(workspace); + } + + if (!changedWorkspaces.length) { + throw new Error('There are no changes to release'); + } + + // Stage yarn.lock changes + const yarnLockPath = path.join(rootWorkspaceDir, 'yarn.lock'); + filesToCommit.push(yarnLockPath); + await stageFiles([yarnLockPath]); + + // Stage updated package.json files + await stageFiles(filesToCommit); + + // Commit all package.json files and yarn.lock + await commitFiles('chore: update package versions [skip ci]', filesToCommit); + + return changedWorkspaces; +}; diff --git a/packages/release-cli/src/update_changelog.ts b/packages/release-cli/src/update_changelog.ts new file mode 100644 index 000000000000..b8476f95b2aa --- /dev/null +++ b/packages/release-cli/src/update_changelog.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'node:path'; +import fs from 'node:fs/promises'; +import * as glob from 'glob'; +import { rimraf } from 'rimraf'; + +const CHANGELOGS_DIR_PATH = 'changelogs'; + +type ChangelogGroupName = 'Features' | 'Bug fixes' | 'Deprecations' | 'Breaking changes' | string; +export type ChangelogMap = Record; + +/** + * Convert individual changelog files into a single changelog string + */ +export const collateChangelogFiles = async (packageRootDir: string) => { + const upcomingChangelogsDir = path.resolve(packageRootDir, CHANGELOGS_DIR_PATH, 'upcoming'); + + const upcomingChangelogMap: ChangelogMap = { + Features: [], + 'Bug fixes': [], + Deprecations: [], + 'Breaking changes': [], + }; + + const processedChangelogFiles: string[] = []; + + try { + await fs.stat(upcomingChangelogsDir); + } catch (err) { + // Directory doesn't exist + return { + changelogMap: upcomingChangelogMap, + changelog: '', + hasChanges: false, + processedChangelogFiles, + }; + } + + const files = glob.globIterate('**.md', { + cwd: upcomingChangelogsDir, + realpath: true, + ignore: ['_template.md'], + }); + + for await (const fileName of files) { + const filePath = path.join(upcomingChangelogsDir, fileName); + const pullRequestId = path.basename(filePath, '.md'); + const pullRequestMarkdownLink = `([#${pullRequestId}](https://github.com/elastic/eui/pull/${pullRequestId}))`; + + processedChangelogFiles.push(filePath); + + const content = await fs.readFile(filePath, 'utf8'); + for (const section of content.split(/\r?\n\*\*/)) { + let heading = ''; + let text = ''; + + const hasHeading = section.match(/\*\*\r?\n/); + if (hasHeading) { + [heading, text] = section.split(/\*\*\r?\n/); + } else { + text = section; + } + // Cleanup + heading = heading.replace('**', '').trim(); + text = text.trim(); + + if (text) { + // Split changelog text into discrete log items (if there are multiple) and append a PR link for each + let items = text.split(/\r?\n/).map((item) => + // Skip indented changelog items - they're presumably a child item providing more info, and don't need individual links + item.startsWith(' -') ? item : `${item} ${pullRequestMarkdownLink}` + ); + + if (!heading) { + // No heading, so must be new features/enhancements only + upcomingChangelogMap['Features'].push(...items); + } else { + // If this is a custom heading, create the heading key + if (!upcomingChangelogMap.hasOwnProperty(heading)) { + upcomingChangelogMap[heading] = []; + } + upcomingChangelogMap[heading].push(...items); + } + } + } + } + + let changelogSections: string[] = []; + + for (const [section, items] of Object.entries(upcomingChangelogMap)) { + if (!items.length) continue; // Nothing to add + + let changelog = ''; + if (section !== 'Features') changelog += `**${section}**\n\n`; + changelog += items.join('\n'); + + changelogSections.push(changelog); + } + + const upcomingChangelog = changelogSections.join('\n\n'); + + return { + changelogMap: upcomingChangelogMap, + changelog: upcomingChangelog, + hasChanges: !!upcomingChangelog.length, + processedChangelogFiles, + }; +}; + +/** + * Write to latest year's changelog file, delete individual upcoming changelog files, & stage changes + */ +export const updateChangelogContent = async (packageRootDir: string, upcomingChangelog: string, version: string) => { + if (!upcomingChangelog) { + throw new Error('Cannot update changelog - no changes found'); + } + + const year = new Date().getUTCFullYear(); + const pathToChangelog = path.resolve(packageRootDir, CHANGELOGS_DIR_PATH, `CHANGELOG_${year}.md`); + + let changelogArchive = ''; + try { + changelogArchive = await fs.readFile(pathToChangelog, 'utf8'); + } catch {} + + let latestVersionHeading = `## [\`v${version}\`](https://github.com/elastic/eui/releases/v${version})`; + if (version.includes('-backport')) { + latestVersionHeading += + '\n\n**This is a backport release only intended for use by Kibana.**'; + } else if (version.includes('-rc')) { + latestVersionHeading += + '\n\n**This is a prerelease candidate not intended for public use.**'; + } + + if (changelogArchive.startsWith(latestVersionHeading)) { + throw new Error('Cannot update changelog - already on latest version'); + } + + const updatedChangelog = `${latestVersionHeading}\n\n${upcomingChangelog}\n\n${changelogArchive}`; + await fs.writeFile(pathToChangelog, updatedChangelog); + + return pathToChangelog; +}; + +export const deleteObsoleteChangelogs = async (changelogFiles: string[]) => { + return rimraf(changelogFiles); +} diff --git a/packages/release-cli/src/version.ts b/packages/release-cli/src/version.ts new file mode 100644 index 000000000000..d8c1a3ef27c2 --- /dev/null +++ b/packages/release-cli/src/version.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type ChangelogMap } from './update_changelog'; + +export const VersionType = { + major: 'major', + minor: 'minor', + patch: 'patch', + backport: 'backport', + prerelease: 'prerelease', +} as const; + +export type VersionType = typeof VersionType[keyof typeof VersionType]; + +export const getUpcomingVersion = (currentVersion: string, target: string): string => { + let [major, minor, patch] = currentVersion.split('.').map(Number); + + const incrementPreId = (preId: string): string => { + const [versionWithoutPreId, affix] = currentVersion.split(`-${preId}`); + // Releasing from a main release, e.g. `v1.1.1` - add the preId and number automatically + if (!affix) return `${currentVersion}-${preId}.0`; + + const releaseNumber = Number(affix.split('.')[1]); + // Edge case for odd formats - coerce to the format we want + if (isNaN(releaseNumber)) return `${versionWithoutPreId}-${preId}.0`; + + // Otherwise, increment the existing release number + return `${versionWithoutPreId}-${preId}.${releaseNumber + 1}`; + }; + + switch (target) { + case 'major': + major += 1; + minor = 0; + patch = 0; + break; + case 'minor': + minor += 1; + patch = 0; + break; + case 'patch': + patch += 1; + break; + case 'backport': + return incrementPreId('backport'); + case 'prerelease': + return incrementPreId('rc'); + } + + return [major, minor, patch].join('.'); +}; + +export const getVersionTypeFromChangelog = (changelogMap: ChangelogMap): VersionType => { + const hasFeatures = changelogMap['Features'].length > 0; + const hasBugFixes = changelogMap['Bug fixes'].length > 0; + const hasBreakingChanges = changelogMap['Breaking changes'].length > 0; + + if (hasBugFixes && !hasFeatures) { + // there are bug fixes with no minor features + return VersionType.patch; + } + + if (hasBreakingChanges) { + // detected breaking changes + return VersionType.major; + } + + // default to a MINOR bump (new features, may have bug fixes, no breaking changes) + return VersionType.minor; +} diff --git a/packages/release-cli/src/workspace.ts b/packages/release-cli/src/workspace.ts new file mode 100644 index 000000000000..5bf5ea48ab78 --- /dev/null +++ b/packages/release-cli/src/workspace.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'node:path'; +import fs from 'node:fs/promises'; + +export const getRootWorkspaceDir = () => path.resolve(__dirname, '../../..'); + +export const getWorkspacePackageJsonPath = (workspaceDir: string) => { + return path.join(workspaceDir, 'package.json'); +}; + +export const getWorkspacePackageJson = async (workspaceDir: string) => { + const packageJsonPath = getWorkspacePackageJsonPath(workspaceDir); + + try { + const packageJsonContent= await fs.readFile(packageJsonPath, 'utf8'); + return JSON.parse(packageJsonContent); + } catch (err) { + const newErr = new Error(`Unable to read package.json from ${packageJsonPath}`); + newErr.stack = (err as Error).stack; + throw newErr; + } +} + +export const getWorkspacePackageVersion = async (workspaceDir: string) => { + const packageJson = await getWorkspacePackageJson(workspaceDir); + + return packageJson.version as string; +}; diff --git a/packages/release-cli/src/yarn_utils.ts b/packages/release-cli/src/yarn_utils.ts new file mode 100644 index 000000000000..e73bddbacad5 --- /dev/null +++ b/packages/release-cli/src/yarn_utils.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { promisify } from 'node:util'; +import { exec, execSync } from 'node:child_process'; + +const execPromise = promisify(exec); + +export interface YarnWorkspace { + location: string; + name: string; +} + +export const getYarnWorkspaces = async (includeRoot: boolean = false) => { + const result = await execPromise('yarn workspaces list --json'); + const workspaces = JSON.parse( + `[${result.stdout.replace(/\n/g, ',').slice(0, -1)}]` + ) as Array; + + if (includeRoot) { + return workspaces; + } + + return workspaces.filter((workspace) => workspace.location !== '.'); +}; + +export const runScriptOnAllWorkspaces = async (script: string) => { + return execPromise(`yarn workspaces foreach --all run ${script}`); +}; + +export const updateWorkspaceVersion = async (workspace: string, version: string) => { + return execPromise(`yarn workspace ${workspace} version ${version}`); +}; + +export const execPublish = (workspace: string, tag: string, otp?: string) => { + if (!tag) { + throw new Error('Tag must be defined'); + } + + const otpStr = otp ? `--otp ${otp}` : ''; + return execSync(`yarn workspace ${workspace} npm publish --access public --tag ${tag} ${otpStr}`, { stdio: 'inherit', encoding: 'utf8' }); +}; + +export const getAuthenticatedUser = async () => { + try { + const result = await execPromise('yarn npm whoami'); + // npmjs usernames can only contain alphanumeric characters and hypens + const [_, username] = result.stdout.split('\n')[0].split(': '); + + // yarn npm whoami can return `undefined` in certain cases + if (!username || username === 'undefined') { + return null; + } + + return username; + } catch (err) { + return null; + } +}; + +export const getYarnRegistryServer = async () => { + // Yarn supports scoped and non-scoped/global settings. + // We must check both to ensure we get the correct value + + const getOutput = async (cmd: string) => { + const result = await execPromise(cmd); + const rawOutput = result.stdout.trim(); + if (rawOutput === 'undefined') { + return null; + } + + const output = JSON.parse(rawOutput); + if (!output || output === '') { + return null; + } + + return output; + } + + const scopedOutput = await getOutput(`yarn config get 'npmScopes["elastic"].npmRegistryServer' --json`); + if (scopedOutput !== null) { + return scopedOutput; + } + + // return await to keep the rejection behavior the same + // for both getOutput calls + return await getOutput('yarn config get npmRegistryServer --json'); +}; diff --git a/packages/release-cli/tsconfig.json b/packages/release-cli/tsconfig.json new file mode 100644 index 000000000000..8d91f3eff131 --- /dev/null +++ b/packages/release-cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": false, + "outDir": "dist", + "strictBuiltinIteratorReturn": false, + }, + "include": [ + "src" + ] +} diff --git a/scripts/release.js b/scripts/release.js new file mode 100644 index 000000000000..acad0053d542 --- /dev/null +++ b/scripts/release.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +require('@elastic/eui-release-cli'); diff --git a/yarn.lock b/yarn.lock index 8ffad26951f8..8601540ae4bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4601,6 +4601,7 @@ __metadata: version: 0.0.0-use.local resolution: "@elastic/eui-docgen@workspace:packages/eui-docgen" dependencies: + "@elastic/eui": "workspace:^" glob: "npm:^11.0.0" react-docgen-typescript: "npm:^2.2.2" rimraf: "npm:^6.0.1" @@ -4646,10 +4647,31 @@ __metadata: version: 0.0.0-use.local resolution: "@elastic/eui-monorepo@workspace:." dependencies: + "@elastic/eui-release-cli": "link:packages/release-cli" pre-push: "npm:^0.1.4" languageName: unknown linkType: soft +"@elastic/eui-release-cli@link:packages/release-cli::locator=%40elastic%2Feui-monorepo%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@elastic/eui-release-cli@link:packages/release-cli::locator=%40elastic%2Feui-monorepo%40workspace%3A." + languageName: node + linkType: soft + +"@elastic/eui-release-cli@workspace:packages/release-cli": + version: 0.0.0-use.local + resolution: "@elastic/eui-release-cli@workspace:packages/release-cli" + dependencies: + "@types/prompts": "npm:^2.4.9" + chalk: "npm:^4" + glob: "npm:^11.0.1" + prompts: "npm:^2.4.2" + rimraf: "npm:^6.0.1" + typescript: "npm:^5.7.3" + yargs: "npm:^17.7.2" + languageName: unknown + linkType: soft + "@elastic/eui-theme-borealis@workspace:^, @elastic/eui-theme-borealis@workspace:packages/eui-theme-borealis": version: 0.0.0-use.local resolution: "@elastic/eui-theme-borealis@workspace:packages/eui-theme-borealis" @@ -8473,6 +8495,16 @@ __metadata: languageName: node linkType: hard +"@types/prompts@npm:^2.4.9": + version: 2.4.9 + resolution: "@types/prompts@npm:2.4.9" + dependencies: + "@types/node": "npm:*" + kleur: "npm:^3.0.3" + checksum: 10c0/22fe0da6807681c85e88ba283184f4be4c8a95c744ea12a638865c98c4e0c22e7f733542f6b0f1fbca02245cdc3fe84feacf9c9adf4ddd8bc98a337fd679d8d2 + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.3 resolution: "@types/prop-types@npm:15.7.3" @@ -12164,7 +12196,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": +"chalk@npm:4.1.2, chalk@npm:^4, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -18647,6 +18679,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^11.0.1": + version: 11.0.1 + resolution: "glob@npm:11.0.1" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^4.0.1" + minimatch: "npm:^10.0.0" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^2.0.0" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/2b32588be52e9e90f914c7d8dec32f3144b81b84054b0f70e9adfebf37cd7014570489f2a79d21f7801b9a4bd4cca94f426966bfd00fb64a5b705cfe10da3a03 + languageName: node + linkType: hard + "glob@npm:^7.0.0, glob@npm:^7.0.3, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.2.0": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -34717,6 +34765,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.7.3": + version: 5.7.3 + resolution: "typescript@npm:5.7.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/b7580d716cf1824736cc6e628ab4cd8b51877408ba2be0869d2866da35ef8366dd6ae9eb9d0851470a39be17cbd61df1126f9e211d8799d764ea7431d5435afa + languageName: node + linkType: hard + "typescript@npm:~5.5.4": version: 5.5.4 resolution: "typescript@npm:5.5.4" @@ -34747,6 +34805,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": + version: 5.7.3 + resolution: "typescript@patch:typescript@npm%3A5.7.3#optional!builtin::version=5.7.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6fd7e0ed3bf23a81246878c613423730c40e8bdbfec4c6e4d7bf1b847cbb39076e56ad5f50aa9d7ebd89877999abaee216002d3f2818885e41c907caaa192cc4 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A~5.5.4#optional!builtin": version: 5.5.4 resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=379a07" @@ -37080,7 +37148,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.3.1, yargs@npm:^17.5.1": +"yargs@npm:^17.3.1, yargs@npm:^17.5.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: