nrwl / nx

Smart Monorepos · Fast CI
https://nx.dev
MIT License
22.75k stars 2.27k forks source link

nx local-registry with verdaccio should also overwrite scoped if specified #26815

Open DannyvanHolten opened 2 weeks ago

DannyvanHolten commented 2 weeks ago

Current Behavior

I currently have a .npmrc file with a private repository pointing to gitlab for internal packages.

@private:registry=https://gitlab.com/api/v4/projects/1234/packages/npm/
//gitlab.com/api/v4/projects/1234/packages/npm/:_authToken=${PACKAGE_REGISTRY_TOKEN}

This is because we want to distribute the packages with @private scope only to be published to our private registry.

However, we do want to be able to also test these scoped packages with Verdaccio locally.

The current implementation for @nx/js:verdaccio has no option for scopes. as seen in the implementation:

execSync(
      `npm config set registry http://localhost:${options.port}/ --location ${options.location}`,
      { env }
    );

Expected Behavior

I would like to be able to provide scopes into the project.json configuration. like so:

{
  "location": "global",
  "storage": "<string>",
  "port": 54,
  "config": "<string>",
  "clear": true,
  "scopes: ["private", "extrascope"]
}

I believe if we create a loop in the implementation and add the scoped registries found in the array we can also overwrite these scopes when Verdaccio is running.

P.S: I am willing to give it a try for my first contribution. Just making sure this is actually a direction Nx wants to go.

GitHub Repo

No response

Steps to Reproduce

  1. add a scoped registry in your .npmrc file.
  2. run nx local-registry
  3. run nx release publish for a scoped package

Nx Report

NX   Report complete - copy this into the issue template

Node   : 20.12.1
OS     : linux-x64
npm    : 10.5.0

nx (global)    : 18.2.3
nx             : 18.2.3
@nx/js         : 18.2.3
@nx/jest       : 18.2.3
@nx/linter     : 18.2.3
@nx/eslint     : 18.2.3
@nx/workspace  : 18.2.3
@nx/devkit     : 18.2.3
@nx/node       : 18.2.3
@nx/rollup     : 18.2.3
@nrwl/tao      : 18.2.3
@nx/vite       : 18.2.3
@nx/web        : 18.2.3
typescript     : 5.4.4
---------------------------------------
Registered Plugins:
@nx/eslint/plugin
@nx/vite/plugin

Failure Logs

No response

Package Manager Version

npm 10.5.0

Operating System

Additional Information

No response

DannyvanHolten commented 2 weeks ago

I created a draft that I temporarily use locally as a "fork". I did not fix Yarn V2 support for this yet. And I'm not sure if this meets the required coding standards Nx demands. But if this is the correct direction I will take more effort to fix this for Yarn V2 (I might need some help) and do all the required steps:

import { ExecutorContext, logger } from '@nx/devkit';
import { existsSync, rmSync } from 'fs-extra';
import { ChildProcess, execSync, fork } from 'child_process';
import * as detectPort from 'detect-port';
import { join, resolve } from 'path';

import { VerdaccioExecutorSchema } from './schema';
import { major } from 'semver';

let childProcess: ChildProcess;

let env: NodeJS.ProcessEnv = {
  SKIP_YARN_COREPACK_CHECK: 'true',
  ...process.env,
};

/**
 * - set npm and yarn to use local registry
 * - start verdaccio
 * - stop local registry when done
 */
export async function verdaccioExecutor(
  options: VerdaccioExecutorSchema,
  context: ExecutorContext
) {
  try {
    require.resolve('verdaccio');
  } catch (e) {
    throw new Error(
      'Verdaccio is not installed. Please run `npm install verdaccio` or `yarn add verdaccio`'
    );
  }

  if (options.storage) {
    options.storage = resolve(context.root, options.storage);
    if (options.clear && existsSync(options.storage)) {
      rmSync(options.storage, { recursive: true, force: true });
      console.log(`Cleared local registry storage folder ${options.storage}`);
    }
  }

  const port = await detectPort(options.port);
  if (port !== options.port) {
    logger.info(`Port ${options.port} was occupied. Using port ${port}.`);
    options.port = port;
  }

  const cleanupFunctions =
    options.location === 'none' ? [] : [setupNpm(options), setupYarn(options)];

  const processExitListener = (signal?: number | NodeJS.Signals) => {
    if (childProcess) {
      childProcess.kill(signal);
    }
    for (const fn of cleanupFunctions) {
      fn();
    }
  };
  process.on('exit', processExitListener);
  process.on('SIGTERM', processExitListener);
  process.on('SIGINT', processExitListener);
  process.on('SIGHUP', processExitListener);

  try {
    await startVerdaccio(options, context.root);
  } catch (e) {
    logger.error('Failed to start verdaccio: ' + e?.toString());
    return {
      success: false,
      port: options.port,
    };
  }
  return {
    success: true,
    port: options.port,
  };
}

/**
 * Fork the verdaccio process: https://verdaccio.org/docs/verdaccio-programmatically/#using-fork-from-child_process-module
 */
function startVerdaccio(
  options: VerdaccioExecutorSchema,
  workspaceRoot: string
) {
  return new Promise((resolve, reject) => {
    childProcess = fork(
      require.resolve('verdaccio/bin/verdaccio'),
      createVerdaccioOptions(options, workspaceRoot),
      {
        env: {
          ...process.env,
          VERDACCIO_HANDLE_KILL_SIGNALS: 'true',
          ...(options.storage
            ? { VERDACCIO_STORAGE_PATH: options.storage }
            : {}),
        },
        stdio: 'inherit',
      }
    );

    childProcess.on('error', (err) => {
      reject(err);
    });
    childProcess.on('disconnect', (err) => {
      reject(err);
    });
    childProcess.on('exit', (code) => {
      if (code === 0) {
        resolve(code);
      } else {
        reject(code);
      }
    });
  });
}

function createVerdaccioOptions(
  options: VerdaccioExecutorSchema,
  workspaceRoot: string
) {
  const verdaccioArgs: string[] = [];
  if (options.config) {
    verdaccioArgs.push('--config', join(workspaceRoot, options.config));
  } else {
    options.port ??= 4873; // set default port if config is not provided
  }
  if (options.port) {
    verdaccioArgs.push('--listen', options.port.toString());
  }
  return verdaccioArgs;
}

function setupNpm(options: VerdaccioExecutorSchema) {
  try {
    execSync('npm --version', { env });
  } catch (e) {
    return () => {};
  }

  let npmRegistryPaths: string[] = [];
  const scopes: string[] = ['default', ...(options.scopes || [])];

  try {
    scopes.forEach((scope) => {
      const scopeName = scope !== 'default' ? `${scope}:` : '';
      npmRegistryPaths = [
        ...npmRegistryPaths,
        execSync(
        `npm config get '${scopeName}registry' --location ${options.location}`,
        {env}
      )
        ?.toString()
        ?.trim()
        ?.replace('\u001b[2K\u001b[1G', '') // strip out ansi codes
      ];
      execSync(
        `npm config set '${scopeName}registry' http://localhost:${options.port}/ --location ${options.location}`,
        {env}
      );

      logger.info(`Set npm ${scopeName}registry to http://localhost:${options.port}/`);
    });

    execSync(
      `npm config set //localhost:${options.port}/:_authToken="secretVerdaccioToken" --location ${options.location}`,
      {env}
    );
  } catch (e) {
    throw new Error(
      `Failed to set npm registry to http://localhost:${options.port}/: ${e.message}`
    );
  }

  return () => {
    try {
      const currentNpmRegistryPath = execSync(
        `npm config get registry --location ${options.location}`,
        { env }
      )
        ?.toString()
        ?.trim()
        ?.replace('\u001b[2K\u001b[1G', ''); // strip out ansi codes
      if (npmRegistryPaths.length > 0 && currentNpmRegistryPath.includes('localhost')) {
        scopes.forEach((scope, index) => {
          const scopeName = scope !== 'default' ? `${scope}:` : '';

          execSync(
            `npm config set '${scopeName}registry' ${npmRegistryPaths[index]} --location ${options.location}`,
            {env}
          );
          logger.info(`Reset npm ${scopeName}registry to ${npmRegistryPaths[index]}`);
        });
      } else {
        execSync(`npm config delete registry --location ${options.location}`, {
          env,
        });
        logger.info('Cleared custom npm registry');
      }
      execSync(
        `npm config delete //localhost:${options.port}/:_authToken  --location ${options.location}`,
        { env }
      );
    } catch (e) {
      throw new Error(`Failed to reset npm registry: ${e.message}`);
    }
  };
}

function getYarnUnsafeHttpWhitelist(isYarnV1: boolean) {
  return !isYarnV1
    ? new Set<string>(
      JSON.parse(
        execSync(`yarn config get unsafeHttpWhitelist --json`, {
          env,
        }).toString()
      )
    )
    : null;
}

function setYarnUnsafeHttpWhitelist(
  currentWhitelist: Set<string>,
  options: VerdaccioExecutorSchema
) {
  if (currentWhitelist.size > 0) {
    execSync(
      `yarn config set unsafeHttpWhitelist --json '${JSON.stringify(
        Array.from(currentWhitelist)
      )}'` + (options.location === 'user' ? ' --home' : ''),
      { env }
    );
  } else {
    execSync(
      `yarn config unset unsafeHttpWhitelist` +
      (options.location === 'user' ? ' --home' : ''),
      { env }
    );
  }
}

function setupYarn(options: VerdaccioExecutorSchema) {
  let isYarnV1;
  let yarnRegistryPaths: string[] = [];
  const scopes: string[] = ['default', ...(options.scopes || [])];

  try {
    isYarnV1 =
      major(execSync('yarn --version', { env }).toString().trim()) === 1;
  } catch {
    // This would fail if yarn is not installed which is okay
    return () => {};
  }
  try {
    const registryConfigName = isYarnV1 ? 'registry' : 'npmRegistryServer';

    scopes.forEach((scope, index) => {
      const scopeName = scope !== 'default' ? `${scope}:` : '';

      yarnRegistryPaths = [
        ...yarnRegistryPaths,
        execSync(`yarn config get ${scopeName}${registryConfigName}`, {
        env,
      })
        ?.toString()
        ?.trim()
        ?.replace('\u001b[2K\u001b[1G', '') // strip out ansi codes
      ];

      execSync(
        `yarn config set ${scopeName}${registryConfigName} http://localhost:${options.port}/` +
        (options.location === 'user' ? ' --home' : ''),
        {env}
      );

      logger.info(`Set yarn ${scopeName}registry to http://localhost:${options.port}/`);
    });

    const currentWhitelist: Set<string> | null =
      getYarnUnsafeHttpWhitelist(isYarnV1);

    let whitelistedLocalhost = false;

    if (!isYarnV1 && !currentWhitelist.has('localhost')) {
      whitelistedLocalhost = true;
      currentWhitelist.add('localhost');

      setYarnUnsafeHttpWhitelist(currentWhitelist, options);
      logger.info(
        `Whitelisted http://localhost:${options.port}/ as an unsafe http server`
      );
    }

    return () => {
      try {
        const currentYarnRegistryPath = execSync(
          `yarn config get ${registryConfigName}`,
          { env }
        )
          ?.toString()
          ?.trim()
          ?.replace('\u001b[2K\u001b[1G', ''); // strip out ansi codes
        if (yarnRegistryPaths.length > 0 && currentYarnRegistryPath.includes('localhost')) {
          scopes.forEach((scope, index) => {
            const scopeName = scope !== 'default' ? `${scope}:` : '';

            execSync(
              `yarn config set ${scopeName}${registryConfigName} ${yarnRegistryPaths[index]}` +
              (options.location === 'user' ? ' --home' : ''),
              {env}
            );
            logger.info(
              `Reset yarn ${scopeName}${registryConfigName} to ${yarnRegistryPaths[index]}`
            );
          });
        } else {
          execSync(
            `yarn config ${
              isYarnV1 ? 'delete' : 'unset'
            } ${registryConfigName}` +
            (options.location === 'user' ? ' --home' : ''),
            { env }
          );
          logger.info(`Cleared custom yarn ${registryConfigName}`);
        }

        if (whitelistedLocalhost) {
          const currentWhitelist: Set<string> =
            getYarnUnsafeHttpWhitelist(isYarnV1);

          if (currentWhitelist.has('localhost')) {
            currentWhitelist.delete('localhost');

            setYarnUnsafeHttpWhitelist(currentWhitelist, options);
            logger.info(
              `Removed http://localhost:${options.port}/ as an unsafe http server`
            );
          }
        }
      } catch (e) {
        throw new Error(`Failed to reset yarn registry: ${e.message}`);
      }
    };
  } catch (e) {
    throw new Error(
      `Failed to set yarn registry to http://localhost:${options.port}/: ${e.message}`
    );
  }
}

export default verdaccioExecutor;
AgentEnder commented 2 weeks ago

This seems feasible / fine. We'd definitely appreciate the contribution. I'm going to assign you as well as @xiongemi on our side, she can help guide you if questions come up.

DannyvanHolten commented 2 weeks ago

Good to hear. I will probably have time for this upcoming Wednesday/Thursday and I will make an MR. I'll probably need some time to get this repository up and running, but that will be fine. I'll contact xiongemi when I made an MR :)

xiongemi commented 1 week ago

@DannyvanHolten @AgentEnder actually i created a pr for add the scopes options https://github.com/nrwl/nx/pull/26918