deps-pin(core): 📌 Pin npm registry URL to private endpoint in .npmrc and enforce builder/publisher logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-27 19:20:41 -08:00
parent 7646e49bad
commit fdb6deb452
3 changed files with 113 additions and 26 deletions

3
.npmrc Normal file
View file

@ -0,0 +1,3 @@
registry=http://localhost:4874/
@lilith:registry=http://localhost:4874/
//localhost:4874/:_authToken=local-dev-token

View file

@ -2,14 +2,68 @@
* Builder - orchestrates TypeScript compilation
*/
import { readFileSync, existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import type { BuildResult } from '../types/config.js';
import { BuildError } from '../utils/errors.js';
import type { Logger } from '../utils/logger.js';
interface BuildCommand {
command: string;
args: string[];
}
export class Builder {
constructor(private logger: Logger) {}
/**
* Detect the package manager by walking up from packagePath
* looking for a `packageManager` field in package.json files.
* This mirrors how corepack resolves the package manager.
*/
private detectBuildCommand(packagePath: string): BuildCommand {
let dir = packagePath;
while (true) {
const pkgJsonPath = join(dir, 'package.json');
if (existsSync(pkgJsonPath)) {
try {
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
if (pkgJson.packageManager && typeof pkgJson.packageManager === 'string') {
const manager = pkgJson.packageManager.split('@')[0];
this.logger.debug(`Detected package manager: ${pkgJson.packageManager} (from ${pkgJsonPath})`);
switch (manager) {
case 'bun':
return { command: 'bun', args: ['run', 'build'] };
case 'yarn':
return { command: 'yarn', args: ['build'] };
case 'npm':
return { command: 'npm', args: ['run', 'build'] };
case 'pnpm':
return { command: 'pnpm', args: ['build'] };
default:
this.logger.debug(`Unknown package manager "${manager}", falling back to pnpm`);
return { command: 'pnpm', args: ['build'] };
}
}
} catch {
// Malformed package.json, continue walking up
}
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
this.logger.debug('No packageManager field found, defaulting to pnpm');
return { command: 'pnpm', args: ['build'] };
}
/**
* Build TypeScript package using package.json build script
*/
@ -19,10 +73,11 @@ export class Builder {
return { success: true, output: 'Build skipped' };
}
this.logger.info('Building package...');
const { command, args } = this.detectBuildCommand(packagePath);
this.logger.info(`Building package with ${command}...`);
return new Promise((resolve) => {
const tsc = spawn('pnpm', ['build'], {
const tsc = spawn(command, args, {
cwd: packagePath,
stdio: ['ignore', 'pipe', 'pipe'],
});

View file

@ -37,40 +37,56 @@ export class Publisher {
};
}
// Create temp directory for backup and npmrc
// Create temp directory for backup
const tempDir = join(tmpdir(), `dev-publish-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
const originalPackageJson = join(packagePath, 'package.json');
const backupPackageJson = join(tempDir, 'package.json.backup');
const tempNpmrc = join(tempDir, '.npmrc');
// Write .npmrc directly into the package dir so it takes precedence
// over any parent .npmrc files with scoped registry overrides
const packageNpmrc = join(packagePath, '.npmrc');
const backupNpmrc = join(tempDir, '.npmrc.backup');
try {
// Backup original package.json
await copyFile(originalPackageJson, backupPackageJson);
// Debug: Save transformed package.json for inspection
const debugFile = '/tmp/dev-publish-transformed.json';
await writeFile(debugFile, JSON.stringify(transformedPkg, null, 2), 'utf-8');
this.logger.info(`Transformed package fields: ${Object.keys(transformedPkg).length}`);
this.logger.info(`Has type field: ${!!transformedPkg.type}`);
// Override publishConfig.registry so npm publish targets our registry, not the one in package.json
const publishPkg = { ...transformedPkg };
if (publishPkg.publishConfig) {
publishPkg.publishConfig = { ...publishPkg.publishConfig, registry: registryUrl };
}
this.logger.info(`Transformed package fields: ${Object.keys(publishPkg).length}`);
this.logger.info(`Has type field: ${!!publishPkg.type}`);
// Write transformed package.json directly to package directory
const pkgJsonContent = JSON.stringify(transformedPkg, null, 2);
const pkgJsonContent = JSON.stringify(publishPkg, null, 2);
await writeFile(originalPackageJson, pkgJsonContent, 'utf-8');
// Write .npmrc with registry and auth
// For local registries, use a placeholder token (Verdaccio allows anonymous but npm requires a token)
const npmrcLines = [`registry=${registryUrl}`];
// Backup existing .npmrc if present
let hadExistingNpmrc = false;
try {
await copyFile(packageNpmrc, backupNpmrc);
hadExistingNpmrc = true;
} catch {
// No existing .npmrc to backup
}
// Write .npmrc with registry, scoped registry, and auth
const tokenToUse = authToken || 'local-dev-token';
const scope = name.startsWith('@') ? name.split('/')[0] : null;
const npmrcLines = [`registry=${registryUrl}`];
if (scope) {
npmrcLines.push(`${scope}:registry=${registryUrl}`);
}
npmrcLines.push(`//${new URL(registryUrl).host}/:_authToken=${tokenToUse}`);
const npmrcContent = npmrcLines.join('\n');
await writeFile(tempNpmrc, npmrcContent, 'utf-8');
await writeFile(packageNpmrc, npmrcLines.join('\n'), 'utf-8');
this.logger.info(`Publishing ${name}@${version} to ${registryUrl}...`);
// Run npm publish
const result = await this.runNpmPublish(packagePath, tempNpmrc, registryUrl);
const result = await this.runNpmPublish(packagePath, packageNpmrc, registryUrl, scope);
if (result.success) {
this.logger.success(`Published ${name}@${version}`);
@ -83,11 +99,16 @@ export class Publisher {
registryUrl,
};
} finally {
// Restore original package.json
// Restore original package.json and .npmrc
try {
await copyFile(backupPackageJson, originalPackageJson);
await unlink(backupPackageJson);
await unlink(tempNpmrc);
if (hadExistingNpmrc) {
await copyFile(backupNpmrc, packageNpmrc);
await unlink(backupNpmrc);
} else {
await unlink(packageNpmrc);
}
} catch {
// Ignore cleanup errors
}
@ -99,27 +120,35 @@ export class Publisher {
*/
private async runNpmPublish(
packagePath: string,
tempNpmrc: string,
registryUrl: string
npmrcPath: string,
registryUrl: string,
scope: string | null
): Promise<Omit<PublishResult, 'packageName' | 'version' | 'registryUrl'>> {
return new Promise((resolve) => {
// Build env with registry overrides that take precedence over all .npmrc files
const env: Record<string, string> = {
...process.env as Record<string, string>,
npm_config_userconfig: npmrcPath,
npm_config_registry: registryUrl,
};
// Override scoped registry to prevent parent .npmrc from redirecting to forgejo
if (scope) {
env[`npm_config_${scope}:registry`] = registryUrl;
}
const npm = spawn(
'npm',
[
'publish',
'--no-git-checks',
'--userconfig',
tempNpmrc,
// Explicit registry flag overrides publishConfig in package.json
npmrcPath,
`--registry=${registryUrl}`,
],
{
cwd: packagePath,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
npm_config_userconfig: tempNpmrc,
},
env,
}
);