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 { loginCommand } from './auth.js'; import { configSetCommand } from './config.js'; import { readRegistryCredential } from '../utils/auth-store.js'; import { resolvePackageRegistry } from '../utils/config-store.js'; import { MATRIX_NPM_REGISTRY_SCOPES } from '../utils/npm-registry-client.js'; export interface IRegistryCommandOptions { readonly cwd?: string; readonly registry?: string; readonly username?: string; readonly token?: string; readonly email?: string; readonly expiresAt?: string; readonly credentialsPath?: string; readonly configPath?: string; readonly timeoutMs?: number; } export interface IRegistryUseResult { readonly ok: true; readonly profile: string; readonly registry: string; readonly configPath: string; } export interface IRegistryLoginResult { readonly ok: true; readonly registry: string; readonly username: string; readonly credentialsPath: string; } export interface IRegistryDoctorResult { readonly ok: boolean; readonly mode: 'registry-doctor'; readonly registry: { readonly url: string; readonly source: string; readonly reachable: boolean; readonly status?: number; readonly error?: string; }; readonly credentials: { readonly found: boolean; readonly path: string; readonly username?: string; readonly authChecked: boolean; readonly authOk?: boolean; readonly whoami?: string; readonly error?: string; }; readonly npm: { readonly userConfig: string; readonly scopeResolution: Record; readonly allScopesMatch: boolean; readonly poisonedUserConfigIgnored: boolean; }; } const LOCAL_GITEA_REGISTRY = 'http://127.0.0.1:3000/api/packages/open-matrix/npm/'; export async function registryUseCommand( profile: string, options: IRegistryCommandOptions = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); const registry = registryForProfile(profile, options.registry, cwd, options.configPath); const result = await configSetCommand('registry', registry, { cwd, configPath: options.configPath, }); return { ok: true, profile, registry: result.value, configPath: result.configPath, }; } export async function registryLoginCommand(options: IRegistryCommandOptions = {}): Promise { if (!options.username || !options.token) { throw new Error('MX_REGISTRY_LOGIN_REQUIRED: --username and --token are required'); } const result = await loginCommand({ username: options.username, token: options.token, email: options.email, expiresAt: options.expiresAt, registry: options.registry, credentialsPath: options.credentialsPath, cwd: options.cwd, }); return { ok: true, registry: result.registry, username: result.username, credentialsPath: result.credentialsPath, }; } export async function registryDoctorCommand(options: IRegistryCommandOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const resolved = resolvePackageRegistry(options.registry, { cwd, configPath: options.configPath, }); const credential = readRegistryCredential(cwd, { registry: resolved.registry, credentialsPath: options.credentialsPath, }); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-registry-doctor-')); try { const userConfig = writeDoctorNpmrc(tempDir, resolved.registry, credential.credential?.token ?? null); const npmRegistry = await runNpm(['config', 'get', 'registry', '--userconfig', userConfig], { cwd, registryUrl: resolved.registry, userConfig, timeoutMs: options.timeoutMs, }); const scopeResolution: Record = {}; for (const scope of MATRIX_NPM_REGISTRY_SCOPES) { const result = await runNpm(['config', 'get', `${scope}:registry`, '--userconfig', userConfig], { cwd, registryUrl: resolved.registry, userConfig, timeoutMs: options.timeoutMs, }); scopeResolution[scope] = result.stdout.trim(); } const registryValue = npmRegistry.stdout.trim(); const allScopesMatch = registryValue === resolved.registry && MATRIX_NPM_REGISTRY_SCOPES.every((scope) => scopeResolution[scope] === resolved.registry); const reachability = await checkRegistryReachability(resolved.registry, options.timeoutMs ?? 3_000); const auth = credential.credential ? await checkNpmWhoami(cwd, resolved.registry, userConfig, options.timeoutMs) : { checked: false as const }; const ok = allScopesMatch && (auth.checked ? auth.ok === true : true); return { ok, mode: 'registry-doctor', registry: { url: resolved.registry, source: resolved.source, reachable: reachability.reachable, ...(reachability.status !== undefined ? { status: reachability.status } : {}), ...(reachability.error ? { error: reachability.error } : {}), }, credentials: { found: Boolean(credential.credential), path: credential.credentialsPath, ...(credential.credential?.username ? { username: credential.credential.username } : {}), authChecked: auth.checked, ...(auth.checked ? { authOk: auth.ok } : {}), ...(auth.checked && auth.whoami ? { whoami: auth.whoami } : {}), ...(auth.checked && auth.error ? { error: auth.error } : {}), }, npm: { userConfig, scopeResolution, allScopesMatch, poisonedUserConfigIgnored: allScopesMatch, }, }; } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } } function registryForProfile( profile: string, explicitRegistry: string | undefined, cwd: string, configPath: string | undefined, ): string { if (profile === 'local-gitea') { return resolvePackageRegistry(explicitRegistry ?? LOCAL_GITEA_REGISTRY, { cwd, configPath }).registry; } if (explicitRegistry) { return resolvePackageRegistry(explicitRegistry, { cwd, configPath }).registry; } if (profile.startsWith('http://') || profile.startsWith('https://')) { return resolvePackageRegistry(profile, { cwd, configPath }).registry; } throw new Error(`MX_REGISTRY_PROFILE_UNKNOWN: ${profile}`); } function writeDoctorNpmrc(tempDir: string, registry: string, token: string | null): string { const filePath = path.join(tempDir, '.npmrc'); const lines = [ `registry=${registry}`, ...MATRIX_NPM_REGISTRY_SCOPES.map((scope) => `${scope}:registry=${registry}`), ]; if (token) { lines.push('always-auth=true'); lines.push(`${registryAuthConfigKey(registry)}=${token}`); } fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); return filePath; } function registryAuthConfigKey(registry: string): string { const parsed = new URL(registry); const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`; return `//${parsed.host}${pathname}:_authToken`; } async function checkRegistryReachability( registry: string, timeoutMs: number, ): Promise<{ readonly reachable: boolean; readonly status?: number; readonly error?: string }> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(registry, { method: 'GET', signal: controller.signal, headers: { accept: 'application/json,text/plain,*/*' }, }); return { reachable: true, status: response.status }; } catch (error) { return { reachable: false, error: error instanceof Error ? error.message : String(error), }; } finally { clearTimeout(timeout); } } async function checkNpmWhoami( cwd: string, registry: string, userConfig: string, timeoutMs: number | undefined, ): Promise<{ readonly checked: true; readonly ok: boolean; readonly whoami?: string; readonly error?: string; }> { const result = await runNpm(['whoami', '--registry', registry, '--userconfig', userConfig], { cwd, registryUrl: registry, userConfig, timeoutMs, }); const output = [result.stderr, result.stdout] .map((value) => value.trim()) .filter(Boolean) .join('\n'); return { checked: true, ok: result.exitCode === 0, ...(result.exitCode === 0 && result.stdout.trim() ? { whoami: result.stdout.trim() } : {}), ...(result.exitCode === 0 ? {} : { error: output || `npm whoami exited ${result.exitCode}` }), }; } async function runNpm( args: readonly string[], options: { readonly cwd: string; readonly registryUrl: string; readonly userConfig: string; readonly timeoutMs?: number; }, ): Promise<{ readonly exitCode: number | null; readonly stdout: string; readonly stderr: string }> { const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; return await new Promise((resolve, reject) => { const child = spawn(npmCommand, args, { cwd: options.cwd, env: { ...process.env, npm_config_userconfig: options.userConfig, NPM_CONFIG_USERCONFIG: options.userConfig, npm_config_registry: options.registryUrl, NPM_CONFIG_REGISTRY: options.registryUrl, npm_config_cache: path.join(path.dirname(options.userConfig), '.npm-cache'), }, shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); const stdout: string[] = []; const stderr: string[] = []; const timeout = setTimeout(() => { child.kill('SIGTERM'); }, options.timeoutMs ?? 10_000); 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', (error) => { clearTimeout(timeout); reject(error); }); child.on('close', (exitCode) => { clearTimeout(timeout); resolve({ exitCode, stdout: stdout.join(''), stderr: stderr.join(''), }); }); }); }