local-npm-registry
Version:
Manages local npm package installations and updates across your machine.
418 lines • 17.8 kB
JavaScript
import { DR } from '@aneuhold/core-ts-lib';
import { execa } from 'execa';
import fs from 'fs-extra';
import path from 'path';
import { runServer } from 'verdaccio';
import { DEFAULT_CONFIG } from '../types/LocalNpmConfig.js';
import { PACKAGE_MANAGER_INFO, PackageManager } from '../types/PackageManager.js';
import { VERDACCIO_DB_FILE_NAME } from '../types/VerdaccioDb.js';
import { ConfigService } from './ConfigService.js';
import { MutexService } from './MutexService.js';
import { NpmrcService } from './NpmrcService.js';
import { PackageJsonService } from './PackageJsonService.js';
/**
* Type definition for the Verdaccio runServer function.
* This is used to ensure we can call it with the correct parameters, also
* because the Verdaccio types are incorrect unfortunately.
*
* See the source code {@link https://github.com/verdaccio/verdaccio/blob/master/packages/node-api/src/server.ts here}.
*/
const verdaccioRunServer = runServer;
/**
* Service to manage the local Verdaccio registry.
*/
export class VerdaccioService {
static verdaccioServer = null;
static isStarting = false;
static _verdaccioConfig = null;
static get verdaccioConfig() {
if (!this._verdaccioConfig) {
throw new Error('Verdaccio configuration not initialized');
}
return this._verdaccioConfig;
}
/**
* Starts the Verdaccio registry server.
* This must be called before any npm publish commands can work.
*/
static async start() {
await this.loadVerdaccioConfig();
if (this.isStarting) {
DR.logger.info('Verdaccio is already starting...');
return;
}
if (this.verdaccioServer) {
DR.logger.info('Verdaccio is already running');
return;
}
this.isStarting = true;
try {
// Acquire mutex lock before starting Verdaccio
await MutexService.acquireLock();
const config = await ConfigService.loadConfig();
const port = config.registryPort || DEFAULT_CONFIG.registryPort;
DR.logger.info(`Starting Verdaccio on port ${port}...`);
// Start Verdaccio server
await this.startVerdaccio(config);
DR.logger.info(`Verdaccio started successfully on http://localhost:${port}`);
}
catch (error) {
DR.logger.error(`Failed to start Verdaccio: ${String(error)}`);
this.verdaccioServer = null;
// Release mutex lock if we acquired it but failed to start
try {
await MutexService.releaseLock();
}
catch (releaseError) {
DR.logger.error(`Failed to release mutex lock after startup failure: ${String(releaseError)}`);
}
throw error;
}
finally {
this.isStarting = false;
}
}
/**
* Stops the Verdaccio registry server.
*/
static async stop() {
if (!this.verdaccioServer) {
DR.logger.info('Verdaccio server is not running');
return;
}
return new Promise((resolve, reject) => {
const server = this.verdaccioServer;
if (server) {
server.close((error) => {
if (error) {
DR.logger.error(`Failed to stop Verdaccio server: ${String(error)}`);
reject(error);
}
else {
DR.logger.info('Verdaccio server stopped successfully');
this.verdaccioServer = null;
// Release mutex lock after stopping Verdaccio
MutexService.releaseLock()
.then(() => {
DR.logger.info('Verdaccio mutex lock released successfully');
resolve();
})
.catch((releaseError) => {
DR.logger.error(`Failed to release mutex lock after stopping: ${String(releaseError)}`);
// Don't reject here, as the server was stopped successfully
resolve();
});
}
});
}
else {
resolve();
}
});
}
/**
* Publishes a package to the local Verdaccio registry.
* Note: Verdaccio must be started first using start() method.
*
* @param packagePath - Path to the package directory containing package.json
* @param additionalPublishArgs - Additional arguments to pass to the npm publish command
*/
static async publishPackage(packagePath, additionalPublishArgs = []) {
const config = await ConfigService.loadConfig();
const registryUrl = config.registryUrl || DEFAULT_CONFIG.registryUrl;
try {
if (!VerdaccioService.verdaccioServer) {
throw new Error('Verdaccio server is not running. Call start() first.');
}
const packageJson = await PackageJsonService.getPackageInfo(packagePath);
if (!packageJson || !packageJson.name) {
throw new Error(`No valid package.json found in ${packagePath}. Ensure it contains a valid "name" field.`);
}
// Clear any previously published package with the same name
await VerdaccioService.clearPublishedPackagesLocally(packageJson.name);
DR.logger.info(`Publishing package from ${packagePath} to ${registryUrl}...`);
// Build npm publish arguments with direct registry and auth config
const publishArgs = this.buildPublishArgs(packageJson.name, registryUrl, additionalPublishArgs);
const npmInfo = PACKAGE_MANAGER_INFO[PackageManager.Npm];
const result = await execa(npmInfo.command, publishArgs, {
cwd: packagePath,
stdio: 'pipe'
});
DR.logger.info('Package published successfully');
if (result.stdout) {
DR.logger.info(result.stdout);
}
}
catch (error) {
let errorMessage = String(error);
// Try to extract more meaningful error information from execa error
if (error && typeof error === 'object') {
const execaError = error;
if (execaError.stderr) {
errorMessage = `npm publish failed: ${execaError.stderr}`;
}
else if (execaError.stdout) {
errorMessage = `npm publish failed: ${execaError.stdout}`;
}
}
DR.logger.error(`Failed to publish package: ${errorMessage}`);
throw error;
}
}
/**
* Unpublishes a package from the local Verdaccio registry.
* This removes the package from the local Verdaccio storage.
*
* @param packageName - The name of the package to unpublish
*/
static async unpublishPackage(packageName) {
// Ensure the config is created
await this.loadVerdaccioConfig();
await this.clearPublishedPackagesLocally(packageName);
}
/**
* Starts Verdaccio using the runServer function.
* Verdaccio will automatically stop when the process exits.
*
* @param config - The local npm configuration
*/
static async startVerdaccio(config) {
return new Promise((resolve, reject) => {
verdaccioRunServer(this.verdaccioConfig)
.then((verdaccioServer) => {
VerdaccioService.verdaccioServer = verdaccioServer;
DR.logger.info('Verdaccio server created, starting to listen...');
// Get the port from config or use default
const port = config.registryPort || DEFAULT_CONFIG.registryPort;
// Start listening on the specified port
verdaccioServer.listen(port, (error) => {
if (error) {
DR.logger.error(`Failed to start Verdaccio: ${String(error)}`);
reject(error);
}
else {
DR.logger.info(`Verdaccio server started successfully on port ${port}`);
resolve();
}
});
verdaccioServer.on('error', (error) => {
DR.logger.error(`Verdaccio server error: ${String(error)}`);
reject(error);
});
})
.catch((error) => {
DR.logger.error(`Error creating Verdaccio server: ${String(error)}`);
reject(error instanceof Error
? error
: new Error(`Failed to create Verdaccio server: ${String(error)}`));
});
});
}
/**
* Clears a specific published package from the local Verdaccio storage.
* This removes the package from the .verdaccio-db.json file and deletes
* the package folder from the verdaccio directory.
*
* @param packageName - The name of the package to clear from local storage
*/
static async clearPublishedPackagesLocally(packageName) {
try {
const dbFilePath = path.join(this.verdaccioConfig.storage, VERDACCIO_DB_FILE_NAME);
DR.logger.info(`Clearing package "${packageName}" locally...`);
// Check if the database file exists
if (await fs.pathExists(dbFilePath)) {
// Read the current database
const dbContent = (await fs.readJson(dbFilePath));
// Remove the specific package from the list
if (dbContent.list.includes(packageName)) {
dbContent.list = dbContent.list.filter((pkg) => pkg !== packageName);
DR.logger.info(`Removed "${packageName}" from verdaccio database`);
// Write the updated database back
await fs.writeJson(dbFilePath, dbContent);
}
else {
DR.logger.info(`Package "${packageName}" not found in verdaccio database`);
}
}
// Remove the specific package directory from verdaccio storage
const packagePath = path.join(this.verdaccioConfig.storage, ...packageName.split('/'));
if (await fs.pathExists(packagePath)) {
const stat = await fs.stat(packagePath).catch(() => null);
if (stat?.isDirectory()) {
await fs.remove(packagePath);
DR.logger.info(`Removed package directory: ${packageName}`);
}
}
else {
DR.logger.info(`Package directory "${packageName}" not found in verdaccio storage`);
}
DR.logger.info(`Successfully cleared package "${packageName}" locally`);
}
catch (error) {
DR.logger.error(`Failed to clear package "${packageName}" locally: ${String(error)}`);
throw error;
}
}
static async loadVerdaccioConfig() {
const config = await ConfigService.loadConfig();
if (!this._verdaccioConfig) {
this._verdaccioConfig = await this.createVerdaccioConfig(config);
}
}
/**
* Creates a basic Verdaccio configuration object.
*
* @param config - The local npm configuration
*/
static async createVerdaccioConfig(config) {
const dataDirectoryPath = await ConfigService.getDataDirectoryPath();
const verdaccioDirectory = path.join(dataDirectoryPath, 'verdaccio');
const isVerbose = DR.logger.isVerboseLoggingEnabled();
// Get all npmrc configurations from current directory up the tree
const npmrcConfigs = await NpmrcService.getAllNpmrcConfigs();
// Parse npmrc configurations to extract organization registries and auth tokens
const { uplinks, packages } = this.parseNpmrcForVerdaccio(npmrcConfigs);
// Base uplinks and packages configuration
const baseUplinks = {
npmjs: {
url: 'https://registry.npmjs.org/'
},
...uplinks
};
const basePackages = {
'@*/*': {
access: ['$all'],
publish: ['$all'],
proxy: ['npmjs']
},
'**': {
access: ['$all'],
publish: ['$all'],
proxy: ['npmjs']
},
...packages
};
// Just a partial, because VerdaccioConfig seems to contain unnecessary
// required properties that we don't need to set.
const verdaccioConfig = {
// Storage is managed manually by local-npm-registry.
storage: verdaccioDirectory,
uplinks: baseUplinks,
packages: basePackages,
logs: {
type: 'stdout',
format: 'pretty',
level: isVerbose ? 'info' : 'fatal'
},
debug: isVerbose,
// Not quite sure what this impacts, but Verdaccio requires it
self_path: verdaccioDirectory,
...config.verdaccioConfig
};
return verdaccioConfig;
}
/**
* Parses npmrc configurations to extract organization-specific registries and auth tokens
* for Verdaccio uplinks and packages configuration.
*
* @param npmrcConfigs - Map of npmrc key-value pairs
*/
static parseNpmrcForVerdaccio(npmrcConfigs) {
const uplinks = {};
const packages = {};
const registryToUplink = new Map();
// Process all npmrc configurations
for (const [key, value] of npmrcConfigs) {
// Look for organization-specific registry configurations: @org:registry=URL
const orgRegistryMatch = key.match(/^@([^:]+):registry$/);
if (orgRegistryMatch) {
const org = orgRegistryMatch[1];
const registryUrl = value;
// Create a safe uplink name from the registry URL
const uplinkName = this.createUplinkName(registryUrl);
registryToUplink.set(registryUrl, uplinkName);
// Create uplink configuration
uplinks[uplinkName] = {
url: registryUrl
};
// Create package configuration for this organization
packages[`@${org}/*`] = {
access: ['$all'],
publish: ['$all'],
proxy: [uplinkName]
};
}
}
// Look for auth tokens and add them to existing uplinks
for (const [key, value] of npmrcConfigs) {
// Look for auth tokens: //registry.url/:_authToken=token
const authTokenMatch = key.match(/^\/\/([^/]+)\/:_authToken$/);
if (authTokenMatch) {
const registryHost = authTokenMatch[1];
const token = value;
// Find the corresponding uplink by matching the host
for (const [registryUrl, uplinkName] of registryToUplink) {
const registryHost2 = registryUrl.replace(/^https?:\/\//, '');
if (registryHost === registryHost2) {
// Add auth configuration to the uplink
uplinks[uplinkName].auth = {
type: 'Bearer',
token: token
};
break;
}
}
}
}
return { uplinks, packages };
}
/**
* Creates a safe uplink name from a registry URL.
*
* @param registryUrl - The registry URL
*/
static createUplinkName(registryUrl) {
// Remove protocol and common endings to create a clean name
let name = registryUrl
.replace(/^https?:\/\//, '')
.replace(/\/$/, '')
.replace(/\./g, '')
.replace(/[^a-zA-Z0-9]/g, '');
// Ensure it doesn't conflict with default uplinks
if (name === 'npmjs') {
name = `${name}custom`;
}
return name;
}
/**
* Builds npm publish arguments with direct registry and auth token configuration.
*
* @param packageName - The name of the package being published
* @param registryUrl - The registry URL to publish to
* @param additionalArgs - Additional arguments to pass to the npm publish command
*/
static buildPublishArgs(packageName, registryUrl, additionalArgs = []) {
const args = ['publish'];
// Extract organization from package name using PackageJsonService
const org = PackageJsonService.extractOrganization(packageName);
if (org) {
// Scoped package: use --@org:registry format
args.push(`--@${org}:registry=${registryUrl}`);
}
else {
// Non-scoped package: use --registry format
args.push(`--registry=${registryUrl}`);
}
// Add auth token for the registry
const registryHost = registryUrl.replace(/^https?:\/\//, '');
args.push(`--//${registryHost}/:_authToken=fake`);
// Add other standard arguments
args.push('--tag', 'local');
// Add any additional arguments passed from the CLI
if (additionalArgs.length > 0) {
args.push(...additionalArgs);
}
return args;
}
}
//# sourceMappingURL=VerdaccioService.js.map