diff --git a/e2e/cli-e2e/project.json b/e2e/cli-e2e/project.json index 0d254242..4fff0d7b 100644 --- a/e2e/cli-e2e/project.json +++ b/e2e/cli-e2e/project.json @@ -17,6 +17,12 @@ "workspaceRoot": "tmp/cli-e2e" } }, + "kill-process": { + "executor": "@org/build-env:kill-process", + "options": { + "workspaceRoot": "tmp/cli-e2e" + } + }, "e2e": { "dependsOn": [ { diff --git a/nx.json b/nx.json index 12813c22..61210a55 100644 --- a/nx.json +++ b/nx.json @@ -5,7 +5,10 @@ "libsDir": "projects" }, "namedInputs": { - "default": ["{projectRoot}/**/*", "sharedGlobals"], + "default": [ + "{projectRoot}/**/*", + "sharedGlobals" + ], "production": [ "default", "!{projectRoot}/.eslintrc.json", @@ -22,36 +25,60 @@ "targetDefaults": { "@nx/esbuild:esbuild": { "cache": true, - "dependsOn": ["^build"], - "inputs": ["production", "^production"] + "dependsOn": [ + "^build" + ], + "inputs": [ + "production", + "^production" + ] }, "@nx/vite:test": { "cache": true, - "inputs": ["default", "^production"] + "inputs": [ + "default", + "^production" + ] }, "nx-release-publish": { "dependsOn": [ { - "projects": ["self"], + "projects": [ + "self" + ], "target": "build" }, { - "projects": ["dependencies"], + "projects": [ + "dependencies" + ], "target": "nx-release-publish" } ] }, "build": { - "inputs": ["production", "^production"] + "inputs": [ + "production", + "^production" + ] }, "@nx/js:tsc": { "cache": true, - "dependsOn": ["^build"], - "inputs": ["production", "^production"] + "dependsOn": [ + "^build" + ], + "inputs": [ + "production", + "^production" + ] }, "@nx/jest:jest": { "cache": true, - "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/jest.preset.js" + ], "options": { "passWithNoTests": true }, @@ -70,11 +97,20 @@ "targetName": "lint" } }, - "./tools/e2e-example-plugins/original.plugin.ts", - "./tools/e2e-example-plugins/env.plugin.ts", - "./tools/e2e-example-plugins/graph.plugin.ts", - "./tools/e2e-example-plugins/pretarget.plugin.ts", - "./tools/plugins/verdaccio-test-env.plugin.ts" + { + "plugin": "@nx/jest/plugin", + "options": { + "jestConfig": "{projectRoot}/jest.config.js", + "tsConfig": "{projectRoot}/tsconfig.spec.json", + "babelConfig": "{projectRoot}/babel.config.js" + } + }, + { + "plugin": "@nx/esbuild/plugin", + "options": { + "config": "{projectRoot}/esbuild.config.js" + } + } ], "release": { "version": { diff --git a/tools/build-env/executors.json b/tools/build-env/executors.json index 66fc0696..d433a95c 100644 --- a/tools/build-env/executors.json +++ b/tools/build-env/executors.json @@ -4,6 +4,11 @@ "implementation": "./src/executors/build/executor", "schema": "./src/executors/build/schema.json", "description": "Generate test environments in your workspace. Cached and ready for use." + }, + "kill-process": { + "implementation": "./src/executors/kill-process/executor", + "schema": "./src/executors/kill-process/schema.json", + "description": "´Kills process by PID, command or file path." } } } diff --git a/tools/build-env/generators.json b/tools/build-env/generators.json deleted file mode 100644 index c07cbb70..00000000 --- a/tools/build-env/generators.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "generators": { - "init": { - "factory": "./src/generators/init/generator", - "schema": "./src/generators/init/schema.json", - "description": "init generator" - }, - "configuration": { - "factory": "./src/generators/configuration/generator", - "schema": "./src/generators/configuration/schema.json", - "description": "configuration generator" - } - } -} diff --git a/tools/build-env/src/executors/build/executor.ts b/tools/build-env/src/executors/build/executor.ts index a7b75dd2..c0c10feb 100644 --- a/tools/build-env/src/executors/build/executor.ts +++ b/tools/build-env/src/executors/build/executor.ts @@ -1,6 +1,8 @@ import {type ExecutorContext, logger} from '@nx/devkit'; // eslint-disable-next-line n/no-sync import type {BuildExecutorOptions} from './schema'; +import {setupNpmEnv} from "../../internal/verdaccio/verdaccio-npm-env"; +import {join} from "node:path"; export type ExecutorOutput = { success: boolean; @@ -8,13 +10,23 @@ export type ExecutorOutput = { error?: Error; }; -export default function runBuildExecutor( +export default async function runBuildExecutor( terminalAndExecutorOptions: BuildExecutorOptions, context: ExecutorContext, ) { - logger.info(`Execute @org/build-env:build with options: ${JSON.stringify(terminalAndExecutorOptions)}`) - + const {projectName} = context; + const normalizedOptions = { + ...terminalAndExecutorOptions, + workspaceRoot: join('tmp', 'environments', projectName) + }; + logger.info(`Execute @org/build-env:build with options: ${JSON.stringify(terminalAndExecutorOptions, null, 2)}`); + try { + const envResult = await setupNpmEnv(normalizedOptions) + logger.info(`envResult: ${JSON.stringify(envResult, null, 2)}`); + } catch (error) { + logger.error(error); + } return Promise.resolve({ success: true, command: '????????', diff --git a/tools/build-env/src/executors/kill-process/README.md b/tools/build-env/src/executors/kill-process/README.md new file mode 100644 index 00000000..30ed74f9 --- /dev/null +++ b/tools/build-env/src/executors/kill-process/README.md @@ -0,0 +1,53 @@ +# Kill Process Executor + +This executor is used to kill a process by PID, command of file. + +#### @org/build-env:kill-process + +## Usage + +// project.json + +```json +{ + "name": "my-project", + "targets": { + "stop-verdaccio-env": { + "executor": "@org/build-env:kill-process" + } + } +} +``` + +By default, the Nx plugin will derive the options from the executor config. + +The following things happen: + +- ??? + +```jsonc +{ + "name": "my-project", + "targets": { + "stop-verdaccio-env": { + "executor": "@org/build-env:kill-process", + "options": { + "filePath": "verdaccio-pid.json", + "verbose": true, + "progress": false + } + } + } +} +``` + +Show what will be executed without actually executing it: + +`nx run my-project:stop-verdaccio-env --dryRun` + +## Options + +| Name | type | description | +|--------------| --------- |--------------------------------------------------------------------| +| **filePath** | `string` | Path to the file containing the PID of the process | +| **dryRun** | `boolean` | To debug the executor, dry run the command without real execution. | diff --git a/tools/build-env/src/executors/kill-process/executor.ts b/tools/build-env/src/executors/kill-process/executor.ts new file mode 100644 index 00000000..f375aee4 --- /dev/null +++ b/tools/build-env/src/executors/kill-process/executor.ts @@ -0,0 +1,35 @@ +import {type ExecutorContext, logger} from '@nx/devkit'; +// eslint-disable-next-line n/no-sync +import type {KillProcessExecutorOptions} from './schema'; +import {join} from "node:path"; +import {killProcessFromPid} from "../../internal/utils/process"; + +export type ExecutorOutput = { + success: boolean; + command?: string; + error?: Error; +}; + +export default async function runKillProcessExecutor( + terminalAndExecutorOptions: KillProcessExecutorOptions, + context: ExecutorContext, +) { + + const {projectName} = context; + const {workspaceRoot} = { + ...terminalAndExecutorOptions, + workspaceRoot: join('tmp', 'environments', projectName) + }; + + logger.info(`Execute @org/stop-verdaccio-env:kill-process with options: ${JSON.stringify(terminalAndExecutorOptions, null, 2)}`); + try { + const envResult = await killProcessFromPid(join(workspaceRoot, 'verdaccio-registry.json')); + logger.info(`envResult: ${JSON.stringify(['envResult'], null, 2)}`); + } catch (error) { + logger.error(error); + } + return Promise.resolve({ + success: true, + command: '????????', + } satisfies ExecutorOutput); +} diff --git a/tools/build-env/src/executors/kill-process/executor.unit.test.ts b/tools/build-env/src/executors/kill-process/executor.unit.test.ts new file mode 100644 index 00000000..67df65e5 --- /dev/null +++ b/tools/build-env/src/executors/kill-process/executor.unit.test.ts @@ -0,0 +1,114 @@ +import {ExecutorContext, logger} from '@nx/devkit'; +// eslint-disable-next-line n/no-sync +import {execSync} from 'node:child_process'; +import {afterEach, beforeEach, expect, vi} from 'vitest'; +import runKillProcessExecutor from './executor'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + + return { + ...actual, + // eslint-disable-next-line n/no-sync + execSync: vi.fn((command: string) => { + if (command.includes('THROW_ERROR')) { + throw new Error(command); + } + }), + }; +}); + +describe('runAutorunExecutor', () => { + const envSpy = vi.spyOn(process, 'env', 'get'); + const loggerInfoSpy = vi.spyOn(logger, 'info'); + const loggerWarnSpy = vi.spyOn(logger, 'warn'); + + beforeEach(() => { + envSpy.mockReturnValue({}); + }); + afterEach(() => { + loggerWarnSpy.mockReset(); + loggerInfoSpy.mockReset(); + envSpy.mockReset().mockReturnValue({}); + }); + + it('should call execSync with stop-verdaccio command and return result', async () => { + const output = await runKillProcessExecutor({}, {} as ExecutorContext); + expect(output.success).toBe(true); + expect(output.command).toMatch('npx @org/cli stop-verdaccio'); + // eslint-disable-next-line n/no-sync + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('npx @org/cli stop-verdaccio'), + { cwd: '/test' }, + ); + }); + + it('should normalize context', async () => { + const output = await runKillProcessExecutor( + {}, + { + ...{} as ExecutorContext, + cwd: 'cwd-form-context', + }, + ); + expect(output.success).toBe(true); + expect(output.command).toMatch('utils'); + // eslint-disable-next-line n/no-sync + expect(execSync).toHaveBeenCalledWith(expect.stringContaining('utils'), { + cwd: 'cwd-form-context', + }); + }); + + it('should process executorOptions', async () => { + const output = await runKillProcessExecutor( + { workspaceRoot: '.' }, + {} as ExecutorContext, + ); + expect(output.success).toBe(true); + expect(output.command).toMatch('--persist.filename="REPORT"'); + }); + + it('should create command from context, options and arguments', async () => { + envSpy.mockReturnValue({ CP_PROJECT: 'CLI' }); + const output = await runKillProcessExecutor( + { workspaceRoot: '.' }, + {} as ExecutorContext + ); + expect(output.command).toMatch('--persist.filename="REPORT"'); + expect(output.command).toMatch( + '--persist.format="md" --persist.format="json"', + ); + expect(output.command).toMatch('--upload.project="CLI"'); + }); + + it('should log information if verbose is set', async () => { + const output = await runKillProcessExecutor( + { verbose: true }, + { ...{} as ExecutorContext, cwd: '' }, + ); + // eslint-disable-next-line n/no-sync + expect(execSync).toHaveBeenCalledTimes(1); + + expect(output.command).toMatch('--verbose'); + expect(loggerWarnSpy).toHaveBeenCalledTimes(0); + expect(loggerInfoSpy).toHaveBeenCalledTimes(2); + expect(loggerInfoSpy).toHaveBeenCalledWith( + expect.stringContaining('Run stop-verdaccio executor'), + ); + expect(loggerInfoSpy).toHaveBeenCalledWith( + expect.stringContaining('Command: npx @org/cli stop-verdaccio'), + ); + }); + + it('should log command if dryRun is set', async () => { + await runKillProcessExecutor({ dryRun: true }, {} as ExecutorContext); + + expect(loggerInfoSpy).toHaveBeenCalledTimes(0); + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'DryRun execution of: npx @org/cli stop-verdaccio --dryRun', + ), + ); + }); +}); diff --git a/tools/build-env/src/executors/kill-process/schema.json b/tools/build-env/src/executors/kill-process/schema.json new file mode 100644 index 00000000..050fbfd5 --- /dev/null +++ b/tools/build-env/src/executors/kill-process/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "KillProcessExecutorOptions", + "title": "A executor to kill processes by PID, command, or file", + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Print the commands that would be run, but don't actually run them" + }, + "filePath": { + "type": "string", + "description": "The path to the file to kill the process for" + }, + "verbose": { + "type": "boolean", + "description": "Print additional logs" + } + }, + "additionalProperties": true +} diff --git a/tools/build-env/src/executors/kill-process/schema.ts b/tools/build-env/src/executors/kill-process/schema.ts new file mode 100644 index 00000000..1d099519 --- /dev/null +++ b/tools/build-env/src/executors/kill-process/schema.ts @@ -0,0 +1,5 @@ +export type KillProcessExecutorOptions = Partial<{ + filePath: string; + dryRun: boolean; + verbose: boolean; +}>; diff --git a/tools/build-env/src/internal/utils/execute-process.ts b/tools/build-env/src/internal/utils/execute-process.ts new file mode 100644 index 00000000..02d974a5 --- /dev/null +++ b/tools/build-env/src/internal/utils/execute-process.ts @@ -0,0 +1,104 @@ +import { + type ChildProcess, + type ChildProcessByStdio, + type SpawnOptionsWithStdioTuple, + type StdioPipe, + spawn, +} from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; + +export type ProcessResult = { + stdout: string; + stderr: string; + code: number | null; + date: string; + duration: number; +}; + +export class ProcessError extends Error { + code: number | null; + stderr: string; + stdout: string; + + constructor(result: ProcessResult) { + super(result.stderr); + this.code = result.code; + this.stderr = result.stderr; + this.stdout = result.stdout; + } +} + +export type ProcessConfig = Omit< + SpawnOptionsWithStdioTuple, + 'stdio' +> & { + command: string; + args?: string[]; + verbose?: boolean; + observer?: ProcessObserver; + ignoreExitCode?: boolean; +}; + +export type ProcessObserver = { + onStdout?: (stdout: string, sourceProcess?: ChildProcess) => void; + onStderr?: (stderr: string, sourceProcess?: ChildProcess) => void; + onError?: (error: ProcessError) => void; + onComplete?: () => void; +}; + +export function executeProcess(cfg: ProcessConfig): Promise { + const { + command, + args, + observer, + verbose = false, + ignoreExitCode = false, + ...options + } = cfg; + const { onStdout, onStderr, onError, onComplete } = observer ?? {}; + const date = new Date().toISOString(); + const start = performance.now(); + + return new Promise((resolve, reject) => { + // shell:true tells Windows to use shell command for spawning a child process + const spawnedProcess = spawn(command, args ?? [], { + shell: true, + ...options, + }) as ChildProcessByStdio; + + let stdout = ''; + let stderr = ''; + + spawnedProcess.stdout.on('data', (data) => { + stdout += String(data); + if (verbose) { + console.info(String(data)); + } + onStdout?.(String(data), spawnedProcess); + }); + + spawnedProcess.stderr.on('data', (data) => { + stderr += String(data); + if (verbose) { + console.error(String(data)); + } + onStderr?.(String(data), spawnedProcess); + }); + + spawnedProcess.on('error', (err) => { + stderr += err.toString(); + }); + + spawnedProcess.on('close', (code) => { + const timings = { date, duration: performance.now() - start }; + if (code === 0 || ignoreExitCode) { + onComplete?.(); + resolve({ code, stdout, stderr, ...timings }); + } else { + const errorMsg = new ProcessError({ code, stdout, stderr, ...timings }); + onError?.(errorMsg); + reject(errorMsg); + } + }); + }); +} diff --git a/tools/build-env/src/internal/utils/logging.ts b/tools/build-env/src/internal/utils/logging.ts new file mode 100644 index 00000000..2cb86b9d --- /dev/null +++ b/tools/build-env/src/internal/utils/logging.ts @@ -0,0 +1,8 @@ +import { bold, gray, red } from 'ansis'; + +export function info(message: string, token: string) { + console.info(`${gray('>')} ${gray(bold(token))} ${message}`); +} +export function error(message: string, token: string) { + console.error(`${red('>')} ${red(bold(token))} ${message}`); +} diff --git a/tools/build-env/src/internal/utils/npm.ts b/tools/build-env/src/internal/utils/npm.ts new file mode 100644 index 00000000..064b0522 --- /dev/null +++ b/tools/build-env/src/internal/utils/npm.ts @@ -0,0 +1,28 @@ +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { ensureDirectoryExists } from './utils'; +import { error, info } from './logging'; + +export function logInfo(msg: string) { + info(msg, 'Npm Env: '); +} + +export function logError(msg: string) { + error(msg, 'Npm Env: '); +} + +export async function setupNpmWorkspace(directory: string, verbose?: boolean) { + if (verbose) { + logInfo(`Execute: npm init in directory ${directory}`); + } + const cwd = process.cwd(); + await ensureDirectoryExists(directory); + process.chdir(join(cwd, directory)); + try { + execFileSync('npm', ['init', '--force']).toString(); + } catch (error) { + logError(`Error creating NPM workspace: ${(error as Error).message}`); + } finally { + process.chdir(cwd); + } +} diff --git a/tools/build-env/src/internal/utils/process.ts b/tools/build-env/src/internal/utils/process.ts new file mode 100644 index 00000000..21e90d18 --- /dev/null +++ b/tools/build-env/src/internal/utils/process.ts @@ -0,0 +1,27 @@ +import {logger, readJsonFile} from "@nx/devkit"; +import {rm} from "node:fs/promises"; + +export async function killProcessFromPid(filePath: string, cleanFs = true): Promise { + let pid: string | number | undefined; + try { + const json = readJsonFile<{ pid?: string | number }>(filePath); + pid = json.pid; + } catch (error) { + throw new Error(`Could not load ${filePath} to get pid`); + } + + if (pid === undefined) { + throw new Error(`no pid found in file ${filePath}`); + } + + try { + process.kill(Number(pid)); + } catch (e) { + logger.error(`Failed killing process with id: ${pid}\n${e}`); + } finally { + if(cleanFs) { + await rm(filePath); + } + } + +} diff --git a/tools/build-env/src/internal/utils/terminal-command.ts b/tools/build-env/src/internal/utils/terminal-command.ts new file mode 100644 index 00000000..f5290579 --- /dev/null +++ b/tools/build-env/src/internal/utils/terminal-command.ts @@ -0,0 +1,54 @@ +type ArgumentValue = number | string | boolean | string[]; +export type CliArgsObject> = + T extends never + ? + Record | { _: string } + : T; + +export function objectToCliArgs< + T extends object = Record, +>(params?: CliArgsObject): string[] { + if (!params) { + return []; + } + + return Object.entries(params).flatMap(([key, value]) => { + // process/file/script + if (key === '_') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Array.isArray(value) ? value : [`${value}`]; + } + + const prefix = key.length === 1 ? '-' : '--'; + // "-*" arguments (shorthands) + if (Array.isArray(value)) { + return value.map(v => `${prefix}${key}="${v}"`); + } + + if (typeof value === 'object') { + return Object.entries(value as Record).flatMap( + // transform nested objects to the dot notation `key.subkey` + ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), + ); + } + + if (typeof value === 'string') { + return [`${prefix}${key}="${value}"`]; + } + + if (typeof value === 'number') { + return [`${prefix}${key}=${value}`]; + } + + if (typeof value === 'boolean') { + return [`${prefix}${value ? '' : 'no-'}${key}`]; + } + + // empty property gets removed + if (value === undefined) { + return []; + } + + throw new Error(`Unsupported type ${typeof value} for key ${key}`); + }); +} diff --git a/tools/build-env/src/internal/utils/utils.ts b/tools/build-env/src/internal/utils/utils.ts new file mode 100644 index 00000000..63f3175b --- /dev/null +++ b/tools/build-env/src/internal/utils/utils.ts @@ -0,0 +1,13 @@ +import { mkdir } from 'node:fs/promises'; + +export async function ensureDirectoryExists(baseDir: string) { + try { + await mkdir(baseDir, { recursive: true }); + return; + } catch (error) { + console.error((error as { code: string; message: string }).message); + if ((error as { code: string }).code !== 'EEXIST') { + throw error; + } + } +} diff --git a/tools/build-env/src/internal/verdaccio/verdaccio-npm-env.ts b/tools/build-env/src/internal/verdaccio/verdaccio-npm-env.ts new file mode 100644 index 00000000..6c0320ad --- /dev/null +++ b/tools/build-env/src/internal/verdaccio/verdaccio-npm-env.ts @@ -0,0 +1,138 @@ +import { join } from 'node:path'; +import { + startVerdaccioServer, + StarVerdaccioOptions, + VercaddioServerResult, + VerdaccioProcessResult, +} from './verdaccio-registry'; +import { rm, writeFile } from 'node:fs/promises'; +import { setupNpmWorkspace } from '../utils/npm'; +import { error, info } from '../utils/logging'; +import { objectToCliArgs } from '../utils/terminal-command'; +import { execSync } from 'node:child_process'; + +function logInfo(msg: string) { + info(msg, 'Verdaccio Env: '); +} + +function errorLog(msg: string) { + error(msg, 'Verdaccio Env: '); +} + +export const verdaccioEnvLogger = { + info: logInfo, + error: errorLog, +}; + +export type VerdaccioEnv = { + workspaceRoot: string; +}; + +export type StartVerdaccioAndSetupEnvOptions = Partial< + StarVerdaccioOptions & VerdaccioEnv +> & + Required>; + +export type NpmTestEnvResult = VerdaccioEnv & { + registry: VercaddioServerResult; + stop: () => void; +}; + +export async function setupNpmEnv({ + verbose = false, + workspaceRoot, + ...opts +}: StarVerdaccioOptions & { + workspaceRoot: string; +}): Promise { + const storage = join(workspaceRoot, 'storage'); + + const registryResult = await startVerdaccioServer({ + storage, + verbose, + ...opts, + }); + + // set up NPM workspace environment + await setupNpmWorkspace(workspaceRoot, verbose); + const userconfig = join(workspaceRoot, '.npmrc'); + configureRegistry({ ...registryResult.registry, userconfig }, verbose); + + const activeRegistry: NpmTestEnvResult = { + ...registryResult, + workspaceRoot, + }; + + logInfo( + `Save active verdaccio registry data to file: ${activeRegistry.workspaceRoot}` + ); + await writeFile( + join(activeRegistry.workspaceRoot, 'verdaccio-registry.json'), + JSON.stringify(activeRegistry.registry, null, 2) + ); + logInfo(`Environment ready under: ${activeRegistry.workspaceRoot}`); + + return activeRegistry; +} + +export async function stopVerdaccioAndTeardownEnv(result: NpmTestEnvResult) { + const { stop, workspaceRoot } = result; + stop(); + await rm(workspaceRoot, { recursive: true, force: true }); +} + +export function configureRegistry( + { + port, + host, + url, + userconfig, + }: VerdaccioProcessResult & { userconfig?: string }, + verbose?: boolean +) { + const setRegistry = `npm config set registry="${url}" ${objectToCliArgs({ + userconfig, + }).join(' ')}`; + if (verbose) { + logInfo(`Set registry:\n${setRegistry}`); + } + execSync(setRegistry); + + /** + * Protocol-Agnostic Configuration: The use of // allows NPM to configure authentication for a registry without tying it to a specific protocol (http: or https:). + * This is particularly useful when the registry might be accessible via both HTTP and HTTPS. + * + * Example: //registry.npmjs.org/:_authToken=your-token + */ + const urlNoProtocol = `//${host}:${port}`; + const token = 'secretVerdaccioToken'; + const setAuthToken = `npm config set ${urlNoProtocol}/:_authToken "${token}" ${objectToCliArgs( + { userconfig } + ).join(' ')}`; + if (verbose) { + logInfo(`Set authToken:\n${setAuthToken}`); + } + execSync(setAuthToken); +} + +export function unconfigureRegistry( + { port, host, userconfig }: VerdaccioProcessResult & { userconfig?: string }, + verbose?: boolean +) { + const urlNoProtocol = `//${host}:${port}`; + const setAuthToken = `npm config delete ${urlNoProtocol}/:_authToken ${objectToCliArgs( + { userconfig } + ).join(' ')}`; + if (verbose) { + logInfo(`Delete authToken:\n${setAuthToken}`); + } + execSync(setAuthToken); + + const setRegistry = `npm config delete registry ${objectToCliArgs({ + userconfig, + }).join(' ')}`; + if (verbose) { + logInfo(`Delete registry:\n${setRegistry}`); + } + execSync(setRegistry); +} diff --git a/tools/build-env/src/internal/verdaccio/verdaccio-registry.ts b/tools/build-env/src/internal/verdaccio/verdaccio-registry.ts new file mode 100644 index 00000000..3bdfc302 --- /dev/null +++ b/tools/build-env/src/internal/verdaccio/verdaccio-registry.ts @@ -0,0 +1,166 @@ +import { gray, bold, red } from 'ansis'; +import { join } from 'node:path'; +import { error, info } from '../utils/logging'; +import {logger} from "@nx/devkit"; +import {objectToCliArgs} from "../utils/terminal-command"; +import {executeProcess} from "../utils/execute-process"; + +export function logInfo(msg: string) { + info(msg, 'Verdaccio: '); +} + +export function logError(msg: string) { + error(msg, 'Verdaccio: '); +} + +export type VerdaccioProcessResult = { + protocol: string; + port: string | number; + host: string; + url: string; +}; +export type VercaddioServerResult = VerdaccioProcessResult & { + pid: number; +} & Required>; + +export type RegistryResult = { + registry: VercaddioServerResult; + stop: () => void; +}; + +export function parseRegistryData(stdout: string): VerdaccioProcessResult { + const output = stdout.toString(); + + // Extract protocol, host, and port + const match = output.match( + /(?https?):\/\/(?[^:]+):(?\d+)/ + ); + + if (!match?.groups) { + throw new Error('Could not parse registry data from stdout'); + } + + const protocol = match.groups['proto']; + if (!protocol || !['http', 'https'].includes(protocol)) { + throw new Error( + `Invalid protocol ${protocol}. Only http and https are allowed.` + ); + } + const host = match.groups['host']; + if (!host) { + throw new Error(`Invalid host ${String(host)}.`); + } + const port = !Number.isNaN(Number(match.groups['port'])) + ? Number(match.groups['port']) + : undefined; + if (!port) { + throw new Error(`Invalid port ${String(port)}.`); + } + return { + protocol, + host, + port, + url: `${protocol}://${host}:${port}`, + }; +} + +export type StarVerdaccioOnlyOptions = { + targetName?: string; + projectName?: string; + verbose?: boolean; +}; + +export type VerdaccioExecuterOptions = { + storage?: string; + port?: string; + p?: string; + config?: string; + c?: string; + location?: string; + clear?: boolean; +}; + +export type StarVerdaccioOptions = VerdaccioExecuterOptions & + StarVerdaccioOnlyOptions; + +export async function startVerdaccioServer({ + targetName = 'start-verdaccio', + projectName, + storage = join('tmp', targetName, 'storage'), + port, + location = 'none', + clear = true, + verbose = true, +}: StarVerdaccioOptions): Promise { + let startDetected = false; + + return new Promise((resolve, reject) => { + executeProcess({ + command: 'nx', + args: objectToCliArgs({ + _: [targetName, projectName ?? '', '--'], + storage, + port, + verbose, + location, + clear, + }), + shell: true, + observer: { + onStdout: (stdout: string, childProcess) => { + if (verbose) { + process.stdout.write( + `${gray('>')} ${gray(bold('Verdaccio'))} ${stdout}` + ); + } + + // Log of interest: warn --- http address - http://localhost:/ - verdaccio/5.31.1 + if (!startDetected && stdout.includes('http://localhost:')) { + startDetected = true; + + const result: RegistryResult = { + registry: { + pid: Number(childProcess?.pid), + storage, + ...parseRegistryData(stdout), + }, + stop: () => { + try { + childProcess?.kill(); + } catch { + logError( + `Can't kill Verdaccio process with id: ${childProcess?.pid}` + ); + } + }, + }; + + logInfo( + `Registry started on URL: ${bold( + result.registry.url + )}, ProcessID: ${bold(String(childProcess?.pid))}` + ); + if (verbose) { + logInfo(''); + console.table(result); + } + + resolve(result); + } + }, + onStderr: (stderr: string) => { + if (verbose) { + process.stdout.write( + `${red('>')} ${red(bold('Verdaccio'))} ${stderr}` + ); + } + }, + }, + }).catch((error) => { + reject(error); + }); + }).catch((error: unknown) => { + logger.error(error); + throw error; + }); +} diff --git a/tools/build-env/src/lib/index.ts b/tools/build-env/src/lib/index.ts deleted file mode 100644 index 6399e7a1..00000000 --- a/tools/build-env/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const t = 42; diff --git a/tools/build-env/src/plugin/verdaccio-env.plugin.ts b/tools/build-env/src/plugin/verdaccio-env.plugin.ts new file mode 100644 index 00000000..4e86b05e --- /dev/null +++ b/tools/build-env/src/plugin/verdaccio-env.plugin.ts @@ -0,0 +1,192 @@ +import { + type CreateNodes, + readJsonFile, + TargetConfiguration, + ProjectConfiguration +} from '@nx/devkit'; +import {dirname, join, relative} from 'node:path'; + +const tmpNpmEnv = join('tmp', 'npm-env'); + +export const createNodes: CreateNodes = [ + '**/project.json', + (projectConfigurationFile: string) => { + console.log('projectConfigurationFile', projectConfigurationFile); + const root = dirname(projectConfigurationFile); + const projectConfiguration: ProjectConfiguration = readJsonFile( + join(process.cwd(), projectConfigurationFile) + ); + + if ( + !('name' in projectConfiguration) || + typeof projectConfiguration.name !== 'string' + ) { + throw new Error('Project name is required'); + } + const {name: envProjectName} = + readJsonFile('project.json'); + const name = projectConfiguration?.name ?? ''; + const tags = projectConfiguration?.tags ?? []; + const isPublishable = tags.some((target) => target === 'publishable'); + const isNpmEnv = tags.some((target) => target === 'npm-env'); + + return { + projects: { + [root]: {} + } + }; + + return { + projects: { + [root]: { + targets: { + // === e2e project + // start-verdaccio, stop-verdaccio + ...(isNpmEnv && + verdaccioTargets({...projectConfiguration, name})), + // setup-npm-env, setup-env, setup-deps + //...(isNpmEnv && envTargets(projectConfiguration)), + // === dependency project + // npm-publish, npm-install + //...(isPublishable && npmTargets({ ...projectConfiguration, root }, envProjectName)), + }, + }, + }, + }; + }, +]; + +function verdaccioTargets( + projectConfiguration: Omit & { name: string } +): Record { + const {name: projectName} = projectConfiguration; + return { + 'start-verdaccio': { + executor: '@nx/js:verdaccio', + options: { + config: '.verdaccio/config.yml', + storage: join(tmpNpmEnv, projectName, 'storage'), + clear: true, + }, + }, + 'stop-verdaccio': { + executor: '@org/build-env:kill-process', + options: { + filePath: join(tmpNpmEnv, projectName), + }, + }, + }; +} + +function envTargets( + projectConfiguration: ProjectConfiguration +): Record { + const {name: projectName} = projectConfiguration; + return { + 'setup-npm-env': { + command: + 'tsx --tsconfig=tools/tsconfig.tools.json tools/tools-utils/src/bin/setup-npm-env.ts', + options: { + projectName, + targetName: 'start-verdaccio', + envProjectName: join(tmpNpmEnv, projectName), + readyWhen: 'Environment ready under', + }, + }, + 'setup-env': { + inputs: ['default', '^production'], + executor: 'nx:run-commands', + options: { + commands: [ + `nx setup-npm-env ${projectName} --workspaceRoot={args.envProjectName}`, + `nx install-deps ${projectName} --envProjectName={args.envProjectName}`, + `nx stop-verdaccio ${projectName} --workspaceRoot={args.workspaceRoot}`, + ], + workspaceRoot: join(tmpNpmEnv, projectName), + forwardAllArgs: true, + // @TODO rename to more intuitive name + envProjectName: projectName, + parallel: false, + }, + }, + 'install-deps': { + dependsOn: [ + { + projects: 'dependencies', + target: 'npm-install', + params: 'forward', + }, + ], + options: { + envProjectName: projectName, + }, + command: 'echo Dependencies installed!', + }, + }; +} + +const relativeFromPath = (dir: string) => + relative(join(process.cwd(), dir), join(process.cwd())); + +function npmTargets( + projectConfiguration: ProjectConfiguration, + envProjectName: string +): Record { + const {root, name: projectName, targets} = projectConfiguration; + const {build} = + (targets as Record<'build', TargetConfiguration<{ outputPath: string }>>) ?? + {}; + const {options} = build ?? {}; + const {outputPath} = options ?? {}; + if (outputPath == null) { + throw new Error('outputPath is required'); + } + + const {name: packageName, version: pkgVersion} = readJsonFile( + join(root, 'package.json') + ); + const userconfig = `${relativeFromPath( + outputPath + )}/${tmpNpmEnv}/{args.envProjectName}/.npmrc`; + const prefix = `${tmpNpmEnv}/{args.envProjectName}`; + + return { + 'npm-publish': { + dependsOn: [ + {projects: 'self', target: 'build', params: 'forward'}, + { + projects: 'dependencies', + target: 'npm-publish', + params: 'forward', + }, + ], + // dist/projects/models + inputs: [{dependentTasksOutputFiles: `**/{options.outputPath}/**`}], + outputs: [ + // + `{workspaceRoot}/${tmpNpmEnv}/{args.envProjectName}/storage/@org/${packageName}`, + ], + cache: true, + command: `npm publish --userconfig=${userconfig}`, + options: { + cwd: outputPath, + envProjectName, + }, + }, + 'npm-install': { + dependsOn: [ + {projects: 'self', target: 'npm-publish', params: 'forward'}, + { + projects: 'dependencies', + target: 'npm-install', + params: 'forward', + }, + ], + command: `npm install --no-fund --no-shrinkwrap --save ${packageName}@{args.pkgVersion} --prefix=${prefix} --userconfig=${userconfig}`, + options: { + pkgVersion, + envProjectName, + }, + }, + }; +}