hivecast-sdk/packages/cli/src/utils/npm-registry-client.ts

346 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 { 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<INpmCommandResult> {
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<readonly INpmRegistrySearchResult[]> {
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<string, unknown> | 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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: 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<INpmCommandResult> {
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(''),
});
});
});
}