feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
A separately versioned module is now distributed as one deterministic .tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by Core before anything is installed. This slice implements the portable `any` target, with the module template as the end-to-end proof. ADR 0049 is accepted. @ekklesia/contracts: the Module Package v1 contract. The strict ekklesia.package.json schema (packageFormat 1, identity, target, runtime, provenance, incorporated Ekklesia packages, strict transitional facts, an exact byte-wise-sorted file list with size and SHA-256), target names, and the generated runtime package.json. @ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir> --target any --out <dir> --source-revision <commit>`. - Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle included; only Node built-ins remain imports. Any warning, unresolved import, external dependency, or native or platform-specific package fails. - Copies browser artifacts as built, after proving them current, and the migrations; generates package.json and ekklesia.package.json; never ships src, tests, declarations, source maps, or workspace ranges. - THIRD_PARTY_NOTICES: every third-party package traced from the bundles' own inputs, credited once with its declared licence and every licence, copying, copyright, and notice file it ships. A missing licence fails; a missing text fails unless its standard text is complete without copyright lines (Apache-2.0, vendored, needed by drizzle-orm). - Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime 0, 0755/0644, no links. Byte-identical across runs and machines. - Writes only the archive and its .sha256; never deploys, links, or installs, so the workspace's install state is untouched. Core: verifyModulePackage and `verify-module-package.js <archive> [--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe or escaping paths, and a second top-level directory; checks metadata, every file, identity, target, Node.js range, SDK, and the transitional Drizzle agreement; runs the platform's module and migration discovery; loads the backend once in a forked process with an empty environment; only then extracts, with normalized modes, into a new directory. Errors are sanitized. @ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs and whether the built file is current. The nested stylesheet build now works from the module directory, so artifacts no longer depend on the caller's working directory. The split deploy smoke builds the package with the canonical packager (scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm deploy packaging), verifies it with the same generic image, installs the verified tree, removes it, and reinstalls the exact same archive. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45d6506c10
commit
35ef31df9f
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePackModuleArguments } from "../src/cli.js";
|
||||
|
||||
const revision = "86ae907f59458508e3277666cdd214f5bd7c1078";
|
||||
|
||||
describe("ekklesia-pack-module arguments", () => {
|
||||
it("takes the module, the target, the output directory, and the source revision", () => {
|
||||
expect(
|
||||
parsePackModuleArguments([
|
||||
"--module",
|
||||
"templates/module",
|
||||
"--target",
|
||||
"any",
|
||||
"--out",
|
||||
"dist/packages",
|
||||
"--source-revision",
|
||||
revision,
|
||||
]),
|
||||
).toEqual({
|
||||
moduleDirectory: "templates/module",
|
||||
outDirectory: "dist/packages",
|
||||
sourceRevision: revision,
|
||||
target: "any",
|
||||
});
|
||||
});
|
||||
|
||||
it("records a repository only when one is given", () => {
|
||||
expect(
|
||||
parsePackModuleArguments([
|
||||
"--module",
|
||||
".",
|
||||
"--target",
|
||||
"any",
|
||||
"--out",
|
||||
"out",
|
||||
"--source-revision",
|
||||
revision,
|
||||
"--repository",
|
||||
"https://example.org/acme/example.git",
|
||||
]).repository,
|
||||
).toBe("https://example.org/acme/example.git");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no arguments", []],
|
||||
["a missing target", ["--module", ".", "--out", "o", "--source-revision", revision]],
|
||||
["a missing revision", ["--module", ".", "--target", "any", "--out", "o"]],
|
||||
[
|
||||
"an unknown option",
|
||||
["--module", ".", "--target", "any", "--out", "o", "--source-revision", revision, "--legacy"],
|
||||
],
|
||||
[
|
||||
"a repeated option",
|
||||
[
|
||||
"--module",
|
||||
".",
|
||||
"--module",
|
||||
"..",
|
||||
"--target",
|
||||
"any",
|
||||
"--out",
|
||||
"o",
|
||||
"--source-revision",
|
||||
revision,
|
||||
],
|
||||
],
|
||||
[
|
||||
"a stray positional argument",
|
||||
["module", "--target", "any", "--out", "o", "--source-revision", revision],
|
||||
],
|
||||
])("refuses %s", (_label, argv) => {
|
||||
expect(() => parsePackModuleArguments(argv)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isBuiltin } from "node:module";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type ModulePackErrorCode, ModulePackError, packModule } from "../src/index.js";
|
||||
import {
|
||||
cleanupFixtures,
|
||||
type FixtureModuleOptions,
|
||||
fixtureModule,
|
||||
SOURCE_REVISION,
|
||||
scratchDirectory,
|
||||
} from "./support/fixture-module.js";
|
||||
import { fileEntries, readTarGz } from "./support/tar.js";
|
||||
|
||||
afterEach(cleanupFixtures);
|
||||
|
||||
const MIT = "MIT License\n\nCopyright (c) 2026 Example Author\n\nPermission is hereby granted.\n";
|
||||
|
||||
async function pack(options: FixtureModuleOptions = {}, overrides: Record<string, unknown> = {}) {
|
||||
const moduleDirectory = await fixtureModule(options);
|
||||
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
|
||||
const result = await packModule({
|
||||
moduleDirectory,
|
||||
nodeRange: ">=24.11.0 <25",
|
||||
outDirectory,
|
||||
sourceRevision: SOURCE_REVISION,
|
||||
target: "any",
|
||||
...overrides,
|
||||
});
|
||||
return { archive: await readFile(result.archivePath), moduleDirectory, outDirectory, result };
|
||||
}
|
||||
|
||||
async function packRejection(
|
||||
options: FixtureModuleOptions,
|
||||
code: ModulePackErrorCode,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<ModulePackError> {
|
||||
try {
|
||||
await pack(options, overrides);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModulePackError);
|
||||
expect((error as ModulePackError).code).toBe(code);
|
||||
return error as ModulePackError;
|
||||
}
|
||||
throw new Error(`Expected ${code}.`);
|
||||
}
|
||||
|
||||
describe("packModule: what a package contains (ADR 0049)", () => {
|
||||
it("produces one archive and its sha256sum line, under the recommended name", async () => {
|
||||
const { archive, outDirectory, result } = await pack();
|
||||
const digest = createHash("sha256").update(archive).digest("hex");
|
||||
expect(path.basename(result.archivePath)).toBe("example-0.1.0-any.tar.gz");
|
||||
expect(result.sha256).toBe(digest);
|
||||
expect(await readFile(path.join(outDirectory, "example-0.1.0-any.tar.gz.sha256"), "utf8")).toBe(
|
||||
`${digest} example-0.1.0-any.tar.gz\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it("holds exactly the runtime payload, under one top-level directory named with the module id", async () => {
|
||||
const { archive } = await pack({ withSchema: true });
|
||||
const files = [...fileEntries(archive).keys()].sort();
|
||||
expect(files).toEqual([
|
||||
"example/THIRD_PARTY_NOTICES",
|
||||
expect.stringMatching(/^example\/dist\/chunks\/chunk-[A-Z0-9]+\.js$/u),
|
||||
"example/dist/schema/index.js",
|
||||
"example/dist/server/index.js",
|
||||
"example/drizzle/0000_initial.sql",
|
||||
"example/drizzle/meta/0000_snapshot.json",
|
||||
"example/drizzle/meta/_ekklesia.json",
|
||||
"example/drizzle/meta/_journal.json",
|
||||
"example/ekklesia.module.json",
|
||||
"example/ekklesia.package.json",
|
||||
"example/package.json",
|
||||
]);
|
||||
for (const file of files) {
|
||||
expect(file).not.toMatch(/\/src\/|\/tests\/|\.d\.ts$|\.map$|README|node_modules/u);
|
||||
}
|
||||
expect(new Set(readTarGz(archive).map((entry) => entry.path.split("/")[0]))).toEqual(
|
||||
new Set(["example"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("generates the runtime package.json instead of copying the development one", async () => {
|
||||
const { archive } = await pack();
|
||||
const runtimePackage = fileEntries(archive).get("example/package.json")?.toString("utf8");
|
||||
expect(JSON.parse(runtimePackage ?? "")).toEqual({
|
||||
name: "@test/module-example",
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
});
|
||||
for (const [file, data] of fileEntries(archive)) {
|
||||
if (file.endsWith(".json")) {
|
||||
expect(data.toString("utf8"), file).not.toContain("workspace:");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("copies the manifest byte for byte", async () => {
|
||||
const { archive, moduleDirectory } = await pack();
|
||||
expect(fileEntries(archive).get("example/ekklesia.module.json")).toEqual(
|
||||
await readFile(path.join(moduleDirectory, "ekklesia.module.json")),
|
||||
);
|
||||
});
|
||||
|
||||
it("describes the package completely in ekklesia.package.json, never hashing itself", async () => {
|
||||
const { archive } = await pack({ withSchema: true });
|
||||
const files = fileEntries(archive);
|
||||
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
|
||||
expect(metadata).toMatchObject({
|
||||
packageFormat: 1,
|
||||
module: { id: "example", version: "0.1.0" },
|
||||
target: "any",
|
||||
runtime: { node: ">=24.11.0 <25" },
|
||||
provenance: {
|
||||
revision: SOURCE_REVISION,
|
||||
packager: { name: "@ekklesia/module-pack", version: expect.any(String) },
|
||||
},
|
||||
incorporates: {
|
||||
"@ekklesia/contracts": expect.any(String),
|
||||
"@ekklesia/module-sdk": expect.any(String),
|
||||
},
|
||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||
});
|
||||
const listed = metadata.files.map((file: { path: string }) => `example/${file.path}`);
|
||||
expect(listed).not.toContain("example/ekklesia.package.json");
|
||||
expect(new Set(listed)).toEqual(
|
||||
new Set([...files.keys()].filter((file) => file !== "example/ekklesia.package.json")),
|
||||
);
|
||||
for (const file of metadata.files as { path: string; size: number; sha256: string }[]) {
|
||||
const data = files.get(`example/${file.path}`);
|
||||
expect(data?.length).toBe(file.size);
|
||||
expect(
|
||||
createHash("sha256")
|
||||
.update(data ?? "")
|
||||
.digest("hex"),
|
||||
).toBe(file.sha256);
|
||||
}
|
||||
});
|
||||
|
||||
it("records only the provenance it was given, and a repository only when asked", async () => {
|
||||
const { archive } = await pack({}, { repository: "https://example.org/acme/example.git" });
|
||||
const metadata = JSON.parse(
|
||||
fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "",
|
||||
);
|
||||
expect(metadata.provenance.repository).toBe("https://example.org/acme/example.git");
|
||||
});
|
||||
|
||||
it("omits transitional database facts from a module that bundles no Drizzle", async () => {
|
||||
const { archive } = await pack({
|
||||
serverSource: 'export default { id: "example", version: "0.1.0", register() {} };\n',
|
||||
});
|
||||
const metadata = JSON.parse(
|
||||
fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "",
|
||||
);
|
||||
expect(metadata.transitional).toBeUndefined();
|
||||
expect(metadata.incorporates).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("packModule: a self-contained backend", () => {
|
||||
it("bundles the SDK, contracts, and Drizzle, leaving only Node built-ins to import", async () => {
|
||||
const { archive } = await pack({ withSchema: true });
|
||||
for (const [file, data] of fileEntries(archive)) {
|
||||
if (!file.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
const code = data.toString("utf8");
|
||||
const specifiers = [
|
||||
...code.matchAll(/(?:^|\n)\s*(?:import|export)\s[^;]*?from\s*"([^"]+)"/gu),
|
||||
...code.matchAll(/(?:^|\n)\s*import\s*"([^"]+)"/gu),
|
||||
...code.matchAll(/\bimport\(\s*"([^"]+)"\s*\)/gu),
|
||||
].map((match) => match[1] ?? "");
|
||||
for (const specifier of specifiers) {
|
||||
expect(specifier.startsWith(".") || isBuiltin(specifier), `${file}: ${specifier}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a dependency the bundle cannot resolve rather than leaving it external", async () => {
|
||||
await packRejection(
|
||||
{
|
||||
serverSource:
|
||||
'import pad from "ekklesia-not-installed";\nexport default { id: "example", version: "0.1.0", register() { pad(); } };\n',
|
||||
},
|
||||
"MODULE_PACK_BUNDLE_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a package that declares an operating system",
|
||||
{
|
||||
name: "fake-native",
|
||||
packageJson: { os: ["linux"], cpu: ["x64"] },
|
||||
files: { "index.js": "export default 1;\n" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"a package built with node-gyp",
|
||||
{
|
||||
name: "fake-gyp",
|
||||
packageJson: { gypfile: true },
|
||||
files: { "index.js": "export default 1;\n", "binding.gyp": "{}" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"a package that ships a compiled addon",
|
||||
{
|
||||
name: "fake-addon",
|
||||
files: {
|
||||
"index.js": "export default 1;\n",
|
||||
"build/Release/addon.node": new Uint8Array([0x7f, 0x45, 0x4c, 0x46]),
|
||||
},
|
||||
},
|
||||
],
|
||||
])("refuses %s in an any-target package", async (_label, fake) => {
|
||||
await packRejection(
|
||||
{
|
||||
fakePackages: [fake],
|
||||
serverSource: `import value from "${fake.name}";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n`,
|
||||
},
|
||||
"MODULE_PACK_NATIVE_DEPENDENCY",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a package whose installed dependency is platform-specific", async () => {
|
||||
await packRejection(
|
||||
{
|
||||
fakePackages: [
|
||||
{
|
||||
name: "fake-wrapper",
|
||||
packageJson: { optionalDependencies: { "fake-wrapper-linux-x64": "1.0.0" } },
|
||||
files: { "index.js": "export default 1;\n", LICENSE: MIT },
|
||||
},
|
||||
{
|
||||
name: "fake-wrapper-linux-x64",
|
||||
packageJson: { os: ["linux"], cpu: ["x64"] },
|
||||
files: { "index.js": "export default 2;\n" },
|
||||
},
|
||||
],
|
||||
serverSource:
|
||||
'import value from "fake-wrapper";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n',
|
||||
},
|
||||
"MODULE_PACK_NATIVE_DEPENDENCY",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses any target but any in this release", async () => {
|
||||
await packRejection({}, "MODULE_PACK_TARGET_UNSUPPORTED", { target: "linux-x64-glibc" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("packModule: identity and inputs", () => {
|
||||
it("refuses a development package.json whose version differs from the manifest", async () => {
|
||||
await packRejection(
|
||||
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
||||
"MODULE_PACK_IDENTITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a module whose backend was never built", async () => {
|
||||
await packRejection(
|
||||
{
|
||||
manifest: (manifest) => ({
|
||||
...manifest,
|
||||
entrypoints: { server: "./dist/missing/index.js" },
|
||||
}),
|
||||
},
|
||||
"MODULE_PACK_ENTRYPOINT_MISSING",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an abbreviated or missing source revision", async () => {
|
||||
await packRejection({}, "MODULE_PACK_REVISION_INVALID", { sourceRevision: "86ae907" });
|
||||
});
|
||||
|
||||
it("refuses an incorporated SDK outside the manifest's own range", async () => {
|
||||
await packRejection(
|
||||
{
|
||||
manifest: (manifest) => ({
|
||||
...manifest,
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^9.0.0" },
|
||||
}),
|
||||
},
|
||||
"MODULE_PACK_SDK_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packModule: THIRD_PARTY_NOTICES", () => {
|
||||
const usesFake = (name: string) =>
|
||||
`import value from "${name}";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n`;
|
||||
|
||||
it("credits every bundled third-party package once, with its licence and notice text", async () => {
|
||||
const { archive } = await pack({
|
||||
fakePackages: [
|
||||
{
|
||||
name: "fake-mit",
|
||||
version: "2.3.4",
|
||||
files: {
|
||||
"index.js": 'import other from "fake-apache-notice";\nexport default other;\n',
|
||||
LICENSE: MIT,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fake-apache-notice",
|
||||
version: "1.0.0",
|
||||
packageJson: { license: "Apache-2.0" },
|
||||
files: {
|
||||
"index.js": "export default 1;\n",
|
||||
"LICENSE.txt": "Apache License text shipped by the package.\n",
|
||||
NOTICE: "Fake Apache Notice\nCopyright 2026 The Fake Foundation\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
serverSource: usesFake("fake-mit"),
|
||||
});
|
||||
const notices = fileEntries(archive).get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
|
||||
expect(notices).toContain("fake-mit 2.3.4");
|
||||
expect(notices).toContain("Copyright (c) 2026 Example Author");
|
||||
expect(notices).toContain("fake-apache-notice 1.0.0");
|
||||
expect(notices).toContain("Copyright 2026 The Fake Foundation");
|
||||
expect(notices.match(/^fake-mit 2\.3\.4$/gmu)).toHaveLength(1);
|
||||
// Only what the bundle actually contains is credited: this backend uses no Drizzle code.
|
||||
expect(notices).not.toContain("drizzle-orm");
|
||||
// The Ekklesia platform's own packages are credited in ekklesia.package.json, not here.
|
||||
expect(notices).not.toMatch(/^@ekklesia\//mu);
|
||||
});
|
||||
|
||||
it("supplies the standard Apache-2.0 text only for a package that ships none, and says so", async () => {
|
||||
const { archive } = await pack({ withSchema: true });
|
||||
const notices = fileEntries(archive).get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
|
||||
const drizzle = notices.slice(notices.indexOf("drizzle-orm 0.45.2"));
|
||||
expect(drizzle).toContain("License: Apache-2.0");
|
||||
expect(drizzle).toContain("ships no licence file");
|
||||
expect(drizzle).toContain("TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION");
|
||||
});
|
||||
|
||||
it("is byte-identical across packages of the same inputs", async () => {
|
||||
const options = { withSchema: true };
|
||||
const first = await pack(options);
|
||||
const second = await pack(options);
|
||||
expect(fileEntries(first.archive).get("example/THIRD_PARTY_NOTICES")).toEqual(
|
||||
fileEntries(second.archive).get("example/THIRD_PARTY_NOTICES"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a bundled package that declares no licence", async () => {
|
||||
await packRejection(
|
||||
{
|
||||
fakePackages: [
|
||||
{
|
||||
name: "fake-unlicensed",
|
||||
packageJson: { license: undefined },
|
||||
files: { "index.js": "export default 1;\n" },
|
||||
},
|
||||
],
|
||||
serverSource: usesFake("fake-unlicensed"),
|
||||
},
|
||||
"MODULE_PACK_LICENCE_UNKNOWN",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an MIT package that ships no licence text, because only it holds the copyright line", async () => {
|
||||
await packRejection(
|
||||
{
|
||||
fakePackages: [{ name: "fake-textless", files: { "index.js": "export default 1;\n" } }],
|
||||
serverSource: usesFake("fake-textless"),
|
||||
},
|
||||
"MODULE_PACK_LICENCE_TEXT_MISSING",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packModule: deterministic archives", () => {
|
||||
it("produces byte-identical archives and digests for identical inputs", async () => {
|
||||
const moduleDirectory = await fixtureModule({ withSchema: true });
|
||||
const runs = [];
|
||||
for (let run = 0; run < 2; run += 1) {
|
||||
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
|
||||
const result = await packModule({
|
||||
moduleDirectory,
|
||||
nodeRange: ">=24.11.0 <25",
|
||||
outDirectory,
|
||||
sourceRevision: SOURCE_REVISION,
|
||||
target: "any",
|
||||
});
|
||||
runs.push({ bytes: await readFile(result.archivePath), sha256: result.sha256 });
|
||||
}
|
||||
expect(runs[0]?.sha256).toBe(runs[1]?.sha256);
|
||||
expect(runs[0]?.bytes.equals(runs[1]?.bytes ?? Buffer.alloc(0))).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes every entry and the gzip header, and contains no links", async () => {
|
||||
const { archive } = await pack({ withSchema: true });
|
||||
// gzip: no file name flag, no modification time.
|
||||
expect(archive[3]).toBe(0);
|
||||
expect(archive.readUInt32LE(4)).toBe(0);
|
||||
|
||||
const entries = readTarGz(archive);
|
||||
const paths = entries.map((entry) => entry.path);
|
||||
expect(paths).toEqual(
|
||||
[...paths].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
expect(["0", "5"], entry.path).toContain(entry.typeflag);
|
||||
expect(entry.mode, entry.path).toBe(entry.typeflag === "5" ? 0o755 : 0o644);
|
||||
expect([entry.uid, entry.gid, entry.mtime]).toEqual([0, 0, 0]);
|
||||
expect([entry.uname, entry.gname]).toEqual(["", ""]);
|
||||
expect(entry.magic).toBe("ustar/00");
|
||||
}
|
||||
// Every directory precedes what it contains.
|
||||
for (const entry of entries) {
|
||||
const parent = entry.path.split("/").slice(0, -1).join("/");
|
||||
if (parent !== "") {
|
||||
expect(paths.indexOf(parent), entry.path).toBeLessThan(paths.indexOf(entry.path));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("refers to no absolute path of the build machine", async () => {
|
||||
const { archive, moduleDirectory } = await pack({ withSchema: true });
|
||||
const root = path.dirname(path.dirname(moduleDirectory));
|
||||
for (const [file, data] of fileEntries(archive)) {
|
||||
expect(data.toString("utf8"), file).not.toContain(moduleDirectory);
|
||||
expect(data.toString("utf8"), file).not.toContain(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Generated module fixtures for the packager's tests: a built module directory in a temporary
|
||||
* location, whose `node_modules` links to the real Module SDK and Drizzle this package depends on.
|
||||
* Everything a development module directory holds is there, including what must never ship
|
||||
* (`src/`, `tests/`, declarations, source maps, `workspace:` ranges), so a test can prove the
|
||||
* packager leaves it out.
|
||||
*/
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
export interface FakePackage {
|
||||
readonly name: string;
|
||||
readonly version?: string;
|
||||
readonly packageJson?: Record<string, unknown>;
|
||||
/** Files relative to the fake package's root. */
|
||||
readonly files?: Record<string, string | Uint8Array>;
|
||||
}
|
||||
|
||||
export interface FixtureModuleOptions {
|
||||
readonly id?: string;
|
||||
readonly version?: string;
|
||||
readonly serverSource?: string;
|
||||
readonly withSchema?: boolean;
|
||||
readonly fakePackages?: readonly FakePackage[];
|
||||
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
export async function cleanupFixtures(): Promise<void> {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
}
|
||||
|
||||
export async function scratchDirectory(prefix = "ekklesia-pack-"): Promise<string> {
|
||||
const directory = await realpath(await mkdtemp(path.join(tmpdir(), prefix)));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function write(file: string, contents: string | Uint8Array): Promise<void> {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, contents);
|
||||
}
|
||||
|
||||
const defaultServer = (
|
||||
id: string,
|
||||
version: string,
|
||||
) => `import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||
export default defineModuleRuntime({ id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} });
|
||||
`;
|
||||
|
||||
const schemaSource = (
|
||||
id: string,
|
||||
) => `import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
|
||||
import { text, uuid } from "drizzle-orm/pg-core";
|
||||
export const records = createModuleTable(${JSON.stringify(id)})("records", {
|
||||
id: uuid("id").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
export default defineModuleSchema({ moduleId: ${JSON.stringify(id)}, tables: { records } });
|
||||
`;
|
||||
|
||||
export async function fixtureModule(options: FixtureModuleOptions = {}): Promise<string> {
|
||||
const id = options.id ?? "example";
|
||||
const version = options.version ?? "0.1.0";
|
||||
const root = await scratchDirectory("ekklesia-pack-fixture-");
|
||||
const moduleDirectory = path.join(root, id);
|
||||
const tableName = `${id.replaceAll("-", "_")}_records`;
|
||||
|
||||
const baseManifest: Record<string, unknown> = {
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
capabilities: [],
|
||||
permissions: [],
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(options.withSchema ? { schema: "./dist/schema/index.js" } : {}),
|
||||
migrations: "./drizzle",
|
||||
},
|
||||
};
|
||||
const manifest = options.manifest ? options.manifest(baseManifest) : baseManifest;
|
||||
|
||||
await write(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
await write(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: `@test/module-${id}`,
|
||||
version,
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: { build: "tsc" },
|
||||
dependencies: { "@ekklesia/module-sdk": "workspace:*", "drizzle-orm": "0.45.2" },
|
||||
devDependencies: { typescript: "7.0.2" },
|
||||
exports: { ".": "./dist/server/index.js" },
|
||||
ekklesia: { layer: "module" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await write(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
options.serverSource ?? defaultServer(id, version),
|
||||
);
|
||||
await write(path.join(moduleDirectory, "dist/server/index.js.map"), '{"version":3}\n');
|
||||
await write(path.join(moduleDirectory, "dist/server/index.d.ts"), "export {};\n");
|
||||
if (options.withSchema) {
|
||||
await write(path.join(moduleDirectory, "dist/schema/index.js"), schemaSource(id));
|
||||
}
|
||||
await write(path.join(moduleDirectory, "src/server/index.ts"), "export {};\n");
|
||||
await write(path.join(moduleDirectory, "tests/server.test.ts"), "export {};\n");
|
||||
await write(path.join(moduleDirectory, "README.md"), "# Development notes\n");
|
||||
|
||||
await write(
|
||||
path.join(moduleDirectory, "drizzle/0000_initial.sql"),
|
||||
`CREATE TABLE "${tableName}" ("id" uuid PRIMARY KEY NOT NULL, "value" text NOT NULL);\n`,
|
||||
);
|
||||
await write(
|
||||
path.join(moduleDirectory, "drizzle/meta/_journal.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{ idx: 0, version: "7", when: 1_700_000_000_000, tag: "0000_initial", breakpoints: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await write(
|
||||
path.join(moduleDirectory, "drizzle/meta/_ekklesia.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
moduleId: id,
|
||||
entries: [{ tag: "0000_initial", destructive: { mode: "none" } }],
|
||||
}),
|
||||
);
|
||||
await write(
|
||||
path.join(moduleDirectory, "drizzle/meta/0000_snapshot.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
id: "10000000-0000-4000-8000-000000000001",
|
||||
prevId: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
);
|
||||
|
||||
// The module's dependencies, as a workspace links them: the real packages this package depends on.
|
||||
const modules = path.join(moduleDirectory, "node_modules");
|
||||
await mkdir(path.join(modules, "@ekklesia"), { recursive: true });
|
||||
await symlink(
|
||||
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
|
||||
path.join(modules, "@ekklesia", "module-sdk"),
|
||||
);
|
||||
await symlink(
|
||||
await realpath(path.join(packageRoot, "node_modules", "drizzle-orm")),
|
||||
path.join(modules, "drizzle-orm"),
|
||||
);
|
||||
for (const fake of options.fakePackages ?? []) {
|
||||
const fakeRoot = path.join(modules, ...fake.name.split("/"));
|
||||
await write(
|
||||
path.join(fakeRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: fake.name,
|
||||
version: fake.version ?? "1.0.0",
|
||||
type: "module",
|
||||
main: "index.js",
|
||||
license: "MIT",
|
||||
...fake.packageJson,
|
||||
}),
|
||||
);
|
||||
for (const [relative, contents] of Object.entries(fake.files ?? {})) {
|
||||
await write(path.join(fakeRoot, relative), contents);
|
||||
}
|
||||
}
|
||||
return moduleDirectory;
|
||||
}
|
||||
|
||||
export const SOURCE_REVISION = "86ae907f59458508e3277666cdd214f5bd7c1078";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
|
||||
/** A test-side view of every ustar header field the determinism rules constrain (ADR 0049). */
|
||||
export interface TarEntry {
|
||||
readonly path: string;
|
||||
readonly typeflag: string;
|
||||
readonly mode: number;
|
||||
readonly uid: number;
|
||||
readonly gid: number;
|
||||
readonly uname: string;
|
||||
readonly gname: string;
|
||||
readonly mtime: number;
|
||||
readonly magic: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
function text(block: Buffer, offset: number, length: number): string {
|
||||
const raw = block.subarray(offset, offset + length);
|
||||
const end = raw.indexOf(0);
|
||||
return raw.subarray(0, end === -1 ? length : end).toString("latin1");
|
||||
}
|
||||
|
||||
function octal(block: Buffer, offset: number, length: number): number {
|
||||
return Number.parseInt(text(block, offset, length).trim() || "0", 8);
|
||||
}
|
||||
|
||||
export function readTarGz(archive: Buffer): readonly TarEntry[] {
|
||||
const raw = gunzipSync(archive);
|
||||
const entries: TarEntry[] = [];
|
||||
let offset = 0;
|
||||
while (offset + 512 <= raw.length) {
|
||||
const block = raw.subarray(offset, offset + 512);
|
||||
if (block.every((byte) => byte === 0)) {
|
||||
break;
|
||||
}
|
||||
const name = text(block, 0, 100);
|
||||
const prefix = text(block, 345, 155);
|
||||
const size = octal(block, 124, 12);
|
||||
entries.push({
|
||||
data: Buffer.from(raw.subarray(offset + 512, offset + 512 + size)),
|
||||
gid: octal(block, 116, 8),
|
||||
gname: text(block, 297, 32),
|
||||
magic: `${text(block, 257, 6)}/${text(block, 263, 2)}`,
|
||||
mode: octal(block, 100, 8),
|
||||
mtime: octal(block, 136, 12),
|
||||
path: prefix === "" ? name : `${prefix}/${name}`,
|
||||
typeflag: text(block, 156, 1),
|
||||
uid: octal(block, 108, 8),
|
||||
uname: text(block, 265, 32),
|
||||
});
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function fileEntries(archive: Buffer): Map<string, Buffer> {
|
||||
return new Map(
|
||||
readTarGz(archive)
|
||||
.filter((entry) => entry.typeflag === "0")
|
||||
.map((entry) => [entry.path, entry.data]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readdir, readFile, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { verifyModulePackage } from "@ekklesia/core/modules";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { packModule } from "../src/index.js";
|
||||
import { cleanupFixtures, SOURCE_REVISION, scratchDirectory } from "./support/fixture-module.js";
|
||||
import { fileEntries } from "./support/tar.js";
|
||||
|
||||
/**
|
||||
* The canonical template module, packaged end to end (ADR 0049): the real built template, the real
|
||||
* packager, and Core's real verifier. Turbo builds the template and the verifier before this suite
|
||||
* runs (both are development dependencies of this package), and runs the template's own tests, which
|
||||
* rebuild its UI in place, before this one (turbo.json).
|
||||
*/
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repositoryRoot = path.resolve(packageRoot, "..", "..");
|
||||
|
||||
afterEach(cleanupFixtures);
|
||||
|
||||
async function templateDirectory(): Promise<string> {
|
||||
return realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-template"));
|
||||
}
|
||||
|
||||
async function readJson(file: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function platform() {
|
||||
const core = await readJson(path.join(repositoryRoot, "core", "package.json"));
|
||||
return {
|
||||
databaseRuntime: {
|
||||
"drizzle-orm": (core.dependencies as Record<string, string>)["drizzle-orm"] ?? "",
|
||||
},
|
||||
moduleSdkVersion: String(
|
||||
(await readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json"))).version,
|
||||
),
|
||||
platformVersion: String((await readJson(path.join(repositoryRoot, "package.json"))).version),
|
||||
};
|
||||
}
|
||||
|
||||
async function fingerprint(file: string): Promise<string> {
|
||||
try {
|
||||
return createHash("sha256")
|
||||
.update(await readFile(file))
|
||||
.digest("hex");
|
||||
} catch {
|
||||
return "absent";
|
||||
}
|
||||
}
|
||||
|
||||
async function treeListing(directory: string): Promise<readonly string[]> {
|
||||
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => !path.join(entry.parentPath, entry.name).includes(`${path.sep}node_modules`))
|
||||
.map((entry) => path.relative(directory, path.join(entry.parentPath, entry.name)))
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function packTemplate() {
|
||||
const outDirectory = await scratchDirectory("ekklesia-template-package-");
|
||||
const result = await packModule({
|
||||
moduleDirectory: await templateDirectory(),
|
||||
outDirectory,
|
||||
sourceRevision: SOURCE_REVISION,
|
||||
target: "any",
|
||||
});
|
||||
return { archive: await readFile(result.archivePath), result };
|
||||
}
|
||||
|
||||
describe("the module template as a Module Package v1", () => {
|
||||
it("packs deterministically, without touching the workspace it was built in", async () => {
|
||||
const template = await templateDirectory();
|
||||
const watched = [
|
||||
path.join(repositoryRoot, "node_modules", ".pnpm-workspace-state-v1.json"),
|
||||
path.join(repositoryRoot, "node_modules", ".modules.yaml"),
|
||||
path.join(repositoryRoot, "pnpm-lock.yaml"),
|
||||
];
|
||||
const before = await Promise.all(watched.map(fingerprint));
|
||||
const templateBefore = await treeListing(template);
|
||||
|
||||
const first = await packTemplate();
|
||||
const second = await packTemplate();
|
||||
|
||||
expect(first.result.sha256).toBe(second.result.sha256);
|
||||
expect(first.archive.equals(second.archive)).toBe(true);
|
||||
expect(path.basename(first.result.archivePath)).toMatch(
|
||||
/^example-\d+\.\d+\.\d+-any\.tar\.gz$/u,
|
||||
);
|
||||
expect(await Promise.all(watched.map(fingerprint))).toEqual(before);
|
||||
expect(await treeListing(template)).toEqual(templateBefore);
|
||||
});
|
||||
|
||||
it("verifies against this platform and extracts to an unrelated directory that holds no link", async () => {
|
||||
const { result } = await packTemplate();
|
||||
const target = path.join(await scratchDirectory("ekklesia-installed-elsewhere-"), "example");
|
||||
const verified = await verifyModulePackage({
|
||||
archivePath: result.archivePath,
|
||||
expectedSha256: result.sha256,
|
||||
extractTo: target,
|
||||
platform: await platform(),
|
||||
});
|
||||
expect(verified).toMatchObject({ moduleId: "example", target: "any" });
|
||||
|
||||
for (const entry of await readdir(target, { recursive: true, withFileTypes: true })) {
|
||||
const stats = await lstat(path.join(entry.parentPath, entry.name));
|
||||
expect(stats.isSymbolicLink(), entry.name).toBe(false);
|
||||
expect(stats.isFile() ? stats.nlink : 1, entry.name).toBe(1);
|
||||
}
|
||||
const listing = await treeListing(target);
|
||||
expect(listing).not.toContain("node_modules");
|
||||
expect(listing.some((file) => /(^|\/)(src|tests)(\/|$)|\.d\.ts$|\.map$/u.test(file))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("loads from its new location without resolving anything outside itself", async () => {
|
||||
const { result } = await packTemplate();
|
||||
const target = path.join(await scratchDirectory("ekklesia-portable-"), "example");
|
||||
await verifyModulePackage({
|
||||
archivePath: result.archivePath,
|
||||
extractTo: target,
|
||||
platform: await platform(),
|
||||
});
|
||||
|
||||
// Every specifier the backend resolves while loading, recorded by Node itself.
|
||||
const script = `
|
||||
import { registerHooks } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const resolved = [];
|
||||
registerHooks({ resolve(specifier, context, next) { const result = next(specifier, context); resolved.push(result.url); return result; } });
|
||||
for (const file of ["dist/server/index.js", "dist/schema/index.js"]) {
|
||||
await import(pathToFileURL(${JSON.stringify(target)} + "/" + file).href);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(resolved));
|
||||
`;
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", script],
|
||||
{
|
||||
cwd: path.dirname(target),
|
||||
env: {},
|
||||
},
|
||||
);
|
||||
const resolved = JSON.parse(stdout) as string[];
|
||||
expect(resolved.length).toBeGreaterThan(0);
|
||||
for (const url of resolved) {
|
||||
const inside = url.startsWith(`file://${target}/`);
|
||||
expect(inside || url.startsWith("node:"), url).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("credits every third-party package its bundles say they contain", async () => {
|
||||
const { archive } = await packTemplate();
|
||||
const files = fileEntries(archive);
|
||||
const notices = files.get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
|
||||
const bundled = new Set<string>();
|
||||
for (const [file, data] of files) {
|
||||
if (file.endsWith(".js")) {
|
||||
// esbuild names every bundled source file in a path comment.
|
||||
for (const match of data
|
||||
.toString("utf8")
|
||||
.matchAll(/node_modules\/((?:@[^/]+\/)?[^/.][^/]*)\//gu)) {
|
||||
if (match[1] && !match[1].startsWith("@ekklesia/")) {
|
||||
bundled.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect([...bundled]).toEqual(
|
||||
expect.arrayContaining(["drizzle-orm", "react", "react-dom", "zod"]),
|
||||
);
|
||||
for (const name of bundled) {
|
||||
expect(notices, name).toMatch(
|
||||
new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&")} \\S+$`, "mu"),
|
||||
);
|
||||
}
|
||||
const licence = await readFile(path.join(packageRoot, "licenses", "Apache-2.0.txt"));
|
||||
expect(createHash("sha256").update(licence).digest("hex")).toBe(
|
||||
"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
||||
);
|
||||
});
|
||||
|
||||
it("refers to no absolute path of the checkout it was built in", async () => {
|
||||
const { archive } = await packTemplate();
|
||||
for (const [file, data] of fileEntries(archive)) {
|
||||
expect(data.toString("utf8"), file).not.toContain(repositoryRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user