313 lines
10 KiB
TypeScript
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(''),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|