UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

251 lines (250 loc) 11.3 kB
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(); })()); }