UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

179 lines (178 loc) 8.86 kB
import chalk from "chalk"; import * as fs from "fs"; import * as path from "path"; import { parseDocument, YAMLMap, isMap } from "yaml"; import { logInfo, logWarn } from "../utils/log.js"; import { MeshCliError, emitJsonPayload } from "../utils/errors.js"; import { findAppRoot, findStackConfigs, getCurrentStack } from "../utils/pulumi.js"; import { atomicWriteFileSync } from "./login.js"; export function validateTenantName(name) { return /^[a-z][a-z0-9-]*$/.test(name) && !name.endsWith("-"); } export function deriveEnvFromStack(stack) { const m = stack.match(/-([a-z0-9]+)$/); return m ? (m[1] ?? null) : null; } export function addTenantToStackConfig(yamlText, spec) { const doc = parseDocument(yamlText); const config = doc.getIn(["config"]); if (!isMap(config)) { return { ok: false, reason: "no-config", detail: "no top-level `config:` map in the stack config" }; } const tenantsPath = ["config", "mesh:tenants"]; let createdTenantsBlock = false; if (!isMap(doc.getIn(tenantsPath))) { if (doc.hasIn(tenantsPath)) { return { ok: false, reason: "no-config", detail: "`mesh:tenants` exists but is not a map" }; } doc.setIn(tenantsPath, new YAMLMap()); createdTenantsBlock = true; } if (doc.hasIn([...tenantsPath, spec.name])) { return { ok: false, reason: "exists", detail: `tenant '${spec.name}' is already declared in mesh:tenants` }; } const entry = {}; if (spec.displayName) entry.displayName = spec.displayName; entry.subdomain = spec.subdomain; if (spec.zitadelOrgId) entry.zitadelOrgId = spec.zitadelOrgId; doc.setIn([...tenantsPath, spec.name], doc.createNode(entry)); return { ok: true, yaml: doc.toString({ flowCollectionPadding: false, lineWidth: 0 }), createdTenantsBlock, }; } export function listTenantsInStackConfig(yamlText) { const doc = parseDocument(yamlText); const tenants = doc.getIn(["config", "mesh:tenants"]); if (!isMap(tenants)) return []; return tenants.items.map((item) => { const name = String(item.key?.value ?? item.key); const value = item.value; const get = (key) => { if (!isMap(value)) return undefined; const v = value.get(key, false); return typeof v === "string" ? v : undefined; }; return { name, displayName: get("displayName"), subdomain: get("subdomain") }; }); } function resolveStackConfig(explicitStack) { const appRoot = findAppRoot(process.cwd()); if (!appRoot) { throw new MeshCliError("No Pulumi.yaml found. Run from the PLATFORM layer of a tenant platform repo (the directory whose stack config carries mesh:tenants)."); } const stacks = findStackConfigs(appRoot); const stack = explicitStack ?? getCurrentStack(appRoot) ?? (stacks.length === 1 ? stacks[0] : null); if (!stack) { throw new MeshCliError(`Could not determine the stack (found: ${stacks.join(", ") || "none"}).`, { remediation: { command: "mesh tenant add <name> --stack <stack>" } }); } const file = path.join(appRoot, `Pulumi.${stack}.yaml`); if (!fs.existsSync(file)) { throw new MeshCliError(`Stack config not found: ${file}`, { remediation: { command: "mesh tenant add <name> --stack <stack>" }, }); } return { appRoot, stack, file, content: fs.readFileSync(file, "utf-8") }; } function readProjectName(appRoot) { const projectDoc = parseDocument(fs.readFileSync(path.join(appRoot, "Pulumi.yaml"), "utf-8")); return String(projectDoc.get("name") ?? ""); } function looksLikePlatformLayer(projectName) { return projectName.endsWith("-platform"); } function assertPlatformLayer(appRoot, content) { if (parseDocument(content).hasIn(["config", "mesh:tenants"])) return; const projectName = readProjectName(appRoot); if (!looksLikePlatformLayer(projectName)) { throw new MeshCliError(`This looks like the wrong layer: project '${projectName}' has no mesh:tenants block and does not look like a platform layer. App tenants are registered on the tenant platform repo's PLATFORM stack (e.g. mesh-sandbox/platform).`); } } export function registerTenantCommands(program) { const tenant = program.command("tenant").description("Register and inspect app tenants on a platform stack"); tenant .command("add <name>") .description("Register an app tenant in the platform stack config (mesh:tenants) — then `mesh deploy up` makes it live.") .option("--display-name <name>", "display name shown in the Hub UI (default: the tenant slug)") .option("--subdomain <subdomain>", "tenant subdomain (default: {name}-{env} derived from the stack name)") .option("--zitadel-org-id <id>", "existing Zitadel organization ID to bind (optional)") .option("--stack <stack>", "platform stack to register on (default: current stack, else the only one)") .option("--json", "machine-readable output", false) .action(async (name, opts) => { if (!validateTenantName(name)) { throw new MeshCliError(`Invalid tenant name '${name}' (lowercase alphanumeric + dashes).`); } const resolved = resolveStackConfig(opts.stack); assertPlatformLayer(resolved.appRoot, resolved.content); const env = deriveEnvFromStack(resolved.stack); const subdomain = opts.subdomain ?? (env ? `${name}-${env}` : null); if (!subdomain) { throw new MeshCliError(`Cannot derive a default subdomain from stack '${resolved.stack}'.`, { remediation: { command: `mesh tenant add ${name} --subdomain <subdomain>` } }); } const result = addTenantToStackConfig(resolved.content, { name, displayName: opts.displayName, subdomain, zitadelOrgId: opts.zitadelOrgId, }); if (!result.ok) { if (result.reason === "exists") { throw new MeshCliError(`${result.detail} (${path.basename(resolved.file)}). Edit the existing entry instead of re-adding it.`); } throw new MeshCliError(`${resolved.file}: ${result.detail}`); } atomicWriteFileSync(resolved.file, result.yaml, fs.statSync(resolved.file).mode & 0o777); if (opts.json) { emitJsonPayload({ ok: true, tenant: name, stack: resolved.stack, file: resolved.file, entry: { displayName: opts.displayName, subdomain, zitadelOrgId: opts.zitadelOrgId }, deploy: `mesh deploy up -s ${resolved.stack}`, }); return; } logInfo(`Tenant '${name}' registered in ${path.basename(resolved.file)} (mesh:tenants, subdomain '${subdomain}')${result.createdTenantsBlock ? " — created the mesh:tenants block" : ""}`); console.log(""); console.log("Next steps:"); console.log(` mesh deploy preview -s ${resolved.stack} # expect only '${name}'-scoped additions`); console.log(` mesh deploy up -s ${resolved.stack} # provision tenant infra + Hub registry entry`); console.log(""); console.log("Then, from the tenant's apps repo:"); console.log(` mesh init app-tenant --tenant ${name} # the doctor should now pass`); console.log(` mesh create-app --tenant ${name} --name <app>`); }); tenant .command("list") .description("List app tenants declared in the platform stack config") .option("--stack <stack>", "platform stack to read (default: current stack, else the only one)") .option("--json", "machine-readable output", false) .action(async (opts) => { const resolved = resolveStackConfig(opts.stack); const tenants = listTenantsInStackConfig(resolved.content); if (opts.json) { emitJsonPayload({ ok: true, stack: resolved.stack, tenants }); return; } if (tenants.length === 0) { const projectName = readProjectName(resolved.appRoot); if (!looksLikePlatformLayer(projectName)) { logWarn(`Project '${projectName}' does not look like a platform layer — tenants are declared on the tenant platform repo's PLATFORM stack (e.g. mesh-sandbox/platform).`); } logInfo(`No tenants declared in ${path.basename(resolved.file)}.`); return; } logInfo(`Tenants on stack '${resolved.stack}':`); for (const t of tenants) { const display = t.displayName ? ` (${t.displayName})` : ""; console.log(` ${chalk.cyan(t.name.padEnd(20))}${t.subdomain ?? ""}${chalk.dim(display)}`); } }); }