@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
179 lines (178 loc) • 8.86 kB
JavaScript
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)}`);
}
});
}