@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
251 lines (250 loc) • 11.3 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { createRequire } from "module";
import { pathToFileURL } from "url";
import { logError, logInfo, logSuccess } from "../utils/log.js";
import { cliReferenceStaleness, runPortalCommand } from "../docs/portal.js";
import { findRepoRoot } from "../utils/workflow-fingerprint.js";
function findProjectRoot(start = process.cwd()) {
let dir = start;
while (true) {
if (fs.existsSync(path.join(dir, "package.json")))
return dir;
const parent = path.dirname(dir);
if (parent === dir)
return start;
dir = parent;
}
}
function resolvePath(projectRoot, candidate) {
return path.isAbsolute(candidate) ? candidate : path.join(projectRoot, candidate);
}
function ensureParentDir(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function readConfig(configPath) {
if (!fs.existsSync(configPath)) {
throw new Error(`Docs config not found: ${configPath}`);
}
const raw = fs.readFileSync(configPath, "utf-8");
const parsed = JSON.parse(raw);
if (parsed.openapi && !Array.isArray(parsed.openapi)) {
throw new Error(`Invalid docs config: 'openapi' must be an array`);
}
return parsed;
}
function validateOpenApiSpec(specPath) {
const content = fs.readFileSync(specPath, "utf-8");
const parsed = JSON.parse(content);
if (typeof parsed.openapi !== "string") {
throw new Error(`Invalid OpenAPI spec (${specPath}): missing 'openapi'`);
}
if (!parsed.info || typeof parsed.info !== "object") {
throw new Error(`Invalid OpenAPI spec (${specPath}): missing 'info' object`);
}
if (!parsed.paths || typeof parsed.paths !== "object") {
throw new Error(`Invalid OpenAPI spec (${specPath}): missing 'paths' object`);
}
}
async function loadSymxchangeGenerator(projectRoot) {
const requireFromProject = createRequire(path.join(projectRoot, "package.json"));
const entryPath = requireFromProject.resolve("@mesh-tech/jh-symitar-api");
const mod = await import(pathToFileURL(entryPath).href);
const fn = mod.generateSymxchangeOpenApi;
if (typeof fn !== "function") {
throw new Error("@mesh-tech/jh-symitar-api does not export generateSymxchangeOpenApi()");
}
return {
generateSymxchangeOpenApi: fn,
};
}
async function buildDocs(configPathArg) {
const cwd = process.cwd();
const configPath = path.isAbsolute(configPathArg ?? "")
? configPathArg
: path.resolve(cwd, configPathArg ?? "docs/docs.config.json");
const projectRoot = findProjectRoot(path.dirname(configPath));
const config = readConfig(configPath);
logInfo(`Using docs config: ${configPath}`);
const generatedSpecs = new Set();
for (const task of config.openapi ?? []) {
if (task.kind === "file") {
const inputPath = resolvePath(projectRoot, task.input);
const outputPath = resolvePath(projectRoot, task.output ?? task.input);
if (!fs.existsSync(inputPath)) {
throw new Error(`OpenAPI source file not found: ${inputPath}`);
}
if (inputPath !== outputPath) {
ensureParentDir(outputPath);
fs.copyFileSync(inputPath, outputPath);
logSuccess(`Copied OpenAPI spec → ${path.relative(projectRoot, outputPath)}`);
}
else {
logInfo(`Using OpenAPI spec: ${path.relative(projectRoot, inputPath)}`);
}
validateOpenApiSpec(outputPath);
generatedSpecs.add(outputPath);
continue;
}
if (task.kind === "symxchange") {
const outputPath = resolvePath(projectRoot, task.output);
const { generateSymxchangeOpenApi } = await loadSymxchangeGenerator(projectRoot);
const spec = await generateSymxchangeOpenApi({
enabled: task.enabled,
title: task.title,
version: task.version,
description: task.description,
basePath: task.basePath,
servers: task.servers,
});
ensureParentDir(outputPath);
fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2));
validateOpenApiSpec(outputPath);
generatedSpecs.add(outputPath);
logSuccess(`Generated SymXchange OpenAPI → ${path.relative(projectRoot, outputPath)}`);
continue;
}
const exhaustive = task;
throw new Error(`Unsupported OpenAPI task kind: ${exhaustive.kind}`);
}
for (const relPath of config.validate ?? []) {
const abs = resolvePath(projectRoot, relPath);
if (!fs.existsSync(abs)) {
throw new Error(`OpenAPI spec to validate not found: ${abs}`);
}
validateOpenApiSpec(abs);
generatedSpecs.add(abs);
logSuccess(`Validated OpenAPI spec: ${path.relative(projectRoot, abs)}`);
}
if (generatedSpecs.size === 0) {
logInfo("No docs tasks configured (nothing to do)");
return;
}
logSuccess(`Docs build complete (${generatedSpecs.size} OpenAPI file${generatedSpecs.size === 1 ? "" : "s"})`);
}
const CLI_REFERENCE_PATH = "docs/portal/generated/cli-reference.md";
async function renderCliReference() {
const [{ createProgram }, { extractCliReference, renderCliReferenceMarkdown }] = await Promise.all([
import("../program.js"),
import("../docs/cli-reference.js"),
]);
return renderCliReferenceMarkdown(extractCliReference(createProgram()));
}
function guarded(action) {
return async () => {
try {
await action();
}
catch (error) {
logError(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
};
}
async function runCliReference(opts) {
const repoRoot = findRepoRoot(process.cwd());
const outPath = resolvePath(repoRoot, opts.out);
const markdown = await renderCliReference();
if (opts.check) {
const existing = fs.existsSync(outPath) ? fs.readFileSync(outPath, "utf-8") : null;
if (existing === markdown) {
logSuccess(`CLI reference is up to date (${path.relative(repoRoot, outPath)})`);
return;
}
throw new Error(`${existing === null ? "CLI reference is missing" : "CLI reference is stale"}: ${path.relative(repoRoot, outPath)}\n` +
` The commander tree changed. Regenerate and commit:\n` +
` pnpm exec mesh docs cli-reference`);
}
ensureParentDir(outPath);
fs.writeFileSync(outPath, markdown);
logSuccess(`CLI reference → ${path.relative(repoRoot, outPath)}`);
}
async function runPortal(opts) {
const repoRoot = findRepoRoot(process.cwd());
await runPortalCommand({
out: opts.out,
assembleOnly: opts.assembleOnly,
serve: opts.serve,
port: opts.port,
check: opts.check,
printManifest: opts.printManifest,
diffBase: opts.diffBase,
manifestOut: opts.manifestOut,
}, { repoRoot, checkCliReference: () => cliReferenceStaleness(repoRoot) });
}
export function registerDocsCommand(program) {
const docs = program
.command("docs")
.description("Mesh documentation — the developer portal, the CLI reference, and app API docs");
docs
.command("build")
.description("Generate app docs artifacts (OpenAPI specs, composed specs)")
.option("-c, --config <path>", "Path to docs config file", "docs/docs.config.json")
.action(async (opts) => {
try {
await buildDocs(opts.config);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
logError(message);
process.exitCode = 1;
}
});
docs
.command("cli-reference")
.description("Regenerate the `mesh` CLI reference markdown from the command tree")
.option("-o, --out <path>", "output markdown path", CLI_REFERENCE_PATH)
.option("--check", "verify only (CI): exit 1 when the committed reference is stale", false)
.action((opts) => guarded(() => runCliReference(opts))());
docs
.command("portal")
.description("Assemble the Mesh developer portal from docs.json-discovered repo docs, then build/serve it with Zudoku")
.option("-o, --out <dir>", "assemble into this content directory and stop (implies --assemble-only)")
.option("--assemble-only", "write the content tree + generated zudoku.config.ts, then stop", false)
.option("--serve", "run `zudoku dev` against the assembled content (local preview)", false)
.option("-p, --port <port>", "port for --serve", "3000")
.option("--check", "CI gate: schema, reserved names, links, and CLI-reference staleness — no build", false)
.option("--print-manifest", "print the publish manifest (every published source path) to stdout", false)
.option("--diff-base <ref>", "also diff the publish set against a git ref (PR job summary)")
.option("--manifest-out <path>", "with --check: also write publish-manifest.json to this path")
.action((opts) => guarded(() => runPortal(opts))());
docs
.command("start")
.description("Serve the Mesh docs locally — detached in a tmux session by default; the working tree in a mesh-platform checkout, or the published @mesh-tech/docs artifact (role-gated registry) anywhere else")
.option("-v, --version <version>", "docs version to serve (== the @mesh-tech/* baseline it describes); default: latest", undefined)
.option("-p, --port <port>", "port to serve on (0 picks a free one)", "4400")
.option("--dev", "in a checkout: run the Zudoku dev server (HMR) in the foreground, for authoring", false)
.option("--foreground", "serve in the foreground (the agent/CI path; automatic when not a TTY)", false)
.action((opts) => guarded(async () => {
const { runDocsStart } = await import("../docs/start.js");
await runDocsStart({
repoRoot: findRepoRoot(process.cwd()),
version: opts.version,
port: opts.port,
dev: opts.dev,
foreground: opts.foreground,
});
})());
docs
.command("stop")
.description(`Stop the detached docs server (the tmux session "${"mesh-docs"}")`)
.action(() => guarded(async () => {
const { runDocsStop } = await import("../docs/start.js");
runDocsStop();
})());
docs
.command("serve-static", { hidden: true })
.option("--root <dir>", "built site root", "")
.option("--port <port>", "port", "4400")
.action((opts) => guarded(async () => {
const { runDocsServeStatic } = await import("../docs/start.js");
await runDocsServeStatic(opts);
})());
docs
.command("list")
.description("List the published docs versions (each is an @mesh-tech/* baseline)")
.action(() => guarded(async () => {
const { runDocsList } = await import("../docs/start.js");
await runDocsList();
})());
}