#!/usr/bin/env bash # The standalone module-development proof (ADR 0053): a module outside this repository, installed # from locally packed @ekklesia/* tarballs, with no workspace link and no Ekklesia registry, must # install, typecheck, build its backend and UI, package with the canonical packager, and verify with # the platform's own verifier. # # bash scripts/test-standalone-module.sh # # Third-party packages (React, TypeScript, esbuild, ...) come from the public npm registry as usual. # Nothing is published, and nothing outside a temporary directory is written except the packed # public packages' rebuilt dist/ output. set -euo pipefail repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" work="$(mktemp -d "${TMPDIR:-/tmp}/ekklesia-standalone-XXXXXX")" trap 'rm -rf "$work"' EXIT fail() { echo "standalone module proof failed: $*" >&2 exit 1 } version="$(node -p 'require(process.argv[1]).version' "$repository_root/package.json")" echo "==> Packing and inspecting the public package set ($version)" node "$repository_root/scripts/pack-public-packages.ts" --out "$work/tarballs" module="$work/standalone-proof" cp -R "$repository_root/tests/fixtures/standalone-module" "$module" cp "$repository_root/LICENSE" "$module/LICENSE" # A module repository's own pnpm settings: esbuild (used by the UI build and the packager) runs its # install script. And, for this proof only, every @ekklesia/* package, the module's own and the ones # they depend on, resolves to its packed tarball, exactly as it would to a published version. { echo "allowBuilds:" echo " esbuild: true" echo "overrides:" for tarball in "$work"/tarballs/*.tgz; do name="$(tar -xzOf "$tarball" package/package.json | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).name')" echo " \"$name\": \"file:$tarball\"" done } >"$module/pnpm-workspace.yaml" echo "==> Installing the module from the tarballs" (cd "$module" && pnpm install --reporter=append-only >"$work/install.log" 2>&1) || { tail -40 "$work/install.log" >&2; fail "install"; } echo "==> Checking what the install resolved" (cd "$module" && node --input-type=module - "$module" <<'EOF') import { readdirSync, realpathSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; const [moduleDirectory] = process.argv.slice(2); const store = readdirSync(path.join(moduleDirectory, "node_modules", ".pnpm")); const failures = []; // The Module SDK and its dependencies never pull Drizzle into a module that does not use it. if (store.some((entry) => entry.startsWith("drizzle-orm@"))) failures.push("drizzle-orm is installed"); // One React, the module's own, shared by @ekklesia/ui. const reacts = store.filter((entry) => /^react@/u.test(entry)); if (reacts.length !== 1) failures.push(`${reacts.length} copies of react are installed`); const fromModule = createRequire(path.join(moduleDirectory, "package.json")); const ui = realpathSync(path.join(moduleDirectory, "node_modules", "@ekklesia", "ui", "package.json")); const fromUi = createRequire(ui); if (realpathSync(fromModule.resolve("react")) !== realpathSync(fromUi.resolve("react"))) { failures.push("@ekklesia/ui resolves a different react"); } // Every Ekklesia package came from a tarball into the module's own store, never from a link. for (const name of readdirSync(path.join(moduleDirectory, "node_modules", "@ekklesia"))) { const real = realpathSync(path.join(moduleDirectory, "node_modules", "@ekklesia", name)); if (!real.startsWith(path.join(moduleDirectory, "node_modules", ".pnpm") + path.sep)) { failures.push(`@ekklesia/${name} is linked from outside the module`); } } if (failures.length > 0) { console.error(failures.join("\n")); process.exit(1); } EOF # The published declarations must stand on their own: no package may name a module a consumer has # not installed (Drizzle above all), and the fixture checks every declaration it reaches. for project in tsconfig.json tsconfig.ui.json; do (cd "$module" && ./node_modules/.bin/tsc --showConfig --project "$project") | node -e 'const c = JSON.parse(require("fs").readFileSync(0, "utf8")); if (c.compilerOptions.skipLibCheck !== false) process.exit(1);' || fail "$project does not typecheck with skipLibCheck: false" done echo "==> Typechecking (skipLibCheck: false) and building" (cd "$module" && pnpm run typecheck && pnpm run build) >"$work/build.log" 2>&1 || { tail -40 "$work/build.log" >&2; fail "typecheck or build"; } # The packager needs no workspace file: the module's Node.js range is its own engines.node. The # binary is run directly, because pnpm would re-resolve the module without the proof's overrides. rm "$module/pnpm-workspace.yaml" echo "==> Packaging with the canonical packager" (cd "$module" && ./node_modules/.bin/ekklesia-pack-module --module . --target any --out "$work/out" \ --license-file LICENSE --source-revision 86ae907f59458508e3277666cdd214f5bd7c1078) archive="$work/out/standalone-proof-0.1.0-any.tar.gz" echo "==> Verifying with the platform verifier" (cd "$repository_root" && node core/dist/cli/verify-module-package.js "$archive" \ --sha256-file "$archive.sha256" --extract-to "$work/verified") echo "==> Checking the package's own description" node --input-type=module - "$work/verified" "$version" "$repository_root" "$work" <<'EOF' import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; const [verified, version, repositoryRoot, work] = process.argv.slice(2); const read = (file) => readFileSync(path.join(verified, file), "utf8"); const metadata = JSON.parse(read("ekklesia.package.json")); const failures = []; const incorporates = metadata.incorporates ?? {}; for (const name of ["@ekklesia/contracts", "@ekklesia/module-database", "@ekklesia/module-sdk"]) { if (incorporates[name] !== version) failures.push(`incorporates ${name}@${incorporates[name]}`); } if (metadata.runtime?.node !== ">=24.11.0 <25") failures.push("runtime.node is not engines.node"); if (metadata.transitional !== undefined) failures.push("records transitional database facts"); const notices = read("THIRD_PARTY_NOTICES"); if (notices.includes("@ekklesia/")) failures.push("credits Ekklesia as third-party"); // The UI bundle holds one React, the module's own. const reacts = notices.split("\n").filter((line) => /^react \d/u.test(line)); if (reacts.length !== 1) failures.push(`credits ${reacts.length} copies of react`); for (const entry of readdirSync(verified, { recursive: true, withFileTypes: true })) { if (!entry.isFile()) continue; const text = readFileSync(path.join(entry.parentPath, entry.name), "latin1"); if (text.includes(repositoryRoot) || text.includes(work)) failures.push(`${entry.name} names a local path`); if (/drizzle-orm/u.test(text) && entry.name.endsWith(".js")) failures.push(`${entry.name} bundles Drizzle`); } if (failures.length > 0) { console.error(failures.join("\n")); process.exit(1); } console.log(`incorporates ${JSON.stringify(incorporates)}`); EOF echo "Standalone module proof passed."