313 lines
10 KiB
TypeScript

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<string, string>;
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<IRegistryUseResult> {
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<IRegistryLoginResult> {
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<IRegistryDoctorResult> {
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<string, string> = {};
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(''),
});
});
});
}