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; 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; }