import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { assessLagoCatalog, buildDesiredLagoCatalog, type LagoCatalogAssessment, type LagoCatalogSnapshot } from "./catalog"; import { BillingCatalogOperatorError, parseBillingCatalogCommand, safeBillingCatalogError, type BillingCatalogCommand } from "./contracts"; import { LagoCatalogClient } from "./lago-client"; type BillingCatalogExecutionResult = { readonly target: BillingCatalogCommand["target"]; readonly mode: BillingCatalogCommand["mode"]; readonly complete: boolean; readonly catalogVersion: string; readonly catalogDigestSha256: string; readonly snapshotPath: string | null; readonly actions: LagoCatalogAssessment["actions"]; }; function safeTimestamp(now: Date): string { return now.toISOString().replace(/[:.]/gu, "-"); } export async function writeRedactedCatalogSnapshot(input: { directory: string; target: BillingCatalogCommand["target"]; snapshot: LagoCatalogSnapshot; assessment: LagoCatalogAssessment; now?: Date; }): Promise { const path = resolve(input.directory, `${input.target}-${safeTimestamp(input.now ?? new Date())}.json`); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await writeFile( path, `${JSON.stringify( { target: input.target, capturedAt: (input.now ?? new Date()).toISOString(), catalogVersion: input.assessment.catalogVersion, catalogDigestSha256: input.assessment.catalogDigestSha256, snapshot: input.snapshot, }, null, 2 )}\n`, { encoding: "utf8", mode: 0o600 } ); return path; } export async function executeBillingCatalogCommand( command: BillingCatalogCommand, client = new LagoCatalogClient({ apiUrl: command.lagoApiUrl, apiKey: command.lagoApiKey, expectedOrganizationName: command.expectedOrganizationName, expectedOrganizationIdSha256: command.expectedOrganizationIdSha256, }) ): Promise { const desired = buildDesiredLagoCatalog(); const initialSnapshot = await client.readCatalog(desired); const initialAssessment = assessLagoCatalog(initialSnapshot, desired); if (command.mode === "plan") { return { target: command.target, mode: command.mode, complete: initialAssessment.complete, catalogVersion: desired.version, catalogDigestSha256: desired.digestSha256, snapshotPath: null, actions: initialAssessment.actions, }; } if (command.mode === "verify") { if (!initialAssessment.complete) { throw new BillingCatalogOperatorError("billing catalog: verification found missing or drifted Lago resources"); } return { target: command.target, mode: command.mode, complete: true, catalogVersion: desired.version, catalogDigestSha256: desired.digestSha256, snapshotPath: null, actions: initialAssessment.actions, }; } const snapshotPath = await writeRedactedCatalogSnapshot({ directory: command.snapshotDirectory, target: command.target, snapshot: initialSnapshot, assessment: initialAssessment, }); for (const action of initialAssessment.actions) { if (action.action === "noop") continue; if (action.resource === "tax") { await client.applyTax(desired, action.action === "update"); continue; } const plan = desired.plans.find((candidate) => candidate.code === action.code); if (!plan) throw new BillingCatalogOperatorError("billing catalog: desired Lago plan is unavailable"); await client.applyPlan(plan, action.action === "update"); } const finalAssessment = assessLagoCatalog(await client.readCatalog(desired), desired); if (!finalAssessment.complete) { throw new BillingCatalogOperatorError("billing catalog: apply did not converge to the canonical contract"); } return { target: command.target, mode: command.mode, complete: true, catalogVersion: desired.version, catalogDigestSha256: desired.digestSha256, snapshotPath, actions: finalAssessment.actions, }; } async function main(): Promise { try { const command = parseBillingCatalogCommand(process.argv.slice(2), process.env); const result = await executeBillingCatalogCommand(command); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } catch (error) { process.stderr.write(`${safeBillingCatalogError(error)}\n`); process.exitCode = 1; } } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { void main(); }