import { cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js"; import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js"; import { ModuleDiscoveryError } from "../src/modules/index.js"; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const templateModule = path.join(repositoryRoot, "templates", "module"); const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), ); }); async function externalModulesDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-")); temporaryDirectories.push(directory); return directory; } /** Installs the module template's migration-bearing package files as module `example`. Migration * discovery imports nothing, so the template's manifest, package metadata, and migrations are all * it reads; the compiled artifacts discovery checks for are copied too when they exist. */ async function installTemplate(modulesDirectory: string): Promise { const target = path.join(modulesDirectory, "example"); for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) { await cp(path.join(templateModule, entry), path.join(target, entry), { recursive: true }); } } describe("operator commands select the modules directory through host configuration", () => { it("defaults to the repository's own modules directory", () => { expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({ modulesDirectory: "/opt/ekklesia/modules", modulesDirectoryRequired: false, }); }); it("honours EKKLESIA_MODULES_DIR", () => { expect( resolveCommandModulesDirectory( { EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }, "/opt/ekklesia", ), ).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true }); }); it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => { expect(() => resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"), ).toThrow(ModuleDiscoveryError); try { resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"); } catch (error) { expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f"); } }); it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => { const modulesDirectory = await externalModulesDirectory(); await installTemplate(modulesDirectory); const aggregate = await discoverRepositoryMigrations(repositoryRoot, { EKKLESIA_MODULES_DIR: modulesDirectory, }); const sources = aggregate.modules.map((module) => module.moduleId); expect(sources[0]).toBe("core"); expect(sources).toContain("example"); expect( aggregate.migrations.some((migration) => migration.identity === "example:0000_initial"), ).toBe(true); const coreTags = (await readdir(path.join(repositoryRoot, "core", "drizzle"))).filter((name) => name.endsWith(".sql"), ); expect(aggregate.modules[0]?.migrations).toHaveLength(coreTags.length); }); it("fails closed when the configured directory does not exist", async () => { const parent = await externalModulesDirectory(); await expect( discoverRepositoryMigrations(repositoryRoot, { EKKLESIA_MODULES_DIR: path.join(parent, "absent"), }), ).rejects.toBeInstanceOf(ModuleDiscoveryError); }); }); describe("no source chooses a modules directory except the resolver", () => { async function sourceFiles(directory: string): Promise { const entries = await readdir(directory, { recursive: true, withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) .map((entry) => path.join(entry.parentPath, entry.name)); } it("never joins `modules` onto a root anywhere else in Core or the API", async () => { const files = [ ...(await sourceFiles(path.join(repositoryRoot, "core", "src"))), ...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))), ]; const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts"); for (const file of files) { if (file === resolver) { continue; } const text = await readFile(file, "utf8"); expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u); } }); });