UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

603 lines (602 loc) 24.5 kB
import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { fileURLToPath } from "url"; import Handlebars from "handlebars"; import { parse as parseYaml } from "yaml"; import { logInfo, logSuccess, logError, logWarn } from "../utils/index.js"; import { INTENT_RANGE, resolveTargetRoot, seedAppSkill, syncSkills } from "./skills.js"; import { renderReport, runAppCheck } from "./app-check.js"; import { findPackageRoot } from "./local/stack.js"; import { SCAFFOLD_TOOLCHAIN, resolvePublishedRange } from "../utils/scaffold-versions.js"; import { registryPreflight } from "../utils/auth-preflight.js"; import { MeshCliError } from "../utils/errors.js"; import { findMeshJson } from "../utils/mesh-json.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const packageRoot = findPackageRoot(__dirname); const TEMPLATES = { workflow: "Hono API + Temporal worker + hello workflow (Pulumi/app-kit)", "api-auth": "Hono API with platform auth: authn (Zitadel JWT) + authz (SpiceDB)", "api-role-gating": "Hono API with role-gating authz: authn (Zitadel JWT) + coarse-role checks on Zitadel project roles, in-process (no SpiceDB)", "external-service": "Hono API calling a third-party vendor via platform-managed credentials (ExternalService + mock for `mesh dev --externals`)", }; const REMOVED_TEMPLATES = { "temporal-api-worker": "renamed to 'workflow'", "api-web-db": "removed (SST-based) — use --primitives service,database", }; const PRIMITIVES = { service: "HTTP Service (API with Hono)", database: "Database (PostgreSQL via Prisma)", temporal: "Temporal (workflow orchestration)", bucket: "S3 Bucket (file storage)", }; const HUB_ACCOUNTS = { mesh: "159923586610", }; export const PLATFORM_MONOREPO_NAME = "mesh-platform"; export function isInsidePlatformMonorepo(dir) { let cur = path.resolve(dir); while (cur !== path.dirname(cur)) { if (fs.existsSync(path.join(cur, "pnpm-workspace.yaml"))) { try { const pkg = JSON.parse(fs.readFileSync(path.join(cur, "package.json"), "utf-8")); if (pkg?.name === PLATFORM_MONOREPO_NAME) return true; } catch { } } cur = path.dirname(cur); } return false; } function resolveDeployerRoleArn(tenant, platformName, env) { const account = HUB_ACCOUNTS[platformName] ?? "ACCOUNT_ID"; return `arn:aws:iam::${account}:role/${tenant}-${env}-apps-deployer`; } const VALID_PRIMITIVES = Object.keys(PRIMITIVES); export function shouldBootstrapAppsRepo(cwd) { return (fs.existsSync(path.join(cwd, ".git")) && !isInsidePlatformMonorepo(cwd) && !fs.existsSync(path.join(cwd, "package.json")) && !fs.existsSync(path.join(cwd, "tenants")) && !fs.existsSync(path.join(cwd, "apps"))); } export function bootstrapAppsRepo(cwd, tenant) { const templateDir = path.join(packageRoot, "templates", "apps-repo"); const registryAccount = HUB_ACCOUNTS.mesh; const context = { repoName: path.basename(cwd), tenant, tenantTitle: tenant .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "), registryUrl: `https://mesh-platform-${registryAccount}.d.codeartifact.us-east-2.amazonaws.com/npm/mesh-packages/`, intentRange: INTENT_RANGE, }; const staging = fs.mkdtempSync(path.join(os.tmpdir(), "mesh-apps-repo-")); try { copyTemplate(templateDir, staging, context); for (const [from, to] of [ ["gitignore", ".gitignore"], ["npmrc", ".npmrc"], ]) { const p = path.join(staging, from); if (fs.existsSync(p)) fs.renameSync(p, path.join(staging, to)); } const created = []; for (const entry of fs.readdirSync(staging)) { const dest = path.join(cwd, entry); if (fs.existsSync(dest)) continue; fs.cpSync(path.join(staging, entry), dest, { recursive: true }); created.push(entry); } fs.mkdirSync(path.join(cwd, "apps"), { recursive: true }); created.push("apps/"); return created; } finally { fs.rmSync(staging, { recursive: true, force: true }); } } export function ensureWorkspaceGlobs(root, required) { const file = path.join(root, "pnpm-workspace.yaml"); if (!fs.existsSync(file)) return []; const text = fs.readFileSync(file, "utf-8"); let declared; try { declared = (parseYaml(text)?.packages ?? []); } catch { return []; } const missing = required.filter((glob) => !declared.includes(glob)); if (missing.length === 0) return []; const lines = text.split("\n"); const start = lines.findIndex((line) => /^packages:\s*$/.test(line)); if (start === -1) return []; let end = start; for (let i = start + 1; i < lines.length; i++) { const line = lines[i] ?? ""; const isEntry = /^\s+-\s/.test(line); if (isEntry || /^\s*#/.test(line) || line.trim() === "") { if (isEntry) end = i; continue; } break; } const indent = (lines[end] ?? "").match(/^(\s*)-/)?.[1] ?? " "; lines.splice(end + 1, 0, ...missing.map((glob) => `${indent}- ${glob}`)); fs.writeFileSync(file, lines.join("\n")); return missing; } export function workspaceGlobForApp(root, appDir) { const rel = path.relative(root, appDir).split(path.sep).join("/"); if (rel === "" || rel.split("/")[0] === "..") return null; return `${path.posix.dirname(rel)}/*/*`; } function maybeBootstrapAppsRepo(cwd, tenant, test) { if (test || !shouldBootstrapAppsRepo(cwd)) return; const created = bootstrapAppsRepo(cwd, tenant); logInfo(`Standalone apps repo detected — bootstrapped workspace files: ${created.join(", ")}`); } Handlebars.registerHelper("titleCase", (str) => { return str .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); }); function isInteractive() { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } async function promptForOptions(options) { const { default: input } = await import("@inquirer/input"); const { default: checkbox } = await import("@inquirer/checkbox"); const tenant = options.tenant ?? (await input({ message: "Tenant name", default: findMeshJson(process.cwd())?.data.tenant, validate: (v) => (v.length > 0 ? true : "Tenant name is required"), })); const name = options.name ?? (await input({ message: "App name", validate: (v) => (v.length > 0 ? true : "App name is required"), })); let primitives; if (options.primitives) { primitives = parsePrimitives(options.primitives); } else { primitives = await checkbox({ message: "Select primitives", choices: VALID_PRIMITIVES.map((p) => ({ name: PRIMITIVES[p], value: p, checked: p === "service", })), }); if (primitives.length === 0) { primitives = ["service"]; } } if (primitives.includes("database") && !primitives.includes("service")) { primitives.push("service"); logInfo("Added 'service' — database files live in the api/ directory."); } if (primitives.includes("bucket") && !primitives.includes("service")) { primitives.push("service"); logInfo("Added 'service' — bucket requires an API service for S3 helpers."); } return { tenant, name, primitives }; } export function copyTemplate(srcDir, destDir, context) { const entries = fs.readdirSync(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(srcDir, entry.name); let destName = entry.name; if (destName.endsWith(".hbs")) { destName = destName.slice(0, -4); } if (destName.includes("{{")) { destName = Handlebars.compile(destName)(context); } const destPath = path.join(destDir, destName); if (entry.isDirectory()) { fs.mkdirSync(destPath, { recursive: true }); copyTemplate(srcPath, destPath, context); } else if (entry.name.endsWith(".hbs")) { const templateContent = fs.readFileSync(srcPath, "utf-8"); const template = Handlebars.compile(templateContent); const output = template(context); fs.writeFileSync(destPath, output); } else { fs.copyFileSync(srcPath, destPath); } } } function parsePrimitives(input) { const selected = input.split(",").map((s) => s.trim().toLowerCase()); const invalid = selected.filter((s) => !VALID_PRIMITIVES.includes(s)); if (invalid.length > 0) { logError(`Unknown primitives: ${invalid.join(", ")}`); logInfo(`Available primitives: ${VALID_PRIMITIVES.join(", ")}`); process.exit(1); } return selected; } export function describeNoAppsHome(cwd, tenant, baseDir, test) { if (test || isInsidePlatformMonorepo(cwd)) { return { message: `Tenant directory not found: ${baseDir}/${tenant}/`, hint: "Create the tenant directory first, or run from within it.", }; } return { message: `This directory is not a Mesh apps repo — no apps/ here${fs.existsSync(path.join(cwd, ".git")) ? "" : " and it is not a git repo"}.`, hint: `Run: mesh init (turns this folder into your ${tenant}-mesh-apps repo), or cd into that repo and re-run mesh create-app.`, }; } export function resolveAppDir(cwd, tenant, name, test) { const baseDir = test ? "tests/tenants" : "tenants"; const possiblePaths = test ? [path.join(cwd, baseDir, tenant, "apps")] : [ path.join(cwd, baseDir, tenant, "apps"), path.join(cwd, "..", tenant, "apps"), path.join(cwd, "apps"), ]; let appsDir = possiblePaths.find((p) => fs.existsSync(p)); if (!appsDir) { appsDir = path.join(cwd, baseDir, tenant, "apps"); if (!fs.existsSync(path.join(cwd, baseDir, tenant))) { const { message, hint } = describeNoAppsHome(cwd, tenant, baseDir, test); logError(message); logInfo(hint); process.exit(1); } if (!fs.existsSync(appsDir)) { logInfo(`Creating apps directory: ${appsDir}`); fs.mkdirSync(appsDir, { recursive: true }); } } return path.join(appsDir, name); } export function generateComposableApp(appDir, context) { const fragmentsDir = path.join(packageRoot, "fragments"); const baseDir = path.join(fragmentsDir, "base"); if (!fs.existsSync(baseDir)) { logError(`Fragments directory not found: ${baseDir}`); logInfo("Make sure the mesh-cli package is installed correctly."); process.exit(1); } copyTemplate(baseDir, appDir, context); if (context.service) { const serviceDir = path.join(fragmentsDir, "service"); if (!fs.existsSync(serviceDir)) { throw new Error(`Fragment directory not found: ${serviceDir}`); } copyTemplate(serviceDir, appDir, context); } if (context.database) { const dbDir = path.join(fragmentsDir, "database"); if (!fs.existsSync(dbDir)) { throw new Error(`Fragment directory not found: ${dbDir}`); } copyTemplate(dbDir, appDir, context); } if (context.temporal) { const temporalDir = path.join(fragmentsDir, "temporal"); if (!fs.existsSync(temporalDir)) { throw new Error(`Fragment directory not found: ${temporalDir}`); } copyTemplate(temporalDir, appDir, context); } if (context.bucket) { const bucketDir = path.join(fragmentsDir, "bucket"); if (!fs.existsSync(bucketDir)) { throw new Error(`Fragment directory not found: ${bucketDir}`); } copyTemplate(bucketDir, appDir, context); } const agentsDir = path.join(fragmentsDir, "agents"); if (fs.existsSync(agentsDir)) { copyTemplate(agentsDir, appDir, context); } } export function registerCreateAppCommand(program) { program .command("create-app") .description("Scaffold a new tenant application") .option("--tenant <tenant>", "Tenant name (e.g., acme)") .option("--name <name>", "Application name (e.g., billing)") .option("--template <template>", `Legacy template: ${Object.keys(TEMPLATES).join(", ")}`) .option("--primitives <primitives>", `Comma-separated primitives: ${VALID_PRIMITIVES.join(", ")} (default: service)`) .option("--test", "Create in tests/tenants/ directory", false) .option("--skip-registry-check", "Scaffold without checking that this machine can install @mesh-tech packages (offline use; run `mesh registry login` before `pnpm install`)", false) .action(async (options) => { await ensureRegistryAccess(process.cwd(), options); if (options.template) { if (!options.tenant || !options.name) { logError("--tenant and --name are required with --template"); process.exit(1); } await runLegacyTemplate(options.tenant, options.name, options.template, options.test); return; } let tenant; let name; let primitives; const needsPrompt = !options.tenant || !options.name || !options.primitives; if (needsPrompt && isInteractive()) { const prompted = await promptForOptions(options); tenant = prompted.tenant; name = prompted.name; primitives = prompted.primitives; } else if (needsPrompt) { options.tenant ??= findMeshJson(process.cwd())?.data.tenant; if (!options.tenant) { logError("--tenant is required (no TTY for interactive mode)"); process.exit(1); } if (!options.name) { logError("--name is required (no TTY for interactive mode)"); process.exit(1); } tenant = options.tenant; name = options.name; primitives = options.primitives ? parsePrimitives(options.primitives) : ["service"]; } else { tenant = options.tenant; name = options.name; primitives = parsePrimitives(options.primitives); } if (primitives.includes("database") && !primitives.includes("service")) { primitives.push("service"); logInfo("Added 'service' — database files live in the api/ directory."); } if (primitives.includes("bucket") && !primitives.includes("service")) { primitives.push("service"); logInfo("Added 'service' — bucket requires an API service for S3 helpers."); } await runComposable(tenant, name, primitives, options.test); }); } export const NO_REGISTRY_ACCESS_MESSAGE = "No registry access on this machine — @mesh-tech packages cannot be installed.\n" + " Run: mesh init\n" + " (sets up your tenant and signs you in to the package registry)\n" + " Scaffolding offline anyway? add --skip-registry-check"; export async function ensureRegistryAccess(cwd, options, preflight = registryPreflight) { if (isInsidePlatformMonorepo(cwd)) return; if (options.skipRegistryCheck) { logWarn("Skipping the registry check — `pnpm install` will need `mesh registry login` first."); return; } const pf = await preflight(); if (pf.state === "unreachable") { logWarn(`Could not reach the package registry to verify access (${pf.detail ?? "unknown error"}) — continuing.`); return; } if (pf.state !== "valid") { throw new MeshCliError(NO_REGISTRY_ACCESS_MESSAGE); } logSuccess(`Registry access OK (${pf.email ?? "token valid"})`); } async function runLegacyTemplate(tenant, name, template, test) { if (!Object.keys(TEMPLATES).includes(template)) { const removed = REMOVED_TEMPLATES[template]; logError(`Unknown template: ${template}${removed ? ` (${removed})` : ""}`); logInfo(`Available templates:`); for (const [tplName, desc] of Object.entries(TEMPLATES)) { console.log(` ${tplName.padEnd(20)} - ${desc}`); } process.exit(1); } logInfo(`Creating app '${name}' for tenant '${tenant}'...`); logInfo(`Template: ${template} (${TEMPLATES[template]})`); if (test) { logInfo("Creating in tests/ directory"); } maybeBootstrapAppsRepo(process.cwd(), tenant, test); const appDir = resolveAppDir(process.cwd(), tenant, name, test); if (fs.existsSync(appDir)) { logError(`App directory already exists: ${appDir}`); process.exit(1); } const templateDir = path.join(packageRoot, "templates", template); if (!fs.existsSync(templateDir)) { logError(`Template not found: ${templateDir}`); process.exit(1); } fs.mkdirSync(appDir, { recursive: true }); try { copyTemplate(templateDir, appDir, { name, tenant }); logSuccess(`Created app at ${appDir}`); } catch (error) { logError(`Failed to create app: ${error}`); fs.rmSync(appDir, { recursive: true, force: true }); process.exit(1); } autoSyncSkills(appDir, name); await checkScaffoldContract(appDir); printLegacyNextSteps(appDir, template); } function autoSyncSkills(appDir, appName) { const root = resolveTargetRoot(appDir); try { syncSkills(root); } catch (err) { logWarn(`Agent-skill sync skipped: ${err instanceof Error ? err.message : err} — run: mesh skills sync`); } try { const seeded = seedAppSkill(root, appName, path.relative(root, appDir)); if (seeded) logSuccess(`seeded: ${seeded} (yours to grow — not managed by mesh skills sync)`); } catch (err) { logWarn(`App skill stub skipped: ${err instanceof Error ? err.message : err}`); } } async function checkScaffoldContract(appDir) { const root = resolveTargetRoot(appDir); const report = await runAppCheck(root, [path.relative(root, appDir) || "."]); if (report.status === "ok") { logSuccess("app contract: met (mesh app check)"); return; } console.log(renderReport(report, { verbose: false })); if (report.status === "warn") { logSuccess("app contract: met — the ⚠ lines above are advisories on the repo, not on this app (mesh app check)"); return; } if (report.status === "error") { logError("the scaffold does not meet the Mesh app contract — this is a template defect; file a platform item and cite the ✗ lines above"); process.exitCode = 1; } } async function runComposable(tenant, name, primitives, test) { logInfo(`Creating app '${name}' for tenant '${tenant}'...`); logInfo(`Primitives: ${primitives.join(", ")}`); if (test) { logInfo("Creating in tests/ directory"); } maybeBootstrapAppsRepo(process.cwd(), tenant, test); const appDir = resolveAppDir(process.cwd(), tenant, name, test); if (fs.existsSync(appDir)) { logError(`App directory already exists: ${appDir}`); process.exit(1); } const region = "us-east-2"; const platformName = "mesh"; const platformEnv = "dev"; const workspaceDeps = isInsidePlatformMonorepo(appDir); const repoRoot = resolveTargetRoot(process.cwd()); const mesh = workspaceDeps ? { range: "", resolved: true } : resolvePublishedRange("@mesh-tech/app-kit", repoRoot); const esmBundle = workspaceDeps ? { range: "", resolved: true } : resolvePublishedRange("@mesh-tech/esm-bundle", repoRoot); if (!workspaceDeps) { logInfo(mesh.resolved ? `@mesh-tech/* pinned to ${mesh.range} (current published line)` : `Registry lookup unavailable — pinning @mesh-tech/* to ${mesh.range}; run \`pnpm up @mesh-tech/*\` once you have registry auth`); } const context = { name, tenant, service: primitives.includes("service"), database: primitives.includes("database"), temporal: primitives.includes("temporal"), bucket: primitives.includes("bucket"), region, workspaceDeps, meshRange: mesh.range, esmBundleRange: esmBundle.range, typesNodeRange: SCAFFOLD_TOOLCHAIN["@types/node"], tsxRange: SCAFFOLD_TOOLCHAIN.tsx, typescriptRange: SCAFFOLD_TOOLCHAIN.typescript, deployerRoleArn: resolveDeployerRoleArn(tenant, platformName, platformEnv), }; fs.mkdirSync(appDir, { recursive: true }); try { generateComposableApp(appDir, context); logSuccess(`Created app at ${appDir}`); } catch (error) { logError(`Failed to create app: ${error}`); fs.rmSync(appDir, { recursive: true, force: true }); process.exit(1); } const nested = fs .readdirSync(appDir, { withFileTypes: true }) .some((entry) => entry.isDirectory() && fs.existsSync(path.join(appDir, entry.name, "package.json"))); const glob = workspaceGlobForApp(repoRoot, appDir); if (nested && glob) { const added = ensureWorkspaceGlobs(repoRoot, [glob]); if (added.length > 0) { logInfo(`pnpm-workspace.yaml: added ${added.join(", ")} so the app's api/worker packages install`); } } autoSyncSkills(appDir, name); await checkScaffoldContract(appDir); printComposableNextSteps(appDir, context); } function printLegacyNextSteps(appDir, _template) { console.log(""); logSuccess("App created successfully!"); console.log(""); console.log("Next steps:"); console.log(` cd ${appDir}`); console.log(" pnpm install"); console.log(" pnpm mesh dev"); console.log(""); console.log("To deploy:"); console.log(" pnpm mesh deploy up"); console.log(""); } function printComposableNextSteps(appDir, context) { console.log(""); logSuccess("App created successfully!"); console.log(""); console.log("Generated:"); console.log(" index.ts Pulumi app definition"); console.log(" AGENTS.md AI agent instructions"); console.log(" CLAUDE.md Bridges AGENTS.md into Claude Code (@AGENTS.md)"); if (context.service) { console.log(" api/ HTTP service (Hono)"); } if (context.temporal) { console.log(" worker/ Temporal worker"); } if (context.database) { console.log(" prisma/ Database schema"); } if (context.bucket) { console.log(" api/src/storage.ts S3 helpers"); } console.log(""); for (const line of composableNextSteps(appDir, findMeshJson(process.cwd())?.data.platform ?? null)) { console.log(line); } } export function composableNextSteps(appDir, platform) { const common = [ "Next steps:", ` cd ${appDir}`, " pnpm install", " mesh skills sync # re-run once deps are installed: picks up package skills + Intent", " pnpm install # again only if skills sync reports it added @tanstack/intent", ]; const local = [ " mesh start # the local Mesh platform (once; Docker — from anywhere)", " mesh dev # run the app against it", ]; const deploy = [ " mesh stack init # personal dev stack (deploy: false)", " mesh deploy up --yes # deploy via the stack's deployer role", ]; if (platform === null || platform === "local") { return [...common, ...local, "", "When you deploy to a Mesh platform:", ...deploy]; } return [...common, ...deploy, " mesh dev # run locally against the platform"]; }