import path from "node:path"; import { readFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { createModuleLifecycleDatabase, createModuleLifecycleStore, discoverModules, isDependencyBlockedDetail, isStorageBlockedDetail, ModuleDiscoveryError, ModuleLifecycleStoreError, resolveModuleEligibility, } from "../modules/index.js"; import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js"; import { resolveCommandModulesDirectory } from "./modules-directory-environment.js"; /** * Enables or disables one installed module, out of band from the running API. * * This exists because module startup is deliberately all-or-nothing (ADR 0015, ADR 0020, preserved * by ADR 0028): a module whose `start` hook throws takes API startup down with it, and the HTTP * lifecycle API that could otherwise turn it off is served by that same process. Without an * out-of-band path, the only recovery from one bad module would be editing the database by hand. * This command is that path — the same shape as `pnpm authorization:bootstrap-owner`: explicit, * operator-invoked, never HTTP-exposed, and never inferred. * * It writes configuration and nothing else. It starts nothing, stops nothing, imports no module * server entrypoint, runs no lifecycle hook, and applies no migration. The change takes effect on * the next API start. */ class ModuleLifecycleCliError extends Error { constructor(message: string) { super(message); this.name = "ModuleLifecycleCliError"; this.stack = `${this.name}: ${this.message}`; } } const usage = "Usage: pnpm modules:enable -- --module | pnpm modules:disable -- --module "; interface ParsedArgs { readonly enabled: boolean; readonly moduleId: string; } /** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts`: this command * takes one named flag plus one mode flag, so a general-purpose argument dependency is not * warranted. `--enable` and `--disable` are mutually exclusive and one is required, so the intent * is always stated rather than defaulted. */ function parseArgs(argv: readonly string[]): ParsedArgs { let moduleId: string | undefined; let enabled: boolean | undefined; for (let index = 0; index < argv.length; index += 1) { const flag = argv[index]; if (flag === "--module") { moduleId = argv[index + 1]; index += 1; } else if (flag === "--enable") { if (enabled === false) { throw new ModuleLifecycleCliError(usage); } enabled = true; } else if (flag === "--disable") { if (enabled === true) { throw new ModuleLifecycleCliError(usage); } enabled = false; } } if (moduleId === undefined || enabled === undefined) { throw new ModuleLifecycleCliError(usage); } return { enabled, moduleId }; } interface PackageVersion { readonly version?: unknown; } async function readPackageVersion(packagePath: string): Promise { const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion; if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) { throw new ModuleLifecycleCliError("The repository package metadata could not be read."); } return packageData.version; } async function main(): Promise { const { enabled, moduleId } = parseArgs(process.argv.slice(2)); const repositoryRoot = process.cwd(); const [platformVersion, moduleSdkVersion] = await Promise.all([ readPackageVersion(path.join(repositoryRoot, "package.json")), readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")), ]); // The same rule the HTTP API enforces: a module this deployment cannot currently see may not be // configured. Manifest-only, exactly like `pnpm modules:validate` — no module server entrypoint // is imported and no lifecycle hook runs, so a module that crashes during `start` is still fully // configurable here, which is the case this command exists for. const discovered = await discoverModules({ moduleSdkVersion, ...resolveCommandModulesDirectory(process.env, repositoryRoot), platformVersion, }); if (!discovered.some((module) => module.manifest.id === moduleId)) { throw new ModuleLifecycleCliError( `No installed module declares the id "${moduleId}". No changes were made.`, ); } // Read only when this command actually executes — never merely by importing this module. const databaseUrl = process.env.DATABASE_URL; if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) { throw new ModuleLifecycleCliError("DATABASE_URL is required."); } // Asserted before the connection is constructed, so an unsafe production transport never opens a // client at all. const transport = resolveExecutableDatabaseTransport(databaseUrl); const { close, db } = createModuleLifecycleDatabase(databaseUrl, { connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000, transport, }); try { const store = createModuleLifecycleStore(db); await store.setEnabled({ enabled, moduleId }); const resolution = resolveModuleEligibility(discovered, await store.readConfiguration()); console.log( `Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`, ); for (const entry of resolution.entries) { const blocked = entry.blocked; if (!blocked) { continue; } // Names the dependency, the configuration key, or the logical storage bucket — never a // configuration value, an endpoint, or a credential. const cause = isDependencyBlockedDetail(blocked) ? blocked.dependency.moduleId : isStorageBlockedDetail(blocked) ? blocked.storageBucketId : blocked.configurationKey; console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`); } } finally { await close().catch(() => undefined); } } function reportCliError(error: unknown): void { if (error instanceof ModuleLifecycleCliError) { console.error(error.message); return; } if (error instanceof ModuleLifecycleStoreError) { console.error(`[${error.code}] ${error.message}`); return; } if (error instanceof ModuleDiscoveryError) { console.error(error.message); for (const issue of error.issues) { const issuePath = issue.path ? ` (${issue.path})` : ""; console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`); } return; } console.error("The module enablement change failed."); } function isDirectExecution(): boolean { const entrypoint = process.argv[1]; return ( entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href ); } if (isDirectExecution()) { main().catch((error: unknown) => { reportCliError(error); process.exitCode = 1; }); }