213 lines
7.0 KiB
TypeScript
213 lines
7.0 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { validateManifestForMxCli } from './manifestValidator.js';
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
export interface IResolvedManifest {
|
|
readonly packageName: string;
|
|
readonly manifest: JsonRecord;
|
|
}
|
|
|
|
const DEFAULT_GLOBAL_PACKAGES_ROOT = path.join(os.homedir(), '.matrix', 'packages');
|
|
const DEFAULT_GLOBAL_REGISTRY_ROOT = path.join(os.homedir(), '.matrix', 'registry');
|
|
|
|
export function resolvePackagesRoot(cwd: string, packagesDir?: string): string {
|
|
return path.resolve(cwd, packagesDir ?? DEFAULT_GLOBAL_PACKAGES_ROOT);
|
|
}
|
|
|
|
export function resolvePackagesNodeModulesRoot(cwd: string, packagesDir?: string): string {
|
|
return path.join(resolvePackagesRoot(cwd, packagesDir), 'node_modules');
|
|
}
|
|
|
|
export function resolveRegistryRoot(cwd: string, registryDir?: string): string {
|
|
return path.resolve(cwd, registryDir ?? DEFAULT_GLOBAL_REGISTRY_ROOT);
|
|
}
|
|
|
|
export function packageNameToPath(packageName: string): string {
|
|
if (packageName.includes('/')) {
|
|
return packageName;
|
|
}
|
|
return packageName.trim();
|
|
}
|
|
|
|
export function listInstalledPackageNames(nodeModulesRoot: string): string[] {
|
|
if (!fs.existsSync(nodeModulesRoot)) {
|
|
return [];
|
|
}
|
|
const entries = fs.readdirSync(nodeModulesRoot, { withFileTypes: true });
|
|
const names: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (entry.name.startsWith('.')) continue;
|
|
|
|
if (entry.name.startsWith('@')) {
|
|
const scopeRoot = path.join(nodeModulesRoot, entry.name);
|
|
for (const scoped of fs.readdirSync(scopeRoot, { withFileTypes: true })) {
|
|
if (!scoped.isDirectory()) continue;
|
|
names.push(`${entry.name}/${scoped.name}`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
names.push(entry.name);
|
|
}
|
|
|
|
return names.sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
function readJsonFile(filePath: string): JsonRecord {
|
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error(`${filePath} must contain a JSON object`);
|
|
}
|
|
return parsed as JsonRecord;
|
|
}
|
|
|
|
function asRecord(value: unknown): JsonRecord | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
return value as JsonRecord;
|
|
}
|
|
|
|
function readOptionalString(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function readPackageJsonIfExists(packageDir: string): JsonRecord | null {
|
|
const packageJsonPath = path.join(packageDir, 'package.json');
|
|
return fs.existsSync(packageJsonPath) ? readJsonFile(packageJsonPath) : null;
|
|
}
|
|
|
|
function packageNameComparable(value: string): string {
|
|
const trimmed = value.trim();
|
|
if (trimmed.startsWith('@matrix/')) {
|
|
return trimmed.slice('@matrix/'.length);
|
|
}
|
|
if (trimmed.startsWith('@open-matrix/')) {
|
|
return trimmed.slice('@open-matrix/'.length);
|
|
}
|
|
if (trimmed.startsWith('@') && trimmed.includes('/')) {
|
|
return trimmed.slice(trimmed.indexOf('/') + 1);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function assertMatrixAndPackageJsonAgree(
|
|
packageDir: string,
|
|
matrixManifest: JsonRecord,
|
|
packageJson: JsonRecord | null,
|
|
): void {
|
|
if (!packageJson) {
|
|
return;
|
|
}
|
|
|
|
const matrixName = readOptionalString(matrixManifest.name);
|
|
const packageName = readOptionalString(packageJson.name);
|
|
if (
|
|
matrixName
|
|
&& packageName
|
|
&& matrixName !== packageName
|
|
&& packageNameComparable(matrixName) !== packageNameComparable(packageName)
|
|
) {
|
|
throw new Error(
|
|
`MX_PACKAGE_IDENTITY_MISMATCH: matrix.json name (${matrixName}) `
|
|
+ `does not match package.json name (${packageName}) in ${packageDir}`,
|
|
);
|
|
}
|
|
|
|
const matrixVersion = readOptionalString(matrixManifest.version);
|
|
const packageVersion = readOptionalString(packageJson.version);
|
|
if (matrixVersion && packageVersion && matrixVersion !== packageVersion) {
|
|
throw new Error(
|
|
`MX_PACKAGE_VERSION_MISMATCH: matrix.json version (${matrixVersion}) `
|
|
+ `does not match package.json version (${packageVersion}) in ${packageDir}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function readManifestFromPackageDir(packageDir: string): IResolvedManifest {
|
|
const matrixJsonPath = path.join(packageDir, 'matrix.json');
|
|
if (fs.existsSync(matrixJsonPath)) {
|
|
const matrixManifest = readJsonFile(matrixJsonPath);
|
|
const packageJson = readPackageJsonIfExists(packageDir);
|
|
assertMatrixAndPackageJsonAgree(packageDir, matrixManifest, packageJson);
|
|
const packageName = readOptionalString(matrixManifest.name);
|
|
if (!packageName) {
|
|
throw new Error(`matrix.json must contain a non-empty "name" field (${matrixJsonPath})`);
|
|
}
|
|
return {
|
|
packageName,
|
|
manifest: matrixManifest,
|
|
};
|
|
}
|
|
|
|
const packageJsonPath = path.join(packageDir, 'package.json');
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
throw new Error(`Package manifest not found in ${packageDir} (expected matrix.json or package.json)`);
|
|
}
|
|
|
|
const packageJson = readJsonFile(packageJsonPath);
|
|
const packageName = typeof packageJson.name === 'string' ? packageJson.name.trim() : '';
|
|
if (!packageName) {
|
|
throw new Error(`package.json must contain a non-empty "name" field (${packageJsonPath})`);
|
|
}
|
|
|
|
const matrix = asRecord(packageJson.matrix) ?? {};
|
|
const manifest: JsonRecord = {
|
|
name: packageName,
|
|
version: packageJson.version,
|
|
runtime: matrix.runtime,
|
|
components: matrix.components,
|
|
permissions: matrix.permissions,
|
|
install: matrix.install,
|
|
};
|
|
return { packageName, manifest };
|
|
}
|
|
|
|
export function validateResolvedManifest(packageName: string, manifest: JsonRecord): void {
|
|
const validation = validateManifestForMxCli({
|
|
...manifest,
|
|
name: manifest.name ?? packageName,
|
|
});
|
|
if (!validation.valid) {
|
|
throw new Error(
|
|
`Manifest validation failed for ${packageName}: ${validation.errors.join('; ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
export function readPackageJson(packageDir: string): JsonRecord {
|
|
const packageJsonPath = path.join(packageDir, 'package.json');
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
throw new Error(`package.json not found in ${packageDir}`);
|
|
}
|
|
return readJsonFile(packageJsonPath);
|
|
}
|
|
|
|
export function readVersionFromPackageDir(packageDir: string): string {
|
|
const matrixJsonPath = path.join(packageDir, 'matrix.json');
|
|
const packageJson = readPackageJsonIfExists(packageDir);
|
|
if (fs.existsSync(matrixJsonPath)) {
|
|
const matrixManifest = readJsonFile(matrixJsonPath);
|
|
assertMatrixAndPackageJsonAgree(packageDir, matrixManifest, packageJson);
|
|
const matrixVersion = readOptionalString(matrixManifest.version);
|
|
const packageVersion = readOptionalString(packageJson?.version);
|
|
if (packageVersion.length > 0) {
|
|
return packageVersion;
|
|
}
|
|
if (matrixVersion.length > 0) {
|
|
return matrixVersion;
|
|
}
|
|
}
|
|
|
|
const version = readOptionalString((packageJson ?? readPackageJson(packageDir)).version);
|
|
if (!version) {
|
|
throw new Error(`Unable to resolve package version in ${packageDir}`);
|
|
}
|
|
return version;
|
|
}
|