diff --git a/.buildkite/scripts/steps/checks/quick_checks.json b/.buildkite/scripts/steps/checks/quick_checks.json index 34396f6e5b24c..ee00e41afe019 100644 --- a/.buildkite/scripts/steps/checks/quick_checks.json +++ b/.buildkite/scripts/steps/checks/quick_checks.json @@ -64,6 +64,9 @@ { "script": ".buildkite/scripts/steps/checks/native_modules.sh" }, + { + "script": ".buildkite/scripts/steps/checks/yarn_install_scripts.sh" + }, { "script": ".buildkite/scripts/steps/checks/test_files_missing_owner.sh" }, @@ -85,4 +88,4 @@ "script": ".buildkite/scripts/steps/checks/verify_moon_projects.sh", "mayChangeFiles": true } -] +] \ No newline at end of file diff --git a/.buildkite/scripts/steps/checks/yarn_install_scripts.sh b/.buildkite/scripts/steps/checks/yarn_install_scripts.sh new file mode 100755 index 0000000000000..2a757b858d418 --- /dev/null +++ b/.buildkite/scripts/steps/checks/yarn_install_scripts.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo --- Check Yarn Install Scripts +node scripts/yarn_install_scripts scan --validate diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 550bacc17d31f..de359962688eb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -96,6 +96,7 @@ packages/kbn-tsconfig/base @elastic/kibana-operations packages/kbn-validate-next-docs-cli @elastic/kibana-operations packages/kbn-web-worker-stub @elastic/kibana-operations packages/kbn-whereis-pkg-cli @elastic/kibana-operations +packages/kbn-yarn-install-scripts @elastic/kibana-operations @elastic/kibana-security packages/kbn-yarn-lock-validator @elastic/kibana-operations src/core @elastic/kibana-core src/core/packages/analytics/browser @elastic/kibana-core diff --git a/.yarnrc b/.yarnrc index eceec9ca34a22..b974847df98b5 100644 --- a/.yarnrc +++ b/.yarnrc @@ -3,3 +3,6 @@ yarn-offline-mirror ".yarn-local-mirror" # Always look into the cache first before fetching online --install.prefer-offline true + +# Install scripts are managed by `yarn kbn bootstrap` via @kbn/yarn-install-scripts +ignore-scripts true diff --git a/package.json b/package.json index 324a20d6c2d67..2054c7b52e9d9 100644 --- a/package.json +++ b/package.json @@ -1625,6 +1625,7 @@ "@kbn/validate-next-docs-cli": "link:packages/kbn-validate-next-docs-cli", "@kbn/web-worker-stub": "link:packages/kbn-web-worker-stub", "@kbn/whereis-pkg-cli": "link:packages/kbn-whereis-pkg-cli", + "@kbn/yarn-install-scripts": "link:packages/kbn-yarn-install-scripts", "@kbn/yarn-lock-validator": "link:packages/kbn-yarn-lock-validator", "@mapbox/vector-tile": "1.3.1", "@mswjs/http-middleware": "0.10.3", diff --git a/packages/kbn-yarn-install-scripts/README.md b/packages/kbn-yarn-install-scripts/README.md new file mode 100644 index 0000000000000..94d42d837b75c --- /dev/null +++ b/packages/kbn-yarn-install-scripts/README.md @@ -0,0 +1,50 @@ +# @kbn/yarn-install-scripts + +Automatic script execution is disabled in `.yarnrc`. This package manages node_module lifecycle scripts for install and postinstall. + +## Configuration + +The `config.json` file contains an array of packages with install scripts and their configured status: + +```json +{ + "packages": [ + { + "path": "package-name", + "lifecycle": "postinstall", + "required": true, + "reason": "reason for action" + } + ] +} +``` + +- `path`: The package path in node_modules (e.g., `@elastic/eui`) +- `lifecycle`: Either `install` or `postinstall` +- `required`: `true` to run the script during bootstrap, `false` to skip it +- `reason`: Explanation of why the script is required or not + +## CLI Usage + +```bash +node scripts/yarn_install_scripts [options] +``` + +### Commands + +#### `run` + +Run allowed install scripts defined in `config.json`: + +```bash +node scripts/yarn_install_scripts run +node scripts/yarn_install_scripts run --verbose # Show install logs +``` + +#### `scan` + +Discovers packages with install scripts and shows whether they are run or skipped: + +```bash +node scripts/yarn_install_scripts scan +``` \ No newline at end of file diff --git a/packages/kbn-yarn-install-scripts/cli/index.ts b/packages/kbn-yarn-install-scripts/cli/index.ts new file mode 100644 index 0000000000000..7d81c12fbb1a6 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/cli/index.ts @@ -0,0 +1,66 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { run } from '@kbn/dev-cli-runner'; +import { createFailError } from '@kbn/dev-cli-errors'; + +import { runCommand } from './run_command'; +import { scanCommand } from './scan_command'; + +const SUPPORTED_COMMANDS = ['run', 'scan']; + +export function cli(): void { + run( + async ({ log, flags, flagsReader }) => { + const args = flagsReader.getPositionals(); + const command = args[0]; + + if (!command) { + throw createFailError( + `No command specified. Usage: node scripts/yarn_install_scripts <${SUPPORTED_COMMANDS.join( + '|' + )}>` + ); + } + + if (!SUPPORTED_COMMANDS.includes(command)) { + throw createFailError( + `Invalid command: ${command}. Must be one of: ${SUPPORTED_COMMANDS.join(', ')}` + ); + } + + if (command === 'run') runCommand(log, Boolean(flags.verbose), Boolean(flags['dry-run'])); + if (command === 'scan') scanCommand(log, Boolean(flags.validate)); + }, + { + usage: `node scripts/yarn_install_scripts [options]`, + description: 'Manage yarn install lifecycle scripts for dependencies', + flags: { + boolean: ['verbose', 'validate', 'dry-run'], + default: { + verbose: false, + validate: false, + 'dry-run': false, + }, + help: ` + Commands: + run - Run allowed install scripts + scan - List packages with install scripts + + Options for 'run': + --verbose Show full output from install scripts + --dry-run Show which install scripts would be run without running them + + Options for 'scan': + --validate Exit with error if any scripts are unconfigured + `, + }, + } + ); +} diff --git a/packages/kbn-yarn-install-scripts/cli/run_command.test.ts b/packages/kbn-yarn-install-scripts/cli/run_command.test.ts new file mode 100644 index 0000000000000..81dcc03ceec94 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/cli/run_command.test.ts @@ -0,0 +1,87 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ToolingLog } from '@kbn/tooling-log'; + +import { runCommand } from './run_command'; +import { loadConfig, runInstallScripts } from '../src'; +import type { InstallScriptsConfig } from '../src/types'; + +jest.mock('../src', () => ({ + loadConfig: jest.fn(), + runInstallScripts: jest.fn(), +})); + +const mockLoadConfig = loadConfig as jest.MockedFunction; +const mockRunInstallScripts = runInstallScripts as jest.MockedFunction; + +const mockLog = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + success: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + write: jest.fn(), +} as unknown as jest.Mocked; + +describe('runCommand', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should load config and run install scripts', () => { + const mockConfig: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + mockLoadConfig.mockReturnValue(mockConfig); + runCommand(mockLog, false, false); + expect(mockLoadConfig).toHaveBeenCalledTimes(1); + expect(mockRunInstallScripts).toHaveBeenCalledWith({ + config: mockConfig, + log: mockLog, + verbose: false, + dryRun: false, + }); + expect(mockLog.success).toHaveBeenCalledWith('Install scripts complete'); + }); + + it('should pass verbose=true to runInstallScripts', () => { + mockLoadConfig.mockReturnValue({ packages: [] }); + runCommand(mockLog, true, false); + expect(mockRunInstallScripts).toHaveBeenCalledWith(expect.objectContaining({ verbose: true })); + }); + + it('should pass dryRun=true to runInstallScripts', () => { + mockLoadConfig.mockReturnValue({ packages: [] }); + runCommand(mockLog, false, true); + expect(mockRunInstallScripts).toHaveBeenCalledWith(expect.objectContaining({ dryRun: true })); + }); + + it('should propagate errors from loadConfig', () => { + mockLoadConfig.mockImplementation(() => { + throw new Error('Config not found'); + }); + + expect(() => runCommand(mockLog, false, false)).toThrow('Config not found'); + expect(mockRunInstallScripts).not.toHaveBeenCalled(); + }); + + it('should propagate errors from runInstallScripts', () => { + mockLoadConfig.mockReturnValue({ packages: [] }); + mockRunInstallScripts.mockImplementation(() => { + throw new Error('Script failed'); + }); + + expect(() => runCommand(mockLog, false, false)).toThrow('Script failed'); + expect(mockLog.success).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-yarn-install-scripts/cli/run_command.ts b/packages/kbn-yarn-install-scripts/cli/run_command.ts new file mode 100644 index 0000000000000..dad4030b02027 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/cli/run_command.ts @@ -0,0 +1,25 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ToolingLog } from '@kbn/tooling-log'; + +import { loadConfig, runInstallScripts } from '../src'; + +export function runCommand(log: ToolingLog, verbose: boolean, dryRun: boolean): void { + const config = loadConfig(); + + runInstallScripts({ + config, + log, + verbose, + dryRun, + }); + + log.success('Install scripts complete'); +} diff --git a/packages/kbn-yarn-install-scripts/cli/scan_command.test.ts b/packages/kbn-yarn-install-scripts/cli/scan_command.test.ts new file mode 100644 index 0000000000000..a7cf8f33cd019 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/cli/scan_command.test.ts @@ -0,0 +1,142 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ToolingLog } from '@kbn/tooling-log'; +import { createFailError } from '@kbn/dev-cli-errors'; + +import { scanCommand } from './scan_command'; +import { loadConfig, scanInstallScripts } from '../src'; +import type { InstallScriptsConfig, PackageWithInstallScript } from '../src/types'; + +jest.mock('../src', () => ({ + loadConfig: jest.fn(), + scanInstallScripts: jest.fn(), + MANAGED_LIFECYCLES: ['install', 'postinstall'], +})); + +jest.mock('@kbn/dev-cli-errors', () => ({ + createFailError: jest.fn((msg: string) => new Error(msg)), +})); + +jest.mock('cli-table3', () => { + return jest.fn().mockImplementation(() => ({ + push: jest.fn(), + toString: jest.fn().mockReturnValue('mock table'), + })); +}); + +const mockLoadConfig = loadConfig as jest.MockedFunction; +const mockScanInstallScripts = scanInstallScripts as jest.MockedFunction; +const mockCreateFailError = createFailError as jest.MockedFunction; + +const mockLog = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + success: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + write: jest.fn(), +} as unknown as jest.Mocked; + +function createPackage( + name: string, + lifecycle: 'install' | 'postinstall' +): PackageWithInstallScript { + const versions: Record = { + '@elastic/eui': '112.0.0', + '@elastic/charts': '71.1.2', + }; + return { name, version: versions[name] || '1.0.0', path: name, lifecycle, script: 'echo' }; +} + +describe('scanCommand', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateFailError.mockImplementation((msg: string) => { + const err = new Error(msg) as Error & { exitCode: number; showHelp: boolean }; + err.exitCode = 1; + err.showHelp = false; + return err as ReturnType; + }); + }); + + it('should log success when no packages have install scripts', () => { + mockScanInstallScripts.mockReturnValue([]); + scanCommand(mockLog, false); + expect(mockLog.success).toHaveBeenCalledWith('No packages with install scripts found.'); + expect(mockLoadConfig).not.toHaveBeenCalled(); + }); + + it('should display status counts from config', () => { + const packages = [ + createPackage('@elastic/eui', 'postinstall'), + createPackage('@elastic/charts', 'postinstall'), + ]; + + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + { path: '@elastic/charts', lifecycle: 'postinstall', required: false, reason: 'Optional' }, + ], + }; + + mockScanInstallScripts.mockReturnValue(packages); + mockLoadConfig.mockReturnValue(config); + scanCommand(mockLog, false); + + expect(mockLog.success).toHaveBeenCalledWith( + 'Found 2 install scripts: 1 required, 1 skipped, 0 unconfigured' + ); + }); + + it('should warn about unconfigured packages', () => { + mockScanInstallScripts.mockReturnValue([createPackage('@elastic/eui', 'postinstall')]); + mockLoadConfig.mockReturnValue({ packages: [] }); + scanCommand(mockLog, false); + + expect(mockLog.warning).toHaveBeenCalledWith( + expect.stringContaining('unconfigured install script(s) found') + ); + }); + + it('should throw in validate mode when unconfigured packages exist', () => { + mockScanInstallScripts.mockReturnValue([createPackage('@elastic/eui', 'postinstall')]); + mockLoadConfig.mockReturnValue({ packages: [] }); + + expect(() => scanCommand(mockLog, true)).toThrow('unconfigured install script(s) found'); + }); + + it('should not throw in validate mode when all packages are configured', () => { + mockScanInstallScripts.mockReturnValue([createPackage('@elastic/eui', 'postinstall')]); + mockLoadConfig.mockReturnValue({ + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }); + + expect(() => scanCommand(mockLog, true)).not.toThrow(); + }); + + it('should propagate errors from scanInstallScripts', () => { + mockScanInstallScripts.mockImplementation(() => { + throw new Error('Scan failed'); + }); + expect(() => scanCommand(mockLog, false)).toThrow('Scan failed'); + }); + + it('should propagate errors from loadConfig', () => { + mockScanInstallScripts.mockReturnValue([createPackage('@elastic/eui', 'postinstall')]); + mockLoadConfig.mockImplementation(() => { + throw new Error('Config failed'); + }); + + expect(() => scanCommand(mockLog, false)).toThrow('Config failed'); + }); +}); diff --git a/packages/kbn-yarn-install-scripts/cli/scan_command.ts b/packages/kbn-yarn-install-scripts/cli/scan_command.ts new file mode 100644 index 0000000000000..856e88991743f --- /dev/null +++ b/packages/kbn-yarn-install-scripts/cli/scan_command.ts @@ -0,0 +1,90 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Table from 'cli-table3'; +import { createFailError } from '@kbn/dev-cli-errors'; +import type { ToolingLog } from '@kbn/tooling-log'; + +import { loadConfig, scanInstallScripts, MANAGED_LIFECYCLES } from '../src'; + +export function scanCommand(log: ToolingLog, validate: boolean): void { + const packages = scanInstallScripts(); + + if (packages.length === 0) { + log.success('No packages with install scripts found.'); + return; + } + + const config = loadConfig(); + const configMap = new Map(config.packages.map((p) => [`${p.path}:${p.lifecycle}`, p.required])); + + const table = new Table({ + head: ['Package', 'Status', 'Version', 'Lifecycle', 'Script'], + }); + + // Sort by lifecycle order, then alphabetically by path + const sortedPackages = [...packages].sort((a, b) => { + const lifecycleDiff = + MANAGED_LIFECYCLES.indexOf(a.lifecycle) - MANAGED_LIFECYCLES.indexOf(b.lifecycle); + if (lifecycleDiff !== 0) return lifecycleDiff; + return a.path.localeCompare(b.path); + }); + + let requiredCount = 0; + let skipCount = 0; + let unconfiguredCount = 0; + + for (const pkg of sortedPackages) { + const script = pkg.script.length > 50 ? pkg.script.substring(0, 47) + '...' : pkg.script; + const key = `${pkg.path}:${pkg.lifecycle}`; + const required = configMap.get(key); + let status: string; + if (required === true) { + status = 'required'; + requiredCount++; + } else if (required === false) { + status = 'skipped'; + skipCount++; + } else { + status = 'unconfigured'; + unconfiguredCount++; + } + table.push([pkg.path, status, pkg.version, pkg.lifecycle, script]); + } + + log.info(table.toString()); + log.success( + `Found ${packages.length} install scripts: ${requiredCount} required, ${skipCount} skipped, ${unconfiguredCount} unconfigured` + ); + + if (unconfiguredCount > 0) { + const message = `${unconfiguredCount} unconfigured install script(s) found. + +To resolve, add each unconfigured package to: + packages/kbn-yarn-install-scripts/config.json + +For each package, determine if the script is required: + - true: Script must run (e.g. downloads chromedriver, required for functional tests) + - false: Script does not need to run (e.g. informational message) + +Example entry: + { + "path": "package-name", + "lifecycle": "postinstall", + "required": true, + "reason": "Explanation of why this is required or not" + } +`; + if (validate) { + throw createFailError(message); + } else { + log.warning(message); + } + } +} diff --git a/packages/kbn-yarn-install-scripts/config.json b/packages/kbn-yarn-install-scripts/config.json new file mode 100644 index 0000000000000..a430e1f6a32fb --- /dev/null +++ b/packages/kbn-yarn-install-scripts/config.json @@ -0,0 +1,112 @@ +{ + "packages": [ + { + "path": "backport", + "lifecycle": "postinstall", + "required": false, + "reason": "Creates config in home directory" + }, + { + "path": "chromedriver", + "lifecycle": "install", + "required": true, + "reason": "Downloads chromedriver binary required for functional tests" + }, + { + "path": "core-js", + "lifecycle": "postinstall", + "required": false, + "reason": "Donation banner" + }, + { + "path": "core-js-pure", + "lifecycle": "postinstall", + "required": false, + "reason": "Donation banner" + }, + { + "path": "cypress", + "lifecycle": "postinstall", + "required": true, + "reason": "Downloads Cypress required for functional tests" + }, + { + "path": "es5-ext", + "lifecycle": "postinstall", + "required": false, + "reason": "Displays informational message" + }, + { + "path": "esbuild", + "lifecycle": "postinstall", + "required": true, + "reason": "Configures esbuild. Used by @elastic/synthetics" + }, + { + "path": "geckodriver", + "lifecycle": "postinstall", + "required": true, + "reason": "Downloads geckodriver required for functional tests" + }, + { + "path": "lmdb", + "lifecycle": "install", + "required": false, + "reason": "Builds native bindings for LMDB, used by @kbn/babel-register cache. Prebuilds are sufficient." + }, + { + "path": "ms-chromium-edge-driver", + "lifecycle": "postinstall", + "required": false, + "reason": "Downloads Edge driver if EDGEDRIVER_DOWNLOAD_ON_INSTALL" + }, + { + "path": "msgpackr-extract", + "lifecycle": "install", + "required": false, + "reason": "Builds native bindings for msgpack serialization used by lmdb. Prebuilds are sufficient." + }, + { + "path": "msw", + "lifecycle": "postinstall", + "required": false, + "reason": "Copies worker script if msw.workerDirectory is configured. Not used." + }, + { + "path": "nice-napi", + "lifecycle": "install", + "required": false, + "reason": "Optionally used by piscina to set nice values" + }, + { + "path": "playwright-chromium", + "lifecycle": "install", + "required": false, + "reason": "Browser installation handled by explicit 'yarn playwright install' in bootstrap" + }, + { + "path": "protobufjs", + "lifecycle": "postinstall", + "required": false, + "reason": "Prints version scheme warning - not needed" + }, + { + "path": "puppeteer", + "lifecycle": "postinstall", + "required": false, + "reason": "Downloads browser binaries for Puppeteer automation testing. skipDownload is true in .puppeteerrc" + }, + { + "path": "sharp", + "lifecycle": "install", + "required": false, + "reason": "Builds from source if required. Prebuilds are sufficient." + }, + { + "path": "@parcel/watcher", + "lifecycle": "install", + "required": false, + "reason": "Builds from source if npm_config_build_from_source=true. Prebuilds are sufficient." + } + ] +} \ No newline at end of file diff --git a/packages/kbn-yarn-install-scripts/jest.config.js b/packages/kbn-yarn-install-scripts/jest.config.js new file mode 100644 index 0000000000000..a3e7cf622900a --- /dev/null +++ b/packages/kbn-yarn-install-scripts/jest.config.js @@ -0,0 +1,14 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-yarn-install-scripts'], +}; diff --git a/packages/kbn-yarn-install-scripts/kibana.jsonc b/packages/kbn-yarn-install-scripts/kibana.jsonc new file mode 100644 index 0000000000000..84e0965b8690b --- /dev/null +++ b/packages/kbn-yarn-install-scripts/kibana.jsonc @@ -0,0 +1,11 @@ +{ + "type": "shared-common", + "id": "@kbn/yarn-install-scripts", + "owner": [ + "@elastic/kibana-operations", + "@elastic/kibana-security" + ], + "group": "platform", + "visibility": "private", + "devOnly": true +} diff --git a/packages/kbn-yarn-install-scripts/moon.yml b/packages/kbn-yarn-install-scripts/moon.yml new file mode 100644 index 0000000000000..faf1df056ea9c --- /dev/null +++ b/packages/kbn-yarn-install-scripts/moon.yml @@ -0,0 +1,48 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/yarn-install-scripts' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/yarn-install-scripts' +type: unknown +owners: + defaultOwner: '@elastic/kibana-operations' +toolchain: + default: node +language: typescript +project: + name: '@kbn/yarn-install-scripts' + description: Moon project for @kbn/yarn-install-scripts + channel: '' + owner: '@elastic/kibana-operations' + metadata: + sourceRoot: packages/kbn-yarn-install-scripts +dependsOn: + - '@kbn/dev-cli-errors' + - '@kbn/dev-cli-runner' + - '@kbn/repo-info' + - '@kbn/tooling-log' +tags: + - shared-common + - package + - dev + - group-platform + - private + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' +tasks: + jest: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' + jestCI: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' diff --git a/packages/kbn-yarn-install-scripts/package.json b/packages/kbn-yarn-install-scripts/package.json new file mode 100644 index 0000000000000..2386be6f99c38 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/yarn-install-scripts", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/kbn-yarn-install-scripts/src/index.ts b/packages/kbn-yarn-install-scripts/src/index.ts new file mode 100644 index 0000000000000..adf7c3d8f4f69 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/src/index.ts @@ -0,0 +1,13 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { loadConfig } from './load_config'; +export { runInstallScripts } from './run'; +export { scanInstallScripts } from './scan'; +export { MANAGED_LIFECYCLES } from './types'; diff --git a/packages/kbn-yarn-install-scripts/src/load_config.test.ts b/packages/kbn-yarn-install-scripts/src/load_config.test.ts new file mode 100644 index 0000000000000..1edafb349cebd --- /dev/null +++ b/packages/kbn-yarn-install-scripts/src/load_config.test.ts @@ -0,0 +1,56 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Fs from 'fs'; + +import { loadConfig } from './load_config'; + +jest.mock('fs'); + +const mockFs = Fs as jest.Mocked; + +describe('loadConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should load and parse a valid config file', () => { + const mockConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + { path: '@elastic/charts', lifecycle: 'install', required: false, reason: 'Optional' }, + ], + }; + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockConfig)); + const result = loadConfig(); + + expect(mockFs.readFileSync).toHaveBeenCalledWith( + expect.stringContaining('config.json'), + 'utf8' + ); + expect(result).toEqual(mockConfig); + }); + + it('should handle an empty packages array', () => { + mockFs.readFileSync.mockReturnValue(JSON.stringify({ packages: [] })); + expect(loadConfig().packages).toHaveLength(0); + }); + + it('should throw when config file does not exist', () => { + mockFs.readFileSync.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + expect(() => loadConfig()).toThrow('Failed to load configuration file'); + }); + + it('should throw when config file contains invalid JSON', () => { + mockFs.readFileSync.mockReturnValue('{ invalid json }'); + expect(() => loadConfig()).toThrow(); + }); +}); diff --git a/packages/kbn-yarn-install-scripts/src/load_config.ts b/packages/kbn-yarn-install-scripts/src/load_config.ts new file mode 100644 index 0000000000000..1368c42741c28 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/src/load_config.ts @@ -0,0 +1,26 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Fs from 'fs'; +import Path from 'path'; + +import { createFailError } from '@kbn/dev-cli-errors'; + +import type { InstallScriptsConfig } from './types'; + +export function loadConfig(): InstallScriptsConfig { + const configPath = Path.resolve(__dirname, '../config.json'); + + try { + const content = Fs.readFileSync(configPath, 'utf8'); + return JSON.parse(content) as InstallScriptsConfig; + } catch (error) { + throw createFailError(`Failed to load configuration file: ${configPath}`); + } +} diff --git a/packages/kbn-yarn-install-scripts/src/run.test.ts b/packages/kbn-yarn-install-scripts/src/run.test.ts new file mode 100644 index 0000000000000..946ee4b37d6b1 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/src/run.test.ts @@ -0,0 +1,335 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { spawnSync } from 'child_process'; +import Fs from 'fs'; + +import type { ToolingLog } from '@kbn/tooling-log'; + +import { runInstallScripts } from './run'; +import type { InstallScriptsConfig } from './types'; + +jest.mock('fs'); +jest.mock('child_process'); +jest.mock('@kbn/repo-info', () => ({ REPO_ROOT: '/mock/kibana' })); +jest.mock('@kbn/dev-cli-errors', () => ({ + createFailError: jest.fn((msg: string) => new Error(msg)), +})); + +const mockFs = Fs as jest.Mocked; +const mockSpawnSync = spawnSync as jest.MockedFunction; + +const mockLog = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + success: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + write: jest.fn(), +} as unknown as jest.Mocked; + +const mockSpawnResult = { + status: 0, + signal: null, + output: [], + pid: 1, + stdout: Buffer.from(''), + stderr: Buffer.from(''), +}; + +describe('runInstallScripts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return early when no packages are configured to run', () => { + runInstallScripts({ config: { packages: [] }, log: mockLog }); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it('should skip packages with required: false', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: false, reason: 'Not needed' }, + ], + }; + runInstallScripts({ config, log: mockLog }); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it('should run script for packages with required: true', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + const packageJson = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo' }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockSpawnSync.mockReturnValue(mockSpawnResult); + + runInstallScripts({ config, log: mockLog }); + + expect(mockSpawnSync).toHaveBeenCalledTimes(1); + const callArgs = mockSpawnSync.mock.calls[0]; + expect(callArgs[0]).toBe('echo'); + const options = callArgs[1] as unknown as { + cwd: string; + shell: boolean; + env: Record; + }; + expect(options.cwd).toBe('/mock/kibana/node_modules/@elastic/eui'); + expect(options.shell).toBe(true); + expect(options.env.npm_lifecycle_event).toBe('postinstall'); + expect(options.env.npm_package_name).toBe('@elastic/eui'); + }); + + it('should use stdio inherit when verbose is true, pipe when false', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + const packageJson = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo' }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockSpawnSync.mockReturnValue(mockSpawnResult); + + runInstallScripts({ config, log: mockLog, verbose: true }); + const verboseOptions = mockSpawnSync.mock.calls[0][1] as unknown as { stdio: string }; + expect(verboseOptions.stdio).toBe('inherit'); + + jest.clearAllMocks(); + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockSpawnSync.mockReturnValue(mockSpawnResult); + + runInstallScripts({ config, log: mockLog, verbose: false }); + const quietOptions = mockSpawnSync.mock.calls[0][1] as unknown as { stdio: string }; + expect(quietOptions.stdio).toBe('pipe'); + }); + + it('should throw when package.json does not exist', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + mockFs.existsSync.mockReturnValue(false); + + expect(() => runInstallScripts({ config, log: mockLog })).toThrow('Package not found'); + }); + + it('should throw when lifecycle script does not exist in package.json', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify({ name: '@elastic/eui', scripts: {} })); + + expect(() => runInstallScripts({ config, log: mockLog })).toThrow( + 'No postinstall script found' + ); + }); + + it('should throw when script exits with non-zero status', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + const packageJson = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'exit 1' }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockSpawnSync.mockReturnValue({ ...mockSpawnResult, status: 1 }); + + expect(() => runInstallScripts({ config, log: mockLog })).toThrow('failed'); + }); + + it('should run install scripts before postinstall scripts', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + { path: '@elastic/charts', lifecycle: 'install', required: true, reason: 'Required' }, + { path: '@elastic/datemath', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync + .mockReturnValueOnce( + JSON.stringify({ + name: '@elastic/charts', + version: '71.1.2', + scripts: { install: 'echo charts' }, + }) + ) + .mockReturnValueOnce( + JSON.stringify({ + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo eui' }, + }) + ) + .mockReturnValueOnce( + JSON.stringify({ + name: '@elastic/datemath', + version: '5.0.3', + scripts: { postinstall: 'echo datemath' }, + }) + ); + mockSpawnSync.mockReturnValue(mockSpawnResult); + + runInstallScripts({ config, log: mockLog }); + + expect(mockSpawnSync).toHaveBeenCalledTimes(3); + // install scripts should run first, then postinstall + expect(mockSpawnSync.mock.calls[0][0]).toBe('echo charts'); // @elastic/charts (install) + expect(mockSpawnSync.mock.calls[1][0]).toBe('echo eui'); // @elastic/eui (postinstall) + expect(mockSpawnSync.mock.calls[2][0]).toBe('echo datemath'); // @elastic/datemath (postinstall) + }); + + it('should run successfully when package.json has no version', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ name: '@elastic/eui', scripts: { postinstall: 'echo' } }) + ); + mockSpawnSync.mockReturnValue(mockSpawnResult); + + expect(() => runInstallScripts({ config, log: mockLog })).not.toThrow(); + expect(mockSpawnSync).toHaveBeenCalledTimes(1); + }); + + describe('dry-run mode', () => { + it('should not execute scripts when dryRun is true', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + const packageJson = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo "test"' }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + + runInstallScripts({ config, log: mockLog, dryRun: true }); + + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it('should log dry-run message when dryRun is true', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + const packageJson = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo "test"' }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + + runInstallScripts({ config, log: mockLog, dryRun: true }); + + expect(mockLog.info).toHaveBeenCalledWith('Running 1 install script(s)...'); + expect(mockLog.info).toHaveBeenCalledWith( + '[dry-run] Would run postinstall for @elastic/eui@112.0.0' + ); + }); + + it('should log dry-run message for multiple packages', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + { path: '@elastic/ecs', lifecycle: 'install', required: true, reason: 'Needed' }, + ], + }; + + mockFs.existsSync.mockReturnValue(true); + // Mocks are in lifecycle order (install before postinstall) due to sorting + mockFs.readFileSync + .mockReturnValueOnce( + JSON.stringify({ + name: '@elastic/ecs', + version: '9.0.0', + scripts: { install: 'echo install' }, + }) + ) + .mockReturnValueOnce( + JSON.stringify({ + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo eui' }, + }) + ); + + runInstallScripts({ config, log: mockLog, dryRun: true }); + + expect(mockSpawnSync).not.toHaveBeenCalled(); + expect(mockLog.info).toHaveBeenCalledWith('Running 2 install script(s)...'); + }); + + it('should still throw when package not found in dry-run mode', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + mockFs.existsSync.mockReturnValue(false); + + expect(() => runInstallScripts({ config, log: mockLog, dryRun: true })).toThrow( + 'Package not found' + ); + }); + + it('should still throw when lifecycle script missing in dry-run mode', () => { + const config: InstallScriptsConfig = { + packages: [ + { path: '@elastic/eui', lifecycle: 'postinstall', required: true, reason: 'Required' }, + ], + }; + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify({ name: '@elastic/eui', scripts: {} })); + + expect(() => runInstallScripts({ config, log: mockLog, dryRun: true })).toThrow( + 'No postinstall script found' + ); + }); + }); +}); diff --git a/packages/kbn-yarn-install-scripts/src/run.ts b/packages/kbn-yarn-install-scripts/src/run.ts new file mode 100644 index 0000000000000..d72493b8bbe97 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/src/run.ts @@ -0,0 +1,122 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { spawnSync } from 'child_process'; +import Fs from 'fs'; +import Path from 'path'; + +import { REPO_ROOT } from '@kbn/repo-info'; +import type { ToolingLog } from '@kbn/tooling-log'; +import { createFailError } from '@kbn/dev-cli-errors'; + +import { MANAGED_LIFECYCLES } from '.'; +import type { InstallScriptsConfig, PackageInstallScript, PackageJson } from './types'; + +interface RunOptions { + config: InstallScriptsConfig; + log: ToolingLog; + verbose?: boolean; + dryRun?: boolean; +} + +interface PackageScriptInfo { + packagePath: string; + packageJson: PackageJson; + version: string; + script: string; +} + +function getPackageScriptInfo(pkg: PackageInstallScript): PackageScriptInfo { + const packagePath = Path.join(REPO_ROOT, 'node_modules', pkg.path); + const packageJsonPath = Path.join(packagePath, 'package.json'); + + if (!Fs.existsSync(packageJsonPath)) { + throw createFailError( + `Package not found: ${pkg.path}\n\n` + + `The package may not be installed. Try running:\n` + + ` yarn kbn bootstrap` + ); + } + + const packageJson: PackageJson = JSON.parse(Fs.readFileSync(packageJsonPath, 'utf8')); + const version = packageJson.version || 'unknown'; + const script = packageJson.scripts?.[pkg.lifecycle]; + + if (!script) { + throw createFailError( + `No ${pkg.lifecycle} script found in ${pkg.path}\n\n` + + `The package.json does not contain a "${pkg.lifecycle}" script.\n` + + `If this package no longer needs this lifecycle script, update the configuration in:\n` + + ` packages/kbn-yarn-install-scripts/config.json` + ); + } + + return { packagePath, packageJson, version, script }; +} + +function runPackageInstallScript( + pkg: PackageInstallScript, + log: ToolingLog, + verbose: boolean, + dryRun: boolean +): void { + const { packagePath, packageJson, version, script } = getPackageScriptInfo(pkg); + const id = `${pkg.lifecycle} for ${pkg.path}@${version}`; + + if (dryRun) { + log.info(`[dry-run] Would run ${id}`); + return; + } + + log.info(`Running ${id}`); + + // Include node_modules/.bin in PATH, lifecycle scripts can reference these + const rootBinPath = Path.join(REPO_ROOT, 'node_modules', '.bin'); + const envPath = `${rootBinPath}${Path.delimiter}${process.env.PATH || ''}`; + + const result = spawnSync(script, { + cwd: packagePath, + stdio: verbose ? 'inherit' : 'pipe', + shell: true, + env: { + ...process.env, + PATH: envPath, + npm_lifecycle_event: pkg.lifecycle, + npm_package_name: packageJson.name, + }, + }); + + if (result.status !== 0) { + const output = [result.stdout, result.stderr] + .map((b) => b?.toString().trim()) + .filter(Boolean) + .join('\n'); + throw createFailError(`${id} failed\n${output}`); + } +} + +export function runInstallScripts(options: RunOptions): void { + const { config, log, verbose = false, dryRun = false } = options; + const packagesToRun = config.packages + .filter((p) => p.required) + .sort( + (a, b) => MANAGED_LIFECYCLES.indexOf(a.lifecycle) - MANAGED_LIFECYCLES.indexOf(b.lifecycle) + ); + + if (!packagesToRun.length) { + log.info('No install scripts configured to run'); + return; + } + + log.info(`Running ${packagesToRun.length} install script(s)...`); + + for (const pkg of packagesToRun) { + runPackageInstallScript(pkg, log, verbose, dryRun); + } +} diff --git a/packages/kbn-yarn-install-scripts/src/scan.test.ts b/packages/kbn-yarn-install-scripts/src/scan.test.ts new file mode 100644 index 0000000000000..91fa6f8c453f6 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/src/scan.test.ts @@ -0,0 +1,203 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Fs from 'fs'; +import type { Dirent } from 'fs'; + +import { scanInstallScripts } from './scan'; + +jest.mock('fs'); +jest.mock('@kbn/repo-info', () => ({ REPO_ROOT: '/mock/kibana' })); + +const mockFs = Fs as jest.Mocked; +const mockReaddirSync = mockFs.readdirSync as jest.Mock; + +function createDirent(name: string, isDir: boolean): Dirent { + return { name, isDirectory: () => isDir } as Dirent; +} + +describe('scanInstallScripts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw when root node_modules folder is missing', () => { + mockFs.existsSync.mockReturnValue(false); + expect(() => scanInstallScripts()).toThrow('No node_modules found'); + }); + + it('should find packages with postinstall scripts', () => { + const packageJson = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo' }, + }; + + mockReaddirSync + .mockReturnValueOnce([createDirent('@elastic', true)]) + .mockReturnValueOnce([createDirent('eui', true)]); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockFs.existsSync + .mockReturnValueOnce(true) // root node_modules + .mockReturnValueOnce(true) // package.json + .mockReturnValueOnce(false); // no nested node_modules + + const result = scanInstallScripts(); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: '@elastic/eui', + lifecycle: 'postinstall', + script: 'echo', + }); + }); + + it('should find both install and postinstall scripts', () => { + const packageJson = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { install: 'echo install', postinstall: 'echo postinstall' }, + }; + + mockReaddirSync + .mockReturnValueOnce([createDirent('@elastic', true)]) + .mockReturnValueOnce([createDirent('eui', true)]); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockFs.existsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + const result = scanInstallScripts(); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.lifecycle)).toEqual(['install', 'postinstall']); + }); + + it('should scan multiple packages', () => { + const euiPkg = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo eui' }, + }; + const chartsPkg = { + name: '@elastic/charts', + version: '71.1.2', + scripts: { postinstall: 'echo charts' }, + }; + + mockReaddirSync + .mockReturnValueOnce([createDirent('@elastic', true)]) + .mockReturnValueOnce([createDirent('eui', true), createDirent('charts', true)]); + mockFs.readFileSync + .mockReturnValueOnce(JSON.stringify(euiPkg)) + .mockReturnValueOnce(JSON.stringify(chartsPkg)); + mockFs.existsSync + .mockReturnValueOnce(true) // root node_modules + .mockReturnValueOnce(true) // eui pkg.json + .mockReturnValueOnce(false) // eui nested node_modules + .mockReturnValueOnce(true) // charts pkg.json + .mockReturnValueOnce(false); // charts nested node_modules + + const result = scanInstallScripts(); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.name)).toContain('@elastic/eui'); + expect(result.map((r) => r.name)).toContain('@elastic/charts'); + }); + + it('should return empty array when no packages have install scripts', () => { + const packageJson = { name: '@elastic/eui', version: '112.0.0', scripts: {} }; + + mockReaddirSync + .mockReturnValueOnce([createDirent('@elastic', true)]) + .mockReturnValueOnce([createDirent('eui', true)]); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockFs.existsSync + .mockReturnValueOnce(true) // root node_modules + .mockReturnValueOnce(true) // eui pkg.json + .mockReturnValueOnce(false); // eui nested node_modules + + const result = scanInstallScripts(); + expect(result).toEqual([]); + }); + + it('should throw when package.json is missing name', () => { + const packageJson = { version: '112.0.0', scripts: { postinstall: 'echo' } }; + + mockReaddirSync + .mockReturnValueOnce([createDirent('@elastic', true)]) + .mockReturnValueOnce([createDirent('eui', true)]); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockFs.existsSync.mockReturnValueOnce(true).mockReturnValueOnce(true); + + expect(() => scanInstallScripts()).toThrow('missing required name or version field'); + }); + + it('should throw when package.json is missing version', () => { + const packageJson = { name: '@elastic/eui', scripts: { postinstall: 'echo' } }; + mockReaddirSync + .mockReturnValueOnce([createDirent('@elastic', true)]) + .mockReturnValueOnce([createDirent('eui', true)]); + mockFs.readFileSync.mockReturnValue(JSON.stringify(packageJson)); + mockFs.existsSync.mockReturnValueOnce(true).mockReturnValueOnce(true); + + expect(() => scanInstallScripts()).toThrow('missing required name or version field'); + }); + + it('should throw when package.json is invalid JSON', () => { + mockReaddirSync + .mockReturnValueOnce([createDirent('@elastic', true)]) + .mockReturnValueOnce([createDirent('eui', true)]); + mockFs.readFileSync.mockReturnValue('{ invalid }'); + mockFs.existsSync.mockReturnValueOnce(true).mockReturnValueOnce(true); + + expect(() => scanInstallScripts()).toThrow(); + }); + + it('should scan nested node_modules directories', () => { + const parentPkg = { + name: '@elastic/eui', + version: '112.0.0', + scripts: { postinstall: 'echo parent' }, + }; + const nestedPkg = { + name: '@elastic/charts', + version: '71.1.2', + scripts: { postinstall: 'echo nested' }, + }; + + mockReaddirSync + // root node_modules -> @elastic scope + .mockReturnValueOnce([createDirent('@elastic', true)]) + // @elastic scope in root -> eui package + .mockReturnValueOnce([createDirent('eui', true)]) + // eui's nested node_modules -> @elastic scope + .mockReturnValueOnce([createDirent('@elastic', true)]) + // @elastic scope in nested -> charts package + .mockReturnValueOnce([createDirent('charts', true)]); + + mockFs.readFileSync + .mockReturnValueOnce(JSON.stringify(parentPkg)) + .mockReturnValueOnce(JSON.stringify(nestedPkg)); + + mockFs.existsSync + .mockReturnValueOnce(true) // root node_modules exists + .mockReturnValueOnce(true) // eui package.json exists + .mockReturnValueOnce(true) // eui has nested node_modules + .mockReturnValueOnce(true) // charts package.json exists + .mockReturnValueOnce(false); // charts has no nested node_modules + + const result = scanInstallScripts(); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.name)).toContain('@elastic/eui'); + expect(result.map((r) => r.name)).toContain('@elastic/charts'); + }); +}); diff --git a/packages/kbn-yarn-install-scripts/src/scan.ts b/packages/kbn-yarn-install-scripts/src/scan.ts new file mode 100644 index 0000000000000..8477448ed7e6a --- /dev/null +++ b/packages/kbn-yarn-install-scripts/src/scan.ts @@ -0,0 +1,74 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Fs from 'fs'; +import Path from 'path'; + +import { REPO_ROOT } from '@kbn/repo-info'; + +import type { PackageWithInstallScript, PackageJson } from './types'; +import { MANAGED_LIFECYCLES } from '.'; + +export function scanInstallScripts(): PackageWithInstallScript[] { + const rootNodeModulesDir = Path.join(REPO_ROOT, 'node_modules'); + const packagesWithInstallScripts: PackageWithInstallScript[] = []; + + if (!Fs.existsSync(rootNodeModulesDir)) { + throw new Error('No node_modules found. Run yarn kbn bootstrap first.'); + } + + const stack: string[] = [rootNodeModulesDir]; + + while (stack.length > 0) { + const currentDir = stack.pop()!; + + const entries = Fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const entryPath = Path.join(currentDir, entry.name); + + if (entry.name.startsWith('@')) { + stack.push(entryPath); + continue; + } + + const packageJsonPath = Path.join(entryPath, 'package.json'); + if (Fs.existsSync(packageJsonPath)) { + const packageJsonContent = Fs.readFileSync(packageJsonPath, 'utf8'); + const packageJson: PackageJson = JSON.parse(packageJsonContent); + + if (!packageJson.name || !packageJson.version) { + throw new Error( + `Invalid package.json at ${packageJsonPath}: missing required name or version field` + ); + } + + for (const lifecycle of MANAGED_LIFECYCLES) { + const script = packageJson.scripts?.[lifecycle]; + if (script) { + packagesWithInstallScripts.push({ + name: packageJson.name, + version: packageJson.version, + path: Path.relative(rootNodeModulesDir, entryPath), + lifecycle, + script, + }); + } + } + } + + const nestedNodeModulesPath = Path.join(entryPath, 'node_modules'); + if (Fs.existsSync(nestedNodeModulesPath)) { + stack.push(nestedNodeModulesPath); + } + } + } + + return packagesWithInstallScripts; +} diff --git a/packages/kbn-yarn-install-scripts/src/types.ts b/packages/kbn-yarn-install-scripts/src/types.ts new file mode 100644 index 0000000000000..79f891b733daa --- /dev/null +++ b/packages/kbn-yarn-install-scripts/src/types.ts @@ -0,0 +1,39 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const MANAGED_LIFECYCLES = ['install', 'postinstall'] as const; +export type Lifecycle = (typeof MANAGED_LIFECYCLES)[number]; + +export interface PackageInstallScript { + path: string; + lifecycle: Lifecycle; + required: boolean; + reason: string; +} + +export interface InstallScriptsConfig { + packages: PackageInstallScript[]; +} + +export interface PackageWithInstallScript { + name: string; + version: string; + path: string; + lifecycle: Lifecycle; + script: string; +} + +export interface PackageJson { + name?: string; + version?: string; + scripts?: { + install?: string; + postinstall?: string; + }; +} diff --git a/packages/kbn-yarn-install-scripts/tsconfig.json b/packages/kbn-yarn-install-scripts/tsconfig.json new file mode 100644 index 0000000000000..721098d8baa17 --- /dev/null +++ b/packages/kbn-yarn-install-scripts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": ["jest", "node"] + }, + "include": ["**/*.ts"], + "exclude": ["target/**/*"], + "kbn_references": [ + "@kbn/dev-cli-errors", + "@kbn/dev-cli-runner", + "@kbn/repo-info", + "@kbn/tooling-log" + ] +} diff --git a/scripts/yarn_install_scripts.js b/scripts/yarn_install_scripts.js new file mode 100644 index 0000000000000..185e2209d2e41 --- /dev/null +++ b/scripts/yarn_install_scripts.js @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +require('@kbn/setup-node-env'); +require('@kbn/yarn-install-scripts/cli').cli(); diff --git a/src/dev/kbn_pm/src/commands/bootstrap/bootstrap_command.mjs b/src/dev/kbn_pm/src/commands/bootstrap/bootstrap_command.mjs index bf1f7c4a7333c..97836a30935e2 100644 --- a/src/dev/kbn_pm/src/commands/bootstrap/bootstrap_command.mjs +++ b/src/dev/kbn_pm/src/commands/bootstrap/bootstrap_command.mjs @@ -15,6 +15,7 @@ import { checkYarnIntegrity, removeYarnIntegrityFileIfExists, yarnInstallDeps, + runInstallScripts, } from './yarn.mjs'; import { sortPackageJson } from './sort_package_json.mjs'; import { regeneratePackageMap } from './regenerate_package_map.mjs'; @@ -109,6 +110,10 @@ export const command = { } }); + await time('run install scripts', async () => { + await runInstallScripts(log, { quiet }); + }); + await time('pre-build webpack bundles for packages', async () => { log.info('pre-build webpack bundles for packages'); await run( diff --git a/src/dev/kbn_pm/src/commands/bootstrap/yarn.mjs b/src/dev/kbn_pm/src/commands/bootstrap/yarn.mjs index f3c96e03e8825..1dbc61e22eb58 100644 --- a/src/dev/kbn_pm/src/commands/bootstrap/yarn.mjs +++ b/src/dev/kbn_pm/src/commands/bootstrap/yarn.mjs @@ -58,6 +58,21 @@ export async function yarnInstallDeps(log, { offline, quiet }) { log.success('Playwright browsers installed'); } +/** + * Runs allowlisted install scripts (.yarnrc ignore-scripts is enabled) + * @param {import('src/platform/packages/private/kbn-some-dev-log').SomeDevLog} log + * @param {{ quiet: boolean }} options + * @returns {Promise} + */ +export async function runInstallScripts(log, { quiet }) { + log.info('running allowlisted install scripts'); + await run('node', ['scripts/yarn_install_scripts.js', 'run'], { + cwd: REPO_ROOT, + pipe: !quiet, + }); + log.success('install scripts completed'); +} + /** * Checks if the installed state adheres to the integrity checksums from the yarn.lock file * @param {import('src/platform/packages/private/kbn-some-dev-log').SomeDevLog} log diff --git a/tsconfig.base.json b/tsconfig.base.json index afd3c1513a53f..26d5349af4452 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2270,6 +2270,8 @@ "@kbn/workchat-framework-plugin/*": ["x-pack/solutions/chat/plugins/workchat-framework/*"], "@kbn/xstate-utils": ["src/platform/packages/shared/kbn-xstate-utils"], "@kbn/xstate-utils/*": ["src/platform/packages/shared/kbn-xstate-utils/*"], + "@kbn/yarn-install-scripts": ["packages/kbn-yarn-install-scripts"], + "@kbn/yarn-install-scripts/*": ["packages/kbn-yarn-install-scripts/*"], "@kbn/yarn-lock-validator": ["packages/kbn-yarn-lock-validator"], "@kbn/yarn-lock-validator/*": ["packages/kbn-yarn-lock-validator/*"], "@kbn/zod": ["src/platform/packages/shared/kbn-zod"], diff --git a/yarn.lock b/yarn.lock index d338bf8f399be..3b92a870a81bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8786,6 +8786,10 @@ version "0.0.0" uid "" +"@kbn/yarn-install-scripts@link:packages/kbn-yarn-install-scripts": + version "0.0.0" + uid "" + "@kbn/yarn-lock-validator@link:packages/kbn-yarn-lock-validator": version "0.0.0" uid ""