diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..28422bc --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +registry=http://localhost:4874/ +@lilith:registry=http://localhost:4874/ +//localhost:4874/:_authToken=local-dev-token \ No newline at end of file diff --git a/src/core/builder.ts b/src/core/builder.ts index 58dcbcc..11205f2 100644 --- a/src/core/builder.ts +++ b/src/core/builder.ts @@ -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'], }); diff --git a/src/core/publisher.ts b/src/core/publisher.ts index 6b2aa55..974fefb 100644 --- a/src/core/publisher.ts +++ b/src/core/publisher.ts @@ -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> { return new Promise((resolve) => { + // Build env with registry overrides that take precedence over all .npmrc files + const env: Record = { + ...process.env as Record, + 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, } );