186 lines
6.3 KiB
TypeScript
186 lines
6.3 KiB
TypeScript
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;
|
|
}
|