UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

244 lines (243 loc) 10.5 kB
import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, writeFileSync } from "node:fs"; import path from "node:path"; import { logError, logInfo, logSuccess } from "../utils/log.js"; import { resolveCliRuntime } from "../utils/build-info.js"; import { discoverDocRoots } from "./discover.js"; import { runAssemble, reportAssembly, DOCS_APP_DIR } from "./portal.js"; import { serveDocsSite } from "./serve.js"; import { DOCS_PACKAGE, fetchDocsArtifact, listDocsVersions, readDocsRegistryAuth, } from "./registry-docs.js"; export const DOCS_TMUX_SESSION = "mesh-docs"; function registryAuthOrThrow() { const auth = readDocsRegistryAuth(); if (!auth) { throw new Error("no CodeArtifact token in ~/.npmrc — run `mesh registry login` first " + "(the docs artifact is role-gated the same way the packages are)."); } return auth; } export async function runDocsList() { const auth = registryAuthOrThrow(); const { versions, latest } = await listDocsVersions(auth); if (versions.length === 0) { logInfo(`No ${DOCS_PACKAGE} versions published yet.`); return; } logInfo(`Published docs versions (docs version == @mesh-tech/* baseline):`); for (const version of [...versions].reverse()) { const marker = version === latest ? " ← latest" : ""; process.stdout.write(` ${version}${marker}\n`); } } export function tmuxInstallHint(platform = process.platform) { if (platform === "darwin") return "brew install tmux"; if (platform === "linux") return "sudo apt-get install tmux # or your distro's package manager"; return "install tmux via your package manager"; } export function tmuxAvailable() { try { execFileSync("tmux", ["-V"], { stdio: ["pipe", "pipe", "pipe"] }); return true; } catch { return false; } } export function shouldDetach(args) { return !args.foreground && !args.dev && args.isTTY; } export function docsSessionExists() { try { execFileSync("tmux", ["has-session", "-t", DOCS_TMUX_SESSION], { stdio: ["pipe", "pipe", "pipe"] }); return true; } catch { return false; } } export function tmuxServeArgs(args) { const serveArgs = ["docs", "serve-static", "--root", args.serveRoot, "--port", String(args.port)]; if (args.mode === "dist") { return { command: ["mesh", ...serveArgs], cwd: args.repoRoot }; } return { command: ["pnpm", "exec", "mesh", ...serveArgs], cwd: args.repoRoot }; } export async function startDetached(args) { if (!tmuxAvailable()) { throw new Error(`tmux is not installed — the detached docs server runs in a tmux session named "${DOCS_TMUX_SESSION}".\n` + `Install it with: ${tmuxInstallHint()}\n` + `Or run in the foreground instead: mesh docs start --foreground`); } if (docsSessionExists()) { logInfo(`Replacing the docs server already running in tmux session "${DOCS_TMUX_SESSION}".`); try { execFileSync("tmux", ["kill-session", "-t", DOCS_TMUX_SESSION], { stdio: ["pipe", "pipe", "pipe"] }); } catch { } } const { command, cwd } = tmuxServeArgs(args); execFileSync("tmux", ["new-session", "-d", "-s", DOCS_TMUX_SESSION, "-c", cwd, "--", ...command], { stdio: ["pipe", "pipe", "pipe"] }); const deadline = Date.now() + 60_000; const url = `http://127.0.0.1:${args.port}`; let lastError = ""; while (Date.now() < deadline) { try { const response = await fetch(`${url}/healthz`); if (response.ok) { printReady(url, args.label); return; } lastError = `HTTP ${response.status}`; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error(`the docs server did not answer at ${url} within 60s (${lastError || "no response"}).\n` + `Read its log with: tmux attach -t ${DOCS_TMUX_SESSION} (detach: Ctrl-B then D)`); } function printReady(url, label) { logSuccess(`Mesh docs (${label}) are live:`); process.stdout.write(`\n ${url}\n\n`); logInfo(`Running detached in the tmux session "${DOCS_TMUX_SESSION}".`); logInfo(` attach: tmux attach -t ${DOCS_TMUX_SESSION}`); logInfo(` stop: mesh docs stop`); } export function runDocsStop() { try { execFileSync("tmux", ["kill-session", "-t", DOCS_TMUX_SESSION], { stdio: ["pipe", "pipe", "pipe"] }); logSuccess(`Stopped the docs server (tmux session "${DOCS_TMUX_SESSION}").`); } catch { logInfo(`No docs server is running (no tmux session named "${DOCS_TMUX_SESSION}").`); } } export async function runDocsServeStatic(args) { const requestedPort = Number.parseInt(args.port, 10); if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) { throw new Error(`Invalid --port: ${args.port}`); } if (!args.root || !existsSync(path.join(args.root, "index.html"))) { throw new Error(`--root ${args.root || "(empty)"} does not hold a built site (no index.html). ` + `This command exists so the detached tmux session can re-enter; you probably want \`mesh docs start\`.`); } const server = await serveDocsSite({ root: args.root, port: requestedPort }); logSuccess(`Mesh docs serving at ${server.url} (from ${args.root})`); await new Promise((resolve) => { const stop = () => void server.close().finally(() => resolve()); process.once("SIGINT", stop); process.once("SIGTERM", stop); }); } export async function runDocsStart(args) { const requestedPort = Number.parseInt(args.port, 10); if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) { throw new Error(`Invalid --port: ${args.port}`); } const detached = shouldDetach({ foreground: args.foreground, dev: args.dev, isTTY: process.stdout.isTTY === true, }); if (detached && requestedPort === 0) { throw new Error("--port 0 (pick a free port) needs a foreground server, because the detached session " + "cannot report the port it chose back to this process. Use `mesh docs start --foreground --port 0`, " + "or pass an explicit port to run detached."); } const localRoots = discoverDocRoots(args.repoRoot); const repoHasDocs = localRoots.roots.length > 0; const registryMode = args.version !== undefined || !repoHasDocs; let serveRoot; let label; if (registryMode) { const auth = registryAuthOrThrow(); const version = args.version === undefined || args.version === "latest" ? (await listDocsVersions(auth)).latest : args.version; if (!version) { throw new Error(`the registry has no ${DOCS_PACKAGE} versions tagged latest`); } logInfo(`Fetching ${DOCS_PACKAGE}@${version} from the registry…`); const artifactDir = await fetchDocsArtifact(auth, version); serveRoot = path.join(artifactDir, "dist"); label = `@mesh-tech/* ${version} (published artifact)`; } else { if (localRoots.errors.length > 0) { for (const error of localRoots.errors) logError(error); throw new Error(`docs discovery failed with ${localRoots.errors.length} problem(s)`); } const appDir = path.join(args.repoRoot, DOCS_APP_DIR); if (args.dev) { const result = runAssemble({ repoRoot: args.repoRoot, outDir: path.join(appDir, "content"), configOut: path.join(appDir, "zudoku.config.ts"), }); reportAssembly(result); const { spawn } = await import("node:child_process"); const child = spawn("pnpm", ["exec", "zudoku", "dev", "--port", String(requestedPort)], { cwd: appDir, stdio: "inherit", env: process.env, }); await new Promise((resolve) => { child.on("close", () => resolve()); process.once("SIGINT", () => child.kill("SIGINT")); process.once("SIGTERM", () => child.kill("SIGTERM")); }); return; } const buildLog = path.join(args.repoRoot, DOCS_APP_DIR, "build.log"); logInfo(`Preparing the docs from this checkout (assemble + build — log: ${path.relative(args.repoRoot, buildLog)})…`); const result = runAssemble({ repoRoot: args.repoRoot, outDir: path.join(appDir, "content"), configOut: path.join(appDir, "zudoku.config.ts"), }); reportAssembly(result); const { spawn } = await import("node:child_process"); writeFileSync(buildLog, ""); const code = await new Promise((resolve) => { const child = spawn("pnpm", ["exec", "zudoku", "build"], { cwd: appDir, stdio: ["ignore", "pipe", "pipe"], env: process.env, }); child.stdout?.on("data", (chunk) => appendFileSync(buildLog, chunk)); child.stderr?.on("data", (chunk) => appendFileSync(buildLog, chunk)); child.on("error", () => resolve(1)); child.on("close", (exitCode) => resolve(exitCode ?? 1)); }); if (code !== 0) throw new Error(`zudoku build exited with code ${code} — see ${buildLog}`); serveRoot = path.join(appDir, "dist"); label = "this checkout"; } if (detached) { const runtime = resolveCliRuntime({ vcs: false }); await startDetached({ mode: runtime.mode, repoRoot: args.repoRoot, serveRoot, port: requestedPort, label, }); return; } const server = await serveDocsSite({ root: serveRoot, port: requestedPort }); logSuccess(`Mesh docs (${label}) serving at ${server.url}`); logInfo("Press Ctrl-C to stop"); await new Promise((resolve) => { const stop = () => { void server.close().finally(() => resolve()); }; process.once("SIGINT", stop); process.once("SIGTERM", stop); }); }