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