@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
704 lines (703 loc) • 28.9 kB
JavaScript
import * as fs from "node:fs";
import * as path from "node:path";
import { logError, logInfo } from "../utils/index.js";
import { MeshCliError, emitJsonPayload } from "../utils/errors.js";
import { resolveTargetRoot } from "./skills.js";
import { ICONS, aggregateStatus, jsonReport, renderHuman, runChecks, } from "./dev-doctor.js";
export const APP_CHECK_RULES = {
UI_IN_API: {
gate: "0.1",
title: "UI is its own service",
severity: "block",
remediation: 'move the pages to apps/<app>/ui and declare new mesh.apps.Service("ui", { env, src: "./ui", … })',
scope: "app",
},
NO_UI_SERVICE: {
gate: "0.1",
title: "UI service declared",
severity: "block",
remediation: 'add apps/<app>/ui + new mesh.apps.Service("ui", …) in index.ts',
scope: "app",
},
NO_REGISTER: {
gate: "0.2",
title: "App registers with the Hub",
severity: "block",
remediation: "end index.ts with `export const app = env.register({ description })`",
scope: "app",
},
CUSTOM_SESSION_AUTH: {
gate: "0.3",
title: "Sign-in through the platform",
severity: "block",
remediation: 'delete the OIDC + cookie code and protect the UI service with auth: { provider: "mesh" }',
scope: "app",
},
NON_PLATFORM_LOGGER: {
gate: "0.4",
title: "Logs through @mesh-tech/logger",
severity: "block",
remediation: "import { createLogger } from \"@mesh-tech/logger\" and log through it (requestLogger for HTTP)",
scope: "app",
},
LOCAL_AUTH_LIB: {
gate: "0.5",
title: "No tenant-local auth library",
severity: "advisory",
remediation: "check whether @mesh-tech/authn already owns this; if the platform lacks it, file a platform item",
scope: "repo",
},
NO_AUTHZ_POINTER: {
gate: "H",
title: "Access surfaced through the Hub",
severity: "block",
remediation: 'end index.ts with new mesh.auth.AppAuthzPointer("authz", { env, mode, opsHubMetadata: compileOpsHubMetadata(schemaDef) }) (role-gating) or { env, mode, opsHubMetadataRef: schema.opsHubMetadataRef } (policy-engine)',
scope: "app",
},
METADATA_UNPUBLISHED: {
gate: "H",
title: "Ops-hub metadata published",
severity: "block",
remediation: "export compileOpsHubMetadata(schemaDef) with mesh.exports.Export (role-gating) or let SpiceDBSchema publish it (policy-engine)",
scope: "app",
},
IAC_GRANTS: {
gate: "H",
title: "No role grants in IaC",
severity: "block",
remediation: "remove the per-user grant map; grant roles from the Hub (App → Access → People)",
scope: "app",
},
POINTER_PROJECT_OVERRIDE: {
gate: "H",
title: "Pointer publishes the env's project",
severity: "advisory",
remediation: "drop `zitadel:` from AppAuthzPointer and pass `zitadelAppProjectId: identity.projectId` to AppEnvironment — the override is the Hub's",
scope: "app",
},
PASSWORD_STORE: {
gate: "H",
title: "No app-side credentials",
severity: "block",
remediation: "delete the password code; the Hub creates sign-in users with a one-time temporary password",
scope: "app",
},
EMAIL_ALLOWLIST: {
gate: "H",
title: "No email allowlist for roles",
severity: "block",
remediation: "declare a role on the ZitadelAppIdentity and let the Hub grant it",
scope: "app",
},
USERS_TABLE: {
gate: "H",
title: "No hand-rolled users/roles table",
severity: "block",
remediation: "drop the table; people and roles live in Zitadel and are administered from the Hub",
scope: "app",
},
DOCS_SITE_UNAUTHED: {
gate: "0.3",
title: "Docs site behind the platform sign-in",
severity: "block",
remediation: 'on an API surface: docs: { auth: { clientId, clientSecret } } (or a client on surfaces.http.auth), or opt out with docs: { site: false }; on a mesh.apps.ApiDocs: auth: { provider: "mesh", clientId, clientSecret }, or drop the ingress (an in-cluster site is reachable through mesh dev)',
scope: "app",
},
DOCS_AUDIENCE_IMPLICIT: {
gate: "H",
title: "Docs audience stated, not derived",
severity: "block",
remediation: 'declare the surface as mesh.apps.AppApiSurface or VendorApiSurface (or pass kind: "app" | "vendor"), or state docs: { audience: { roles: […] } }',
scope: "app",
},
};
export const APP_CHECK_CODES = Object.keys(APP_CHECK_RULES);
const SRC_EXT = new Set([".ts", ".tsx", ".js", ".mjs", ".jsx"]);
const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".generated", "test", "tests", "__tests__", "fixtures", "scripts"]);
const NON_SERVICE_DIRS = new Set([
"docs",
"scripts",
"lib",
"libs",
"migrations",
"test",
"tests",
"node_modules",
"infra",
"mocks",
"skills",
"prisma",
]);
function rel(root, abs) {
return path.relative(root, abs).split(path.sep).join("/");
}
function isDir(p) {
try {
return fs.statSync(p).isDirectory();
}
catch {
return false;
}
}
function readText(p) {
try {
return fs.readFileSync(p, "utf8");
}
catch {
return "";
}
}
export function srcFiles(dir) {
const out = [];
const walk = (d) => {
let entries;
try {
entries = fs.readdirSync(d, { withFileTypes: true });
}
catch {
return;
}
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name) || e.name.startsWith("scenario"))
continue;
walk(p);
continue;
}
if (!e.isFile())
continue;
if (!SRC_EXT.has(path.extname(e.name)))
continue;
if (/\.(test|spec)\./.test(e.name) || /\.config\./.test(e.name) || e.name === "build.mjs")
continue;
out.push(p);
}
};
walk(dir);
return out.sort();
}
function isTestHarness(name) {
return name.startsWith("test-") || name.endsWith("-test");
}
export function isUiDir(name) {
return ["ui", "web", "frontend", "client", "portal-ui", "app"].includes(name) || name.endsWith("-ui");
}
function serviceDirs(app) {
let entries;
try {
entries = fs.readdirSync(app, { withFileTypes: true });
}
catch {
return [];
}
return entries
.filter((e) => e.isDirectory() && !NON_SERVICE_DIRS.has(e.name) && !e.name.startsWith(".") && !isTestHarness(e.name))
.map((e) => path.join(app, e.name))
.sort();
}
function authzSurface(app) {
const hits = [];
const walk = (d) => {
let entries;
try {
entries = fs.readdirSync(d, { withFileTypes: true });
}
catch {
return;
}
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) {
if (["node_modules", "dist", "build"].includes(e.name))
continue;
walk(p);
continue;
}
if (!e.isFile())
continue;
if (!/\.(ts|tsx|yaml|sql)$/.test(e.name) || /\.(test|spec)\.tsx?$/.test(e.name) || e.name.endsWith(".d.ts"))
continue;
const lines = readText(p).split("\n");
lines.forEach((text, i) => {
if (/^\s*(\/\/|\*|\/\*|#|--)/.test(text))
return;
hits.push({ file: p, line: i + 1, text });
});
}
};
walk(app);
return hits;
}
const RE_HTML = /from ["']hono\/(html|jsx)["']|react-dom\/server|\bc\.html\(| f.code === code);
if (mine.length === 0)
return { status: "ok", summary: `gate ${rule.gate}: no ${code} finding` };
return {
status: rule.severity === "block" ? "error" : "warn",
summary: `gate ${rule.gate}: ${code} × ${mine.length}`,
detail: mine.map((f) => `${f.path} — ${f.why}`).join("\n "),
remediation: rule.remediation,
};
}
export function appContractChecks(scope) {
return APP_CHECK_CODES.filter((code) => APP_CHECK_RULES[code].scope === scope).map((code) => ({
id: code,
title: `${APP_CHECK_RULES[code].title} (${APP_CHECK_RULES[code].gate})`,
phases: ["ondemand", "preflight"],
run: async (ctx) => resultFor(code, ctx.findings),
}));
}
export async function runAppCheck(root, apps) {
if (!isDir(root))
throw new MeshCliError(`no such checkout: ${root}`);
const targets = apps && apps.length > 0 ? apps.map((a) => a.replace(/\/+$/, "")) : listApps(root);
for (const a of targets) {
if (!isDir(path.resolve(root, a)))
throw new MeshCliError(`no such app dir: ${a}`);
}
const scopes = [];
for (const app of targets) {
const results = await runChecks({ root, app, findings: scanApp(root, app) }, "ondemand", appContractChecks("app"));
scopes.push({ scope: app, ...jsonReport(results) });
}
const repoResults = await runChecks({ root, app: null, findings: scanRepo(root) }, "ondemand", appContractChecks("repo"));
scopes.push({ scope: "(repo)", ...jsonReport(repoResults) });
return { root, status: aggregateStatus(scopes.map((s) => ({ status: s.status, summary: "" }))), scopes };
}
export function renderReport(report, opts) {
const out = [];
for (const scope of report.scopes) {
const shown = opts.verbose ? scope.checks : scope.checks.filter((c) => c.status !== "ok");
if (shown.length === 0 && !opts.verbose)
continue;
out.push(`${ICONS[scope.status]} ${scope.scope}`);
out.push(renderHuman(shown.map((c) => ({
check: { id: c.id, title: `${c.id} — ${APP_CHECK_RULES[c.id]?.title ?? c.id}` },
result: { status: c.status, summary: c.summary, remediation: c.remediation ?? undefined, detail: c.detail ?? undefined },
})))
.split("\n")
.map((l) => ` ${l}`)
.join("\n"));
}
out.push(`${ICONS[report.status]} app contract: ${report.status}`);
return out.filter(Boolean).join("\n");
}
export function registerAppCommands(program) {
const app = program.command("app").description("Inspect a tenant app against the Mesh app contract");
app
.command("check")
.description("Check tenant apps against the Mesh app contract (@mesh-tech/app-kit skills/apps/references/app-contract.md): a UI is its own service, the app registers with the Hub, sign-in goes through the platform proxy, logs go through @mesh-tech/logger, and people/roles/keys are surfaced through the Hub. Exit 1 on any blocking finding — the same gate mesh create-app, tenant CI and the platform reviewer run.")
.argument("[apps...]", "repo-relative app dirs to check (default: every apps/* with a Pulumi program)")
.option("--root <dir>", "checkout to scan (default: the enclosing git root)")
.option("--json", "machine-readable output (one report per app plus the repo-scoped checks)", false)
.option("--verbose", "also print the gates that passed", false)
.action(async (apps, opts) => {
const root = path.resolve(opts.root ?? resolveTargetRoot());
const report = await runAppCheck(root, apps);
if (opts.json) {
emitJsonPayload({ ok: report.status !== "error", ...report });
}
else {
if (report.scopes.length === 1)
logInfo(`no apps/* with a Pulumi program under ${root}`);
console.log(renderReport(report, { verbose: opts.verbose }));
}
if (report.status === "error") {
if (!opts.json)
logError("the app contract is not met — every ✗ above names the gate, the file and the replacement");
process.exitCode = 1;
}
});
}