186 lines
6.3 KiB
TypeScript
Raw Normal View History

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<string, MatrixWorkspaceServiceDeclaration>;
}
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<string, unknown>,
workspaceConfigPath: string,
): Record<string, MatrixWorkspaceServiceDeclaration> {
const normalized: Record<string, MatrixWorkspaceServiceDeclaration> = {};
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<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}