import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { readRegistryCredential } from './auth-store.js'; export const MATRIX_NPM_REGISTRY_SCOPES = ['@open-matrix', '@matrix', '@omega'] as const; export interface INpmCommandResult { readonly exitCode: number | null; readonly stdout: string; readonly stderr: string; } export interface INpmRegistrySearchResult { readonly packageName: string; readonly version: string; readonly description?: string; readonly keywords: readonly string[]; } export function isHttpRegistryUrl(value: string | undefined): value is string { if (!value) { return false; } try { const parsed = new URL(value); return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } catch { return false; } } export function npmPackageSpecifier(packageName: string, version: string | undefined): string { return version ? `${packageName}@${version}` : packageName; } export async function prepareNpmRegistryTarball( specifier: string, registryUrl: string, cwd: string, credentialsPath: string | undefined, ): Promise<{ readonly tarballPath: string; readonly cleanupDir: string }> { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-install-npm-registry-')); const credential = readRegistryCredential(cwd, { registry: registryUrl, credentialsPath, }).credential; const userConfig = writeTemporaryNpmrc(tempDir, registryUrl, credential?.token ?? null); const result = await runNpmCommand([ 'pack', specifier, '--pack-destination', tempDir, '--registry', registryUrl, '--userconfig', userConfig, ], { cwd: tempDir, registryUrl, userConfig, cacheDir: path.join(tempDir, '.npm-cache'), }); if (result.exitCode !== 0) { fs.rmSync(tempDir, { recursive: true, force: true }); const output = [result.stderr, result.stdout] .filter((value) => typeof value === 'string' && value.trim().length > 0) .join('\n') .trim(); throw new Error( `MX_REGISTRY_FETCH_FAILED: failed to fetch ${specifier} from ${registryUrl}` + (output ? `\n${output}` : ''), ); } const tarballs = fs.readdirSync(tempDir) .filter((entry) => entry.endsWith('.tgz') || entry.endsWith('.tar.gz')); if (tarballs.length === 0) { fs.rmSync(tempDir, { recursive: true, force: true }); throw new Error(`MX_REGISTRY_FETCH_FAILED: npm pack produced no tarball for ${specifier}`); } return { tarballPath: path.join(tempDir, tarballs[0]!), cleanupDir: tempDir, }; } export async function packNpmPublishTarball( packageDir: string, outDir: string, ): Promise<{ readonly tarballPath: string; readonly stdout: string; readonly stderr: string }> { fs.mkdirSync(outDir, { recursive: true }); const result = await runNpmCommand([ 'pack', packageDir, '--pack-destination', outDir, ], { cwd: packageDir, cacheDir: path.join(outDir, '.npm-cache'), }); if (result.exitCode !== 0) { const output = [result.stderr, result.stdout] .filter((value) => typeof value === 'string' && value.trim().length > 0) .join('\n') .trim(); throw new Error(`MX_REGISTRY_PACK_FAILED: npm pack failed${output ? `\n${output}` : ''}`); } const stdoutTarball = result.stdout .split(/\r?\n/) .map((line) => line.trim()) .reverse() .find((line) => line.endsWith('.tgz') || line.endsWith('.tar.gz')); if (stdoutTarball) { const tarballPath = path.join(outDir, path.basename(stdoutTarball)); if (fs.existsSync(tarballPath)) { return { tarballPath, stdout: result.stdout, stderr: result.stderr, }; } } const tarball = fs.readdirSync(outDir) .filter((entry) => entry.endsWith('.tgz') || entry.endsWith('.tar.gz')) .map((entry) => ({ entry, mtimeMs: fs.statSync(path.join(outDir, entry)).mtimeMs, })) .sort((left, right) => left.mtimeMs - right.mtimeMs) .at(-1); if (!tarball) { throw new Error(`MX_REGISTRY_PACK_FAILED: npm pack produced no tarball under ${outDir}`); } return { tarballPath: path.join(outDir, tarball.entry), stdout: result.stdout, stderr: result.stderr, }; } export async function publishNpmTarball( tarballPath: string, registryUrl: string, cwd: string, credentialsPath: string | undefined, ): Promise { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-publish-npm-registry-')); try { const credential = readRegistryCredential(cwd, { registry: registryUrl, credentialsPath, }).credential; const userConfig = writeTemporaryNpmrc(tempDir, registryUrl, credential?.token ?? null); const result = await runNpmCommand([ 'publish', tarballPath, '--registry', registryUrl, '--userconfig', userConfig, ], { cwd, registryUrl, userConfig, cacheDir: path.join(tempDir, '.npm-cache'), }); return result; } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } } export async function searchNpmRegistry( query: string, registryUrl: string, cwd: string, credentialsPath: string | undefined, limit: number, ): Promise { const trimmedQuery = query.trim(); if (!trimmedQuery) { throw new Error('MX_SEARCH_INVALID: query is required'); } const searchUrl = new URL('-/v1/search', registryUrl.endsWith('/') ? registryUrl : `${registryUrl}/`); searchUrl.searchParams.set('text', trimmedQuery); searchUrl.searchParams.set('size', String(limit)); const credential = readRegistryCredential(cwd, { registry: registryUrl, credentialsPath, }).credential; const response = await fetch(searchUrl, { headers: { accept: 'application/json', ...(credential?.token ? { authorization: `Bearer ${credential.token}` } : {}), }, }); if (!response.ok) { const body = await response.text(); throw new Error( `MX_REGISTRY_SEARCH_FAILED: registry search failed with HTTP ${response.status}` + (body.trim() ? `\n${body.trim()}` : ''), ); } const parsed = asRecord(await response.json() as unknown); const objects = Array.isArray(parsed?.objects) ? parsed.objects : []; const results: INpmRegistrySearchResult[] = []; for (const object of objects) { const packageRecord = asRecord(asRecord(object)?.package); const packageName = readNpmSearchPackageName(packageRecord); const version = readString(packageRecord?.version); if (!packageName || !version) { continue; } results.push({ packageName, version, ...(readString(packageRecord?.description) ? { description: readString(packageRecord?.description)! } : {}), keywords: readStringArray(packageRecord?.keywords), }); } return results; } function readNpmSearchPackageName(packageRecord: Record | null): string | undefined { const name = readString(packageRecord?.name); if (!name) { return undefined; } if (name.startsWith('@')) { return name; } const scope = readString(packageRecord?.scope); if (!scope) { return name; } return `${scope.startsWith('@') ? scope : `@${scope}`}/${name}`; } function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; } function readString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function readStringArray(value: unknown): readonly string[] { if (!Array.isArray(value)) { return []; } return value .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); } function registryAuthConfigKey(registryUrl: string): string { const parsed = new URL(registryUrl); const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`; return `//${parsed.host}${pathname}:_authToken`; } function writeTemporaryNpmrc( tempDir: string, registryUrl: string, token: string | null, ): string { const npmrcPath = path.join(tempDir, '.npmrc'); const lines = [ `registry=${registryUrl}`, ]; for (const scope of MATRIX_NPM_REGISTRY_SCOPES) { lines.push(`${scope}:registry=${registryUrl}`); } if (token) { lines.push('always-auth=true'); lines.push(`${registryAuthConfigKey(registryUrl)}=${token}`); } fs.writeFileSync(npmrcPath, `${lines.join('\n')}\n`, 'utf8'); return npmrcPath; } async function runNpmCommand( args: readonly string[], options: { readonly cwd: string; readonly registryUrl?: string; readonly userConfig?: string; readonly cacheDir: string; }, ): Promise { const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; fs.mkdirSync(options.cacheDir, { recursive: true }); const globalConfig = path.join(options.cacheDir, 'empty-global-npmrc'); if (!fs.existsSync(globalConfig)) { fs.writeFileSync(globalConfig, '', 'utf8'); } return await new Promise((resolve, reject) => { const child = spawn(npmCommand, [...args], { cwd: options.cwd, env: { ...process.env, ...(options.userConfig ? { NPM_CONFIG_USERCONFIG: options.userConfig, npm_config_userconfig: options.userConfig, } : {}), NPM_CONFIG_GLOBALCONFIG: globalConfig, npm_config_globalconfig: globalConfig, ...(options.registryUrl ? { npm_config_registry: options.registryUrl } : {}), npm_config_cache: options.cacheDir, }, shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); const stdout: string[] = []; const stderr: string[] = []; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => stdout.push(chunk)); child.stderr.on('data', (chunk: string) => stderr.push(chunk)); child.on('error', reject); child.on('close', (exitCode) => { resolve({ exitCode, stdout: stdout.join(''), stderr: stderr.join(''), }); }); }); }