import * as fs from 'node:fs'; import * as path from 'node:path'; export type MatrixWorkspacePackageStore = 'global' | 'system'; export type MatrixWorkspaceServiceDeclaration = | { readonly path: string } | { readonly package: string; readonly store?: MatrixWorkspacePackageStore }; export interface MatrixWorkspaceConfig { readonly home?: string; readonly default?: readonly string[]; readonly services?: Record; } export interface MatrixWorkspaceInitResult { readonly workspaceRoot: string; readonly workspaceConfigPath: string; readonly home: string; readonly created: boolean; } export function ensureMatrixWorkspace(workspaceRoot: string): MatrixWorkspaceInitResult { const root = path.resolve(workspaceRoot); const matrixDir = path.join(root, '.matrix'); const workspaceConfigPath = path.join(matrixDir, 'workspace.json'); fs.mkdirSync(matrixDir, { recursive: true }); let created = false; if (!fs.existsSync(workspaceConfigPath)) { writeWorkspaceConfig(root, { home: '.matrix/home', default: [], services: {}, }); created = true; } const config = readWorkspaceConfig(root); const home = path.resolve(root, config.home ?? '.matrix/home'); fs.mkdirSync(home, { recursive: true }); return { workspaceRoot: root, workspaceConfigPath, home, created, }; } export function readWorkspaceConfig(workspaceRoot: string): MatrixWorkspaceConfig { const workspaceConfigPath = path.join(path.resolve(workspaceRoot), '.matrix', 'workspace.json'); const parsed = JSON.parse(fs.readFileSync(workspaceConfigPath, 'utf8')) as unknown; if (!isRecord(parsed)) { throw new Error(`${workspaceConfigPath} must contain a JSON object`); } return { ...(typeof parsed.home === 'string' && parsed.home.trim() ? { home: parsed.home.trim() } : {}), ...(Array.isArray(parsed.default) ? { default: parsed.default.filter(isNonEmptyString) } : {}), ...(isRecord(parsed.services) ? { services: normalizeServices(parsed.services, workspaceConfigPath) } : {}), }; } export function writeWorkspaceConfig(workspaceRoot: string, config: MatrixWorkspaceConfig): string { const root = path.resolve(workspaceRoot); const matrixDir = path.join(root, '.matrix'); const workspaceConfigPath = path.join(matrixDir, 'workspace.json'); fs.mkdirSync(matrixDir, { recursive: true }); fs.writeFileSync(workspaceConfigPath, `${JSON.stringify({ home: config.home ?? '.matrix/home', default: config.default ?? [], services: config.services ?? {}, }, null, 2)}\n`, 'utf8'); return workspaceConfigPath; } export function addWorkspaceService( workspaceRoot: string, serviceId: string, declaration: MatrixWorkspaceServiceDeclaration, options: { readonly addToDefault?: boolean } = {}, ): string { ensureMatrixWorkspace(workspaceRoot); const config = readWorkspaceConfig(workspaceRoot); const services = { ...(config.services ?? {}) }; services[serviceId] = declaration; const currentDefault = config.default ?? []; const nextDefault = options.addToDefault === false || currentDefault.includes(serviceId) ? currentDefault : [...currentDefault, serviceId]; return writeWorkspaceConfig(workspaceRoot, { ...config, services, default: nextDefault, }); } export function ensureBaseHostServices(workspaceRoot: string): string { ensureMatrixWorkspace(workspaceRoot); const config = readWorkspaceConfig(workspaceRoot); const services = { ...(config.services ?? {}) }; const systemSource = findSourceCheckoutPackage(workspaceRoot, 'system'); const hostControlSource = findSourceCheckoutPackage(workspaceRoot, 'host-control'); if (!services.system) { services.system = systemSource ? { path: relativeWorkspacePath(workspaceRoot, systemSource) } : { package: '@open-matrix/system', store: 'system' }; } if (!services['host-control']) { services['host-control'] = hostControlSource ? { path: relativeWorkspacePath(workspaceRoot, hostControlSource) } : { package: '@open-matrix/host-control', store: 'system' }; } const currentDefault = config.default ?? []; const prefix = ['system', 'host-control']; return writeWorkspaceConfig(workspaceRoot, { ...config, services, default: [ ...prefix, ...currentDefault.filter((entry) => !prefix.includes(entry)), ], }); } export function findSourceCheckoutPackage(workspaceRoot: string, packageDirName: string): string | null { const root = path.resolve(workspaceRoot); const candidates = [ path.join(root, 'projects', 'matrix-3', 'packages', packageDirName), path.join(root, 'packages', packageDirName), ]; for (const candidate of candidates) { if ( fs.existsSync(path.join(candidate, 'package.json')) && fs.existsSync(path.join(candidate, 'matrix.json')) ) { return candidate; } } return null; } export function relativeWorkspacePath(workspaceRoot: string, target: string): string { const relative = path.relative(path.resolve(workspaceRoot), path.resolve(target)).replaceAll(path.sep, '/'); if (!relative) { return '.'; } return relative.startsWith('.') ? relative : `./${relative}`; } function normalizeServices( services: Record, workspaceConfigPath: string, ): Record { const normalized: Record = {}; for (const [id, value] of Object.entries(services)) { if (!isRecord(value)) { throw new Error(`${workspaceConfigPath}: services.${id} must be an object`); } if (typeof value.path === 'string' && value.path.trim()) { normalized[id] = { path: value.path.trim() }; continue; } if (typeof value.package === 'string' && value.package.trim()) { const store = value.store === 'system' ? 'system' : value.store === 'global' ? 'global' : undefined; normalized[id] = { package: value.package.trim(), ...(store ? { store } : {}), }; continue; } throw new Error(`${workspaceConfigPath}: services.${id} must declare path or package`); } return normalized; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; }