CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The production image now contains zero feature modules and is the same for every installation. Installed modules live in module storage that one Core resolver selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an absolute path that must exist when set (/var/lib/ekklesia/modules in the image). A relative, blank, missing, or unreadable configured directory fails closed with a sanitized error that never contains the value. - Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply and validate, module validation, module enablement, and Doctor all resolve it the same way; the test-only API override still takes precedence. - Doctor receives Core's migrations directory explicitly instead of deriving it from the modules directory's parent. - The migration runner retains and reports the applied history of a module that is not installed, and plans and applies Core's and present modules' migrations around it. Core's history and every installed module's history stay strict: a missing or changed applied migration, or a downgrade, still fails. No down migrations, no data deletion. - deploy/Dockerfile copies no module and installs nothing for the module template; .dockerignore excludes modules/ and the build refuses a context that contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and sets EKKLESIA_MODULES_DIR. - deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api and migrate, with create_host_path disabled. - scripts/check-image.sh proves the image holds no modules and that a populated modules/ directory leaves its runtime file system identical; CI runs it. - The split deploy smoke now uses the same generic image: it boots with no modules, installs a self-contained test module packaged from the template (test-only), migrates and restarts, removes it (Core runs, planning reports the retained history, data survives), and restores it with its data. Docs: deploy README (module storage, install/update/remove/restore, the runtime modules target), .env.example, API configuration, module development guide, testing, workflow, security, and the architecture overview. Co-Authored-By: Claude Opus 5.5 <[email protected]>
934 lines
32 KiB
TypeScript
934 lines
32 KiB
TypeScript
import type { Dirent } from "node:fs";
|
|
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
isVersionCompatible,
|
|
type ModuleCapabilityDeclaration,
|
|
type ModuleManifest,
|
|
type PermissionId,
|
|
safeParseModuleManifest,
|
|
semanticVersionSchema,
|
|
} from "@ekklesia/contracts";
|
|
|
|
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
|
|
|
/**
|
|
* The only manifest entrypoint that resolves to a directory rather than a compiled file.
|
|
*
|
|
* Every other entrypoint is an executable build artifact, so the compiled-ESM rule is expressed as
|
|
* "not a directory entrypoint" rather than as a list of the entrypoints it covers. An allowlist is
|
|
* what let `jobs` and, before it, `ui` be added to the manifest and silently escape this check;
|
|
* defaulting a new entrypoint to *stricter* validation is the safer failure mode.
|
|
*/
|
|
const DIRECTORY_ENTRYPOINTS: ReadonlySet<string> = new Set(["migrations"]);
|
|
|
|
/** Whether an entrypoint must reference compiled ESM JavaScript (ADR 0006's production loading
|
|
* boundary): true for every entrypoint that is a file rather than a directory. */
|
|
function isCompiledEntrypoint(entrypointName: string): boolean {
|
|
return !DIRECTORY_ENTRYPOINTS.has(entrypointName);
|
|
}
|
|
|
|
export type ModuleDiscoveryIssueCode =
|
|
| "DISCOVERY_CONFIGURATION_INVALID"
|
|
| "MODULE_CAPABILITY_INCOMPATIBLE"
|
|
| "MODULE_CAPABILITY_MISSING"
|
|
| "MODULE_DEPENDENCY_CYCLE"
|
|
| "MODULE_DEPENDENCY_INCOMPATIBLE"
|
|
| "MODULE_DEPENDENCY_MISSING"
|
|
| "MODULE_DIRECTORY_INVALID"
|
|
| "MODULE_ENTRYPOINT_INVALID"
|
|
| "MODULE_ENTRYPOINT_MISSING"
|
|
| "MODULE_ID_DUPLICATE"
|
|
| "MODULE_MANIFEST_INVALID"
|
|
| "MODULE_MANIFEST_MISSING"
|
|
| "MODULE_PACKAGE_INVALID"
|
|
| "MODULE_PACKAGE_MISSING"
|
|
| "MODULE_PACKAGE_NAME_DUPLICATE"
|
|
| "MODULE_PLATFORM_INCOMPATIBLE"
|
|
| "MODULE_SDK_INCOMPATIBLE"
|
|
| "MODULE_VERSION_MISMATCH"
|
|
| "MODULES_DIRECTORY_INVALID"
|
|
| "MODULES_DIRECTORY_UNREADABLE";
|
|
|
|
export interface ModuleDiscoveryIssue {
|
|
readonly code: ModuleDiscoveryIssueCode;
|
|
readonly message: string;
|
|
readonly moduleDirectory: string;
|
|
readonly moduleId?: string;
|
|
readonly path?: string;
|
|
}
|
|
|
|
/**
|
|
* One declared browser contribution, with its artifact resolved and validated exactly like every
|
|
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
|
|
* what it renders.
|
|
*/
|
|
export interface ResolvedModuleContribution {
|
|
readonly id: string;
|
|
readonly version: string;
|
|
readonly capability: string;
|
|
readonly capabilityRange: string;
|
|
readonly artifactPath: string;
|
|
}
|
|
|
|
export interface ResolvedModuleEntrypoints {
|
|
readonly server: string;
|
|
readonly schema?: string;
|
|
readonly migrations?: string;
|
|
readonly jobs?: string;
|
|
/** A module's primary presentation artifact (ADR 0021), resolved and validated exactly like the
|
|
* other executable entrypoints. Discovery never imports or evaluates browser artifacts. */
|
|
readonly ui?: string;
|
|
/** The independently mountable full-viewport artifact and its statically declared access policy.
|
|
* `artifactPath` is canonical and package-contained; `permission` already matched one of this
|
|
* module's own manifest declarations during manifest parsing. */
|
|
readonly display?: {
|
|
readonly artifactPath: string;
|
|
readonly permission: PermissionId;
|
|
};
|
|
}
|
|
|
|
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
|
// which structurally satisfy `T extends object` — without this check first, the mapped-type
|
|
// branch below would incorrectly expand a branded string into `String.prototype`'s member shape
|
|
// and lose the brand entirely, rather than leaving an already-immutable primitive untouched.
|
|
type DeepReadonly<T> = T extends string | number | boolean | bigint | symbol | null | undefined
|
|
? T
|
|
: T extends (...arguments_: never[]) => unknown
|
|
? T
|
|
: T extends readonly (infer Item)[]
|
|
? readonly DeepReadonly<Item>[]
|
|
: T extends object
|
|
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
|
: T;
|
|
|
|
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
|
|
|
export interface DiscoveredModule {
|
|
readonly directory: string;
|
|
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
|
|
* inside this module package. Empty for a module that contributes nothing. */
|
|
readonly contributions: readonly ResolvedModuleContribution[];
|
|
readonly entrypoints: ResolvedModuleEntrypoints;
|
|
readonly manifest: ReadonlyModuleManifest;
|
|
readonly manifestPath: string;
|
|
readonly packageName: string;
|
|
}
|
|
|
|
export interface ModuleDiscoveryOptions {
|
|
readonly moduleSdkVersion: string;
|
|
readonly modulesDirectory: string;
|
|
/**
|
|
* True when an operator configured the directory explicitly (`EKKLESIA_MODULES_DIR`, ADR 0048).
|
|
* A configured directory that is missing, not a directory, or not an absolute path then fails
|
|
* discovery closed rather than reading as zero modules: a mistyped mount must never quietly start
|
|
* the platform with nothing installed. False or omitted keeps the development default lenient,
|
|
* where a checkout with no `modules/` simply has no modules.
|
|
*/
|
|
readonly modulesDirectoryRequired?: boolean;
|
|
readonly platformVersion: string;
|
|
}
|
|
|
|
/** The one sanitized message for an unusable configured modules directory. It never carries the
|
|
* value: a path is machine detail, and a misplaced value could be anything at all. */
|
|
export const MODULES_DIRECTORY_INVALID_MESSAGE = "EKKLESIA_MODULES_DIR must be an absolute path.";
|
|
|
|
/**
|
|
* Whether a configured modules directory is usable as given: a non-empty absolute path with no
|
|
* surrounding whitespace and no NUL byte. Deliberately strict rather than forgiving, because
|
|
* trimming or resolving a near-miss would silently pick a directory the operator did not name.
|
|
*/
|
|
export function isUsableModulesDirectoryPath(value: string): boolean {
|
|
return (
|
|
value.length > 0 &&
|
|
value.trim() === value &&
|
|
!value.includes("\u0000") &&
|
|
path.isAbsolute(value)
|
|
);
|
|
}
|
|
|
|
interface ModulePackageData {
|
|
name: string;
|
|
version: string;
|
|
}
|
|
|
|
interface ModuleCandidate {
|
|
contributions: readonly ResolvedModuleContribution[];
|
|
directory: string;
|
|
directoryName: string;
|
|
entrypoints: ResolvedModuleEntrypoints;
|
|
manifest: ModuleManifest;
|
|
manifestPath: string;
|
|
packageName: string;
|
|
}
|
|
|
|
interface CapabilityProvider {
|
|
candidate: ModuleCandidate;
|
|
declaration: ModuleCapabilityDeclaration;
|
|
}
|
|
|
|
export class ModuleDiscoveryError extends Error {
|
|
readonly issues: readonly ModuleDiscoveryIssue[];
|
|
|
|
constructor(issues: readonly ModuleDiscoveryIssue[]) {
|
|
const sortedIssues = [...issues].sort(compareIssues);
|
|
super(`Module discovery failed with ${sortedIssues.length} issue(s).`);
|
|
this.name = "ModuleDiscoveryError";
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
this.issues = freezeRecursively(sortedIssues);
|
|
}
|
|
}
|
|
|
|
function compareText(left: string, right: string): number {
|
|
if (left < right) {
|
|
return -1;
|
|
}
|
|
if (left > right) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function compareIssues(left: ModuleDiscoveryIssue, right: ModuleDiscoveryIssue): number {
|
|
return (
|
|
compareText(left.moduleDirectory, right.moduleDirectory) ||
|
|
compareText(left.code, right.code) ||
|
|
compareText(left.path ?? "", right.path ?? "") ||
|
|
compareText(left.message, right.message)
|
|
);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function freezeRecursively<T>(value: T): T {
|
|
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
|
|
return value;
|
|
}
|
|
for (const nestedValue of Object.values(value)) {
|
|
freezeRecursively(nestedValue);
|
|
}
|
|
Object.freeze(value);
|
|
return value;
|
|
}
|
|
|
|
function errorCode(error: unknown): string | undefined {
|
|
if (isRecord(error) && typeof error.code === "string") {
|
|
return error.code;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function issuePath(pathParts: readonly PropertyKey[]): string {
|
|
return pathParts.map(String).join(".");
|
|
}
|
|
|
|
function pathIsInside(parent: string, candidate: string): boolean {
|
|
const relativePath = path.relative(parent, candidate);
|
|
return (
|
|
relativePath === "" ||
|
|
(relativePath !== ".." &&
|
|
!relativePath.startsWith(`..${path.sep}`) &&
|
|
!path.isAbsolute(relativePath))
|
|
);
|
|
}
|
|
|
|
async function readJson(pathname: string): Promise<unknown> {
|
|
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
|
}
|
|
|
|
function validatePackageData(
|
|
input: unknown,
|
|
directoryName: string,
|
|
issues: ModuleDiscoveryIssue[],
|
|
): ModulePackageData | undefined {
|
|
if (!isRecord(input)) {
|
|
issues.push({
|
|
code: "MODULE_PACKAGE_INVALID",
|
|
message: "package.json must contain a JSON object.",
|
|
moduleDirectory: directoryName,
|
|
});
|
|
return undefined;
|
|
}
|
|
|
|
const name = input.name;
|
|
const version = input.version;
|
|
const packageType = input.type;
|
|
const layer = isRecord(input.ekklesia) ? input.ekklesia.layer : undefined;
|
|
let validPackage = true;
|
|
|
|
if (typeof name !== "string" || name.length === 0) {
|
|
validPackage = false;
|
|
issues.push({
|
|
code: "MODULE_PACKAGE_INVALID",
|
|
message: "package.json must declare a non-empty package name.",
|
|
moduleDirectory: directoryName,
|
|
path: "name",
|
|
});
|
|
}
|
|
if (semanticVersionSchema.safeParse(version).success === false) {
|
|
validPackage = false;
|
|
issues.push({
|
|
code: "MODULE_PACKAGE_INVALID",
|
|
message: "package.json must declare a strict semantic version.",
|
|
moduleDirectory: directoryName,
|
|
path: "version",
|
|
});
|
|
}
|
|
if (packageType !== "module") {
|
|
validPackage = false;
|
|
issues.push({
|
|
code: "MODULE_PACKAGE_INVALID",
|
|
message: 'package.json must declare "type": "module".',
|
|
moduleDirectory: directoryName,
|
|
path: "type",
|
|
});
|
|
}
|
|
if (layer !== "module") {
|
|
validPackage = false;
|
|
issues.push({
|
|
code: "MODULE_PACKAGE_INVALID",
|
|
message: 'package.json must declare "ekklesia.layer": "module".',
|
|
moduleDirectory: directoryName,
|
|
path: "ekklesia.layer",
|
|
});
|
|
}
|
|
|
|
if (!validPackage || typeof name !== "string" || typeof version !== "string") {
|
|
return undefined;
|
|
}
|
|
return { name, version };
|
|
}
|
|
|
|
interface ResolvedModuleArtifacts {
|
|
readonly contributions: readonly ResolvedModuleContribution[];
|
|
readonly entrypoints: ResolvedModuleEntrypoints;
|
|
}
|
|
|
|
async function resolveEntrypoints(
|
|
moduleDirectory: string,
|
|
directoryName: string,
|
|
manifest: ModuleManifest,
|
|
issues: ModuleDiscoveryIssue[],
|
|
): Promise<ResolvedModuleArtifacts | undefined> {
|
|
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
|
const resolvedContributions = new Map<string, string>();
|
|
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
|
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
|
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
|
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
.map(([entrypointName, relativeEntrypoint]) => ({
|
|
entrypointName,
|
|
issuePath: `entrypoints.${entrypointName}`,
|
|
relativeEntrypoint,
|
|
}));
|
|
// A contribution artifact is resolved through the identical loop as every other compiled
|
|
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
|
|
// are one implementation, so a contribution can never quietly receive weaker checks than
|
|
// `entrypoints.ui` (ADR 0041).
|
|
const configuredContributions = manifest.contributions.map((contribution, index) => ({
|
|
entrypointName: `contribution:${contribution.id}`,
|
|
issuePath: `contributions.${index}.artifact`,
|
|
relativeEntrypoint: contribution.artifact,
|
|
}));
|
|
const configuredEntrypoints = [
|
|
...ordinaryConfiguredEntrypoints,
|
|
...(display === undefined
|
|
? []
|
|
: [
|
|
{
|
|
entrypointName: "display",
|
|
issuePath: "entrypoints.display.artifact",
|
|
relativeEntrypoint: display.artifact,
|
|
},
|
|
]),
|
|
...configuredContributions,
|
|
];
|
|
let entrypointCollision = false;
|
|
|
|
if (display !== undefined) {
|
|
const configuredDisplayPath = path.resolve(moduleDirectory, display.artifact);
|
|
for (const ordinary of ordinaryConfiguredEntrypoints) {
|
|
if (path.resolve(moduleDirectory, ordinary.relativeEntrypoint) === configuredDisplayPath) {
|
|
entrypointCollision = true;
|
|
issues.push({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: `display must not resolve to the same configured file as ${ordinary.entrypointName}.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: "entrypoints.display.artifact",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const {
|
|
entrypointName,
|
|
issuePath: entrypointIssuePath,
|
|
relativeEntrypoint,
|
|
} of configuredEntrypoints) {
|
|
if (
|
|
isCompiledEntrypoint(entrypointName) &&
|
|
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
|
) {
|
|
issues.push({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: entrypointIssuePath,
|
|
});
|
|
continue;
|
|
}
|
|
const configuredPath = path.resolve(moduleDirectory, relativeEntrypoint);
|
|
if (!pathIsInside(moduleDirectory, configuredPath)) {
|
|
issues.push({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: `${entrypointName} resolves outside its module directory.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: entrypointIssuePath,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const canonicalEntrypoint = await realpath(configuredPath);
|
|
if (!pathIsInside(canonicalModuleDirectory, canonicalEntrypoint)) {
|
|
issues.push({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: `${entrypointName} resolves through a link outside its module directory.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: entrypointIssuePath,
|
|
});
|
|
continue;
|
|
}
|
|
const entrypointStats = await stat(canonicalEntrypoint);
|
|
const hasExpectedKind = DIRECTORY_ENTRYPOINTS.has(entrypointName)
|
|
? entrypointStats.isDirectory()
|
|
: entrypointStats.isFile();
|
|
if (!hasExpectedKind) {
|
|
issues.push({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: DIRECTORY_ENTRYPOINTS.has(entrypointName)
|
|
? `${entrypointName} must resolve to a directory.`
|
|
: `${entrypointName} must resolve to a file.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: entrypointIssuePath,
|
|
});
|
|
continue;
|
|
}
|
|
if (entrypointName.startsWith("contribution:")) {
|
|
resolvedContributions.set(
|
|
entrypointName.slice("contribution:".length),
|
|
canonicalEntrypoint,
|
|
);
|
|
} else {
|
|
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
|
}
|
|
} catch (error) {
|
|
const code = errorCode(error);
|
|
issues.push({
|
|
code: code === "ENOENT" ? "MODULE_ENTRYPOINT_MISSING" : "MODULE_ENTRYPOINT_INVALID",
|
|
message:
|
|
code === "ENOENT"
|
|
? `${entrypointName} does not exist at ${relativeEntrypoint}.`
|
|
: `${entrypointName} could not be resolved at ${relativeEntrypoint}${
|
|
code ? ` (${code})` : ""
|
|
}.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: entrypointIssuePath,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (resolved.display !== undefined) {
|
|
for (const [entrypointName, canonicalEntrypoint] of Object.entries(resolved)) {
|
|
if (entrypointName !== "display" && canonicalEntrypoint === resolved.display) {
|
|
entrypointCollision = true;
|
|
issues.push({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: `display must not resolve through a link to the same file as ${entrypointName}.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: "entrypoints.display.artifact",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// A contribution artifact must be its own file. Two declarations resolving to one path — through
|
|
// a link, since the manifest already rejects two identical declared paths — would make one
|
|
// contribution silently serve another's bytes, and the loader's identity check would then reject
|
|
// whichever one lost.
|
|
const canonicalContributionPaths = new Map<string, string>();
|
|
for (const [contributionId, canonicalPath] of resolvedContributions) {
|
|
const claimedBy = canonicalContributionPaths.get(canonicalPath);
|
|
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
|
|
entrypointCollision = true;
|
|
issues.push({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: "contributions",
|
|
});
|
|
continue;
|
|
}
|
|
canonicalContributionPaths.set(canonicalPath, contributionId);
|
|
}
|
|
|
|
if (
|
|
!resolved.server ||
|
|
entrypointCollision ||
|
|
resolvedContributions.size !== manifest.contributions.length
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const contributions = manifest.contributions.map((contribution) =>
|
|
Object.freeze({
|
|
artifactPath: resolvedContributions.get(contribution.id) as string,
|
|
capability: contribution.capability,
|
|
capabilityRange: contribution.capabilityRange,
|
|
id: contribution.id,
|
|
version: contribution.version,
|
|
}),
|
|
);
|
|
|
|
const entrypoints: ResolvedModuleEntrypoints = {
|
|
server: resolved.server,
|
|
...(resolved.schema ? { schema: resolved.schema } : {}),
|
|
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
|
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
|
|
...(resolved.ui ? { ui: resolved.ui } : {}),
|
|
...(resolved.display && display
|
|
? {
|
|
display: {
|
|
artifactPath: resolved.display,
|
|
permission: display.permission,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
return { contributions: Object.freeze(contributions), entrypoints };
|
|
}
|
|
|
|
async function scanModuleDirectory(
|
|
modulesDirectory: string,
|
|
directoryName: string,
|
|
issues: ModuleDiscoveryIssue[],
|
|
): Promise<ModuleCandidate | undefined> {
|
|
const moduleDirectory = path.join(modulesDirectory, directoryName);
|
|
const packagePath = path.join(moduleDirectory, "package.json");
|
|
const manifestPath = path.join(moduleDirectory, MODULE_MANIFEST_FILENAME);
|
|
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
|
let canonicalManifestPath: string | undefined;
|
|
let packageInput: unknown;
|
|
let manifestInput: unknown;
|
|
let packageLoaded = false;
|
|
let manifestLoaded = false;
|
|
|
|
try {
|
|
const canonicalPackagePath = await realpath(packagePath);
|
|
if (!pathIsInside(canonicalModuleDirectory, canonicalPackagePath)) {
|
|
issues.push({
|
|
code: "MODULE_PACKAGE_INVALID",
|
|
message: "package.json must not resolve outside its module directory.",
|
|
moduleDirectory: directoryName,
|
|
});
|
|
} else {
|
|
packageInput = await readJson(canonicalPackagePath);
|
|
packageLoaded = true;
|
|
}
|
|
} catch (error) {
|
|
const code = errorCode(error);
|
|
issues.push({
|
|
code: code === "ENOENT" ? "MODULE_PACKAGE_MISSING" : "MODULE_PACKAGE_INVALID",
|
|
message:
|
|
code === "ENOENT"
|
|
? "package.json is missing."
|
|
: `package.json could not be read or parsed${code ? ` (${code})` : ""}.`,
|
|
moduleDirectory: directoryName,
|
|
});
|
|
}
|
|
|
|
try {
|
|
canonicalManifestPath = await realpath(manifestPath);
|
|
if (!pathIsInside(canonicalModuleDirectory, canonicalManifestPath)) {
|
|
issues.push({
|
|
code: "MODULE_MANIFEST_INVALID",
|
|
message: `${MODULE_MANIFEST_FILENAME} must not resolve outside its module directory.`,
|
|
moduleDirectory: directoryName,
|
|
});
|
|
} else {
|
|
manifestInput = await readJson(canonicalManifestPath);
|
|
manifestLoaded = true;
|
|
}
|
|
} catch (error) {
|
|
const code = errorCode(error);
|
|
issues.push({
|
|
code: code === "ENOENT" ? "MODULE_MANIFEST_MISSING" : "MODULE_MANIFEST_INVALID",
|
|
message:
|
|
code === "ENOENT"
|
|
? `${MODULE_MANIFEST_FILENAME} is missing.`
|
|
: `${MODULE_MANIFEST_FILENAME} could not be read or parsed${code ? ` (${code})` : ""}.`,
|
|
moduleDirectory: directoryName,
|
|
});
|
|
}
|
|
|
|
if (!packageLoaded || !manifestLoaded || !canonicalManifestPath) {
|
|
return undefined;
|
|
}
|
|
|
|
const packageData = validatePackageData(packageInput, directoryName, issues);
|
|
const parsedManifest = safeParseModuleManifest(manifestInput);
|
|
if (!parsedManifest.success) {
|
|
for (const validationIssue of parsedManifest.error.issues) {
|
|
issues.push({
|
|
code: "MODULE_MANIFEST_INVALID",
|
|
message: validationIssue.message,
|
|
moduleDirectory: directoryName,
|
|
path: issuePath(validationIssue.path),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!packageData || !parsedManifest.success) {
|
|
return undefined;
|
|
}
|
|
|
|
const manifest = parsedManifest.data;
|
|
if (packageData.version !== manifest.version) {
|
|
issues.push({
|
|
code: "MODULE_VERSION_MISMATCH",
|
|
message: `package.json version ${packageData.version} does not match manifest version ${manifest.version}.`,
|
|
moduleDirectory: directoryName,
|
|
moduleId: manifest.id,
|
|
path: "version",
|
|
});
|
|
}
|
|
|
|
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
|
if (!artifacts) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
contributions: artifacts.contributions,
|
|
directory: canonicalModuleDirectory,
|
|
directoryName,
|
|
entrypoints: artifacts.entrypoints,
|
|
manifest,
|
|
manifestPath: canonicalManifestPath,
|
|
packageName: packageData.name,
|
|
};
|
|
}
|
|
|
|
function addGraphEdge(
|
|
dependencyId: string,
|
|
dependentId: string,
|
|
dependents: Map<string, Set<string>>,
|
|
indegrees: Map<string, number>,
|
|
): void {
|
|
const targets = dependents.get(dependencyId);
|
|
if (!targets || targets.has(dependentId)) {
|
|
return;
|
|
}
|
|
targets.add(dependentId);
|
|
indegrees.set(dependentId, (indegrees.get(dependentId) ?? 0) + 1);
|
|
}
|
|
|
|
function nodeParticipatesInCycle(node: string, graph: ReadonlyMap<string, Set<string>>): boolean {
|
|
const visited = new Set<string>();
|
|
const pending = [...(graph.get(node) ?? [])];
|
|
while (pending.length > 0) {
|
|
const candidate = pending.pop();
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
if (candidate === node) {
|
|
return true;
|
|
}
|
|
if (visited.has(candidate)) {
|
|
continue;
|
|
}
|
|
visited.add(candidate);
|
|
pending.push(...(graph.get(candidate) ?? []));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function topologicalModuleIds(
|
|
modulesById: ReadonlyMap<string, ModuleCandidate>,
|
|
dependents: Map<string, Set<string>>,
|
|
indegrees: Map<string, number>,
|
|
issues: ModuleDiscoveryIssue[],
|
|
): string[] {
|
|
const ready = [...modulesById.keys()]
|
|
.filter((moduleId) => indegrees.get(moduleId) === 0)
|
|
.sort(compareText);
|
|
const ordered: string[] = [];
|
|
|
|
while (ready.length > 0) {
|
|
const moduleId = ready.shift();
|
|
if (!moduleId) {
|
|
break;
|
|
}
|
|
ordered.push(moduleId);
|
|
for (const dependentId of [...(dependents.get(moduleId) ?? [])].sort(compareText)) {
|
|
const nextIndegree = (indegrees.get(dependentId) ?? 0) - 1;
|
|
indegrees.set(dependentId, nextIndegree);
|
|
if (nextIndegree === 0) {
|
|
ready.push(dependentId);
|
|
ready.sort(compareText);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ordered.length !== modulesById.size) {
|
|
const cycleMembers = [...modulesById.keys()]
|
|
.filter((moduleId) => nodeParticipatesInCycle(moduleId, dependents))
|
|
.sort(compareText);
|
|
issues.push({
|
|
code: "MODULE_DEPENDENCY_CYCLE",
|
|
message: `Module dependency cycle contains: ${cycleMembers.join(", ")}.`,
|
|
moduleDirectory: cycleMembers.join(","),
|
|
});
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
export async function discoverModules(
|
|
options: ModuleDiscoveryOptions,
|
|
): Promise<readonly DiscoveredModule[]> {
|
|
const issues: ModuleDiscoveryIssue[] = [];
|
|
for (const [versionName, version] of [
|
|
["platformVersion", options.platformVersion],
|
|
["moduleSdkVersion", options.moduleSdkVersion],
|
|
] as const) {
|
|
if (!semanticVersionSchema.safeParse(version).success) {
|
|
issues.push({
|
|
code: "DISCOVERY_CONFIGURATION_INVALID",
|
|
message: `${versionName} must be a strict semantic version, received ${version}.`,
|
|
moduleDirectory: ".",
|
|
path: versionName,
|
|
});
|
|
}
|
|
}
|
|
if (options.modulesDirectoryRequired && !isUsableModulesDirectoryPath(options.modulesDirectory)) {
|
|
issues.push({
|
|
code: "MODULES_DIRECTORY_INVALID",
|
|
message: MODULES_DIRECTORY_INVALID_MESSAGE,
|
|
moduleDirectory: ".",
|
|
});
|
|
}
|
|
if (issues.length > 0) {
|
|
throw new ModuleDiscoveryError(issues);
|
|
}
|
|
|
|
let directoryEntries: Dirent[];
|
|
try {
|
|
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
|
|
return Object.freeze([]);
|
|
}
|
|
throw new ModuleDiscoveryError([
|
|
{
|
|
code: "MODULES_DIRECTORY_UNREADABLE",
|
|
message: `The configured modules directory could not be read${
|
|
errorCode(error) ? ` (${errorCode(error)})` : ""
|
|
}.`,
|
|
moduleDirectory: ".",
|
|
},
|
|
]);
|
|
}
|
|
|
|
const visibleEntries = directoryEntries
|
|
.filter((entry) => !entry.name.startsWith("."))
|
|
.sort((left, right) => compareText(left.name, right.name));
|
|
const directoryNames: string[] = [];
|
|
for (const entry of visibleEntries) {
|
|
if (entry.isSymbolicLink()) {
|
|
issues.push({
|
|
code: "MODULE_DIRECTORY_INVALID",
|
|
message: "Module directories must not be symbolic links.",
|
|
moduleDirectory: entry.name,
|
|
});
|
|
} else if (entry.isDirectory()) {
|
|
directoryNames.push(entry.name);
|
|
}
|
|
}
|
|
const candidates: ModuleCandidate[] = [];
|
|
for (const directoryName of directoryNames) {
|
|
const candidate = await scanModuleDirectory(options.modulesDirectory, directoryName, issues);
|
|
if (candidate) {
|
|
candidates.push(candidate);
|
|
}
|
|
}
|
|
|
|
const duplicateIds = new Set<string>();
|
|
const duplicatePackageNames = new Set<string>();
|
|
const firstById = new Map<string, ModuleCandidate>();
|
|
const firstByPackageName = new Map<string, ModuleCandidate>();
|
|
for (const candidate of candidates) {
|
|
const existingId = firstById.get(candidate.manifest.id);
|
|
if (existingId) {
|
|
duplicateIds.add(candidate.manifest.id);
|
|
issues.push({
|
|
code: "MODULE_ID_DUPLICATE",
|
|
message: `Module ID ${candidate.manifest.id} is also declared by ${existingId.directoryName}.`,
|
|
moduleDirectory: candidate.directoryName,
|
|
moduleId: candidate.manifest.id,
|
|
path: "id",
|
|
});
|
|
} else {
|
|
firstById.set(candidate.manifest.id, candidate);
|
|
}
|
|
|
|
const existingPackage = firstByPackageName.get(candidate.packageName);
|
|
if (existingPackage) {
|
|
duplicatePackageNames.add(candidate.packageName);
|
|
issues.push({
|
|
code: "MODULE_PACKAGE_NAME_DUPLICATE",
|
|
message: `Package name ${candidate.packageName} is also used by ${existingPackage.directoryName}.`,
|
|
moduleDirectory: candidate.directoryName,
|
|
moduleId: candidate.manifest.id,
|
|
path: "name",
|
|
});
|
|
} else {
|
|
firstByPackageName.set(candidate.packageName, candidate);
|
|
}
|
|
}
|
|
|
|
const modulesById = new Map(
|
|
candidates
|
|
.filter(
|
|
(candidate) =>
|
|
!duplicateIds.has(candidate.manifest.id) &&
|
|
!duplicatePackageNames.has(candidate.packageName),
|
|
)
|
|
.map((candidate) => [candidate.manifest.id, candidate] as const),
|
|
);
|
|
|
|
const capabilityProviders = new Map<string, CapabilityProvider>();
|
|
for (const candidate of modulesById.values()) {
|
|
if (!isVersionCompatible(options.platformVersion, candidate.manifest.compatibility.platform)) {
|
|
issues.push({
|
|
code: "MODULE_PLATFORM_INCOMPATIBLE",
|
|
message: `Platform ${options.platformVersion} does not satisfy ${candidate.manifest.compatibility.platform}.`,
|
|
moduleDirectory: candidate.directoryName,
|
|
moduleId: candidate.manifest.id,
|
|
path: "compatibility.platform",
|
|
});
|
|
}
|
|
if (
|
|
!isVersionCompatible(options.moduleSdkVersion, candidate.manifest.compatibility.moduleSdk)
|
|
) {
|
|
issues.push({
|
|
code: "MODULE_SDK_INCOMPATIBLE",
|
|
message: `Module SDK ${options.moduleSdkVersion} does not satisfy ${candidate.manifest.compatibility.moduleSdk}.`,
|
|
moduleDirectory: candidate.directoryName,
|
|
moduleId: candidate.manifest.id,
|
|
path: "compatibility.moduleSdk",
|
|
});
|
|
}
|
|
|
|
for (const declaration of candidate.manifest.capabilities) {
|
|
capabilityProviders.set(declaration.id, { candidate, declaration });
|
|
}
|
|
}
|
|
|
|
const dependents = new Map<string, Set<string>>();
|
|
const indegrees = new Map<string, number>();
|
|
for (const moduleId of modulesById.keys()) {
|
|
dependents.set(moduleId, new Set());
|
|
indegrees.set(moduleId, 0);
|
|
}
|
|
|
|
for (const candidate of modulesById.values()) {
|
|
for (const dependencyKind of ["required", "optional"] as const) {
|
|
for (const [dependencyId, versionRange] of Object.entries(
|
|
candidate.manifest.dependencies.modules[dependencyKind],
|
|
)) {
|
|
const dependency = modulesById.get(dependencyId);
|
|
if (!dependency) {
|
|
if (dependencyKind === "required") {
|
|
issues.push({
|
|
code: "MODULE_DEPENDENCY_MISSING",
|
|
message: `Required module ${dependencyId} is not installed.`,
|
|
moduleDirectory: candidate.directoryName,
|
|
moduleId: candidate.manifest.id,
|
|
path: `dependencies.modules.${dependencyKind}.${dependencyId}`,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (!isVersionCompatible(dependency.manifest.version, versionRange)) {
|
|
issues.push({
|
|
code: "MODULE_DEPENDENCY_INCOMPATIBLE",
|
|
message: `${dependencyId} ${dependency.manifest.version} does not satisfy ${versionRange}.`,
|
|
moduleDirectory: candidate.directoryName,
|
|
moduleId: candidate.manifest.id,
|
|
path: `dependencies.modules.${dependencyKind}.${dependencyId}`,
|
|
});
|
|
}
|
|
addGraphEdge(dependencyId, candidate.manifest.id, dependents, indegrees);
|
|
}
|
|
}
|
|
|
|
for (const dependencyKind of ["required", "optional"] as const) {
|
|
for (const [capability, versionRange] of Object.entries(
|
|
candidate.manifest.dependencies.capabilities[dependencyKind],
|
|
)) {
|
|
const provider = capabilityProviders.get(capability);
|
|
if (!provider) {
|
|
if (dependencyKind === "required") {
|
|
issues.push({
|
|
code: "MODULE_CAPABILITY_MISSING",
|
|
message: `Required capability ${capability} is not installed.`,
|
|
moduleDirectory: candidate.directoryName,
|
|
moduleId: candidate.manifest.id,
|
|
path: `dependencies.capabilities.${dependencyKind}.${capability}`,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (!isVersionCompatible(provider.declaration.version, versionRange)) {
|
|
issues.push({
|
|
code: "MODULE_CAPABILITY_INCOMPATIBLE",
|
|
message: `Capability ${capability} ${provider.declaration.version} does not satisfy ${versionRange}.`,
|
|
moduleDirectory: candidate.directoryName,
|
|
moduleId: candidate.manifest.id,
|
|
path: `dependencies.capabilities.${dependencyKind}.${capability}`,
|
|
});
|
|
}
|
|
addGraphEdge(provider.candidate.manifest.id, candidate.manifest.id, dependents, indegrees);
|
|
}
|
|
}
|
|
}
|
|
|
|
const orderedIds = topologicalModuleIds(modulesById, dependents, indegrees, issues);
|
|
if (issues.length > 0) {
|
|
throw new ModuleDiscoveryError(issues);
|
|
}
|
|
|
|
return Object.freeze(
|
|
orderedIds.map((moduleId) => {
|
|
const candidate = modulesById.get(moduleId);
|
|
if (!candidate) {
|
|
throw new Error(`Internal discovery error: ${moduleId} was not indexed.`);
|
|
}
|
|
const { directoryName: _directoryName, ...discoveredModule } = candidate;
|
|
return freezeRecursively(discoveredModule);
|
|
}),
|
|
);
|
|
}
|