UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

515 lines (507 loc) 23.2 kB
import * as fs from "fs"; import * as path from "path"; import { createRequire } from "module"; import { execFileSync } from "child_process"; import { logError, logInfo, logSuccess, logWarn } from "../utils/log.js"; const DISCOVERY_HELPERS_SRC = ` function discoverAux(filePath, source) { const dir = dirname(filePath); const aux = {}; const re = /import\\s+(?:type\\s+)?(?:(?:\\{[^}]*\\}|\\*\\s+as\\s+\\w+)(?:\\s*,\\s*)?)+\\s+from\\s+["'](\\.[^"']+)["']/g; let m; while ((m = re.exec(source)) !== null) { const spec = m[1]; if (!spec.startsWith("./") && !spec.startsWith("../")) continue; for (const c of [join(dir, spec.replace(/\\.js$/, ".ts")), join(dir, spec + ".ts")]) { if (existsSync(c) && statSync(c).isFile()) { const s = readFileSync(c, "utf-8"); if (s.includes("proxyActivities") || s.includes("@temporalio/workflow") || s.includes("wf.")) { aux[spec] = s; } break; } } } return aux; } function discoverActivities(filePath, source) { const dir = dirname(filePath); const acts = {}; const re = /import\\s+type\\s+\\{[^}]*createActivities[^}]*\\}\\s+from\\s+["'](\\.[^"']+)["']/g; let m; while ((m = re.exec(source)) !== null) { const spec = m[1]; for (const c of [join(dir, spec.replace(/\\.js$/, ".ts")), join(dir, spec + ".ts")]) { if (existsSync(c) && statSync(c).isFile()) { acts[spec] = readFileSync(c, "utf-8"); const actDir = dirname(c); try { for (const sib of readdirSync(actDir)) { if (!sib.endsWith(".ts") || sib === basename(c)) continue; const sp = join(actDir, sib); if (statSync(sp).isFile()) { acts[spec.replace(/\\/[^/]+$/, "/" + sib.replace(".ts", ".js"))] = readFileSync(sp, "utf-8"); } } } catch {} break; } } } return acts; } /** * Discover a sibling process artifact for a workflow file: a "process/" * directory containing "*.process.json" files, checked both next to the * workflow file itself AND next to its parent directory (worker packages * typically nest the workflow file under "src/", with "process/" a sibling * of "src/" at the package root, not of the file directly — e.g. * "worker/src/workflows.ts" + "worker/process/*.process.json"). Since * extractWorkflowIR itself validates the artifact (schema errors and a * workflowType mismatch both fall back with a warning, never throw — P6), * this only needs a best-effort guess at the workflow's name to avoid * bothering with an OBVIOUSLY unrelated file in a multi-workflow directory — * a quick regex for the first exported async function, same derivation * extractWorkflowIR falls back to absent an explicit \`name\` override. * Returns the parsed JSON (unknown to this script) or undefined. */ function discoverProcessArtifact(filePath, source) { const fileDir = dirname(filePath); const candidateDirs = [join(fileDir, "process"), join(fileDir, "..", "process")]; const nameMatch = source.match(/export\\s+async\\s+function\\s+(\\w+)/); const guessedName = nameMatch ? nameMatch[1] : undefined; for (const procDir of candidateDirs) { if (!existsSync(procDir) || !statSync(procDir).isDirectory()) continue; for (const entry of readdirSync(procDir)) { if (!entry.endsWith(".process.json")) continue; const entryPath = join(procDir, entry); if (!statSync(entryPath).isFile()) continue; try { const parsed = JSON.parse(readFileSync(entryPath, "utf-8")); if (!guessedName || parsed?.process?.workflowType === guessedName) { return parsed; } } catch { // Not valid JSON — skip; extractWorkflowIR would reject it anyway. } } } return undefined; } `; function resolveExtractorPath() { try { const require = createRequire(import.meta.url); const resolved = require.resolve("@mesh-tech/workflow-viz/ast-extractor"); return resolved; } catch { return null; } } function runExtraction(targetPath, extractorPath, explicitProcessPath) { let explicitProcessArtifact; if (explicitProcessPath) { try { explicitProcessArtifact = JSON.parse(fs.readFileSync(explicitProcessPath, "utf-8")); } catch (err) { logError(`Could not read/parse --process ${explicitProcessPath}: ${err instanceof Error ? err.message : String(err)}`); return null; } } const script = ` import { extractWorkflowIR } from "${extractorPath.replace(/\\/g, "/")}"; import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; import { basename, dirname, join } from "node:path"; ${DISCOVERY_HELPERS_SRC} const target = ${JSON.stringify(targetPath)}; const explicitProcessArtifact = ${JSON.stringify(explicitProcessArtifact ?? null)}; const stat = statSync(target); const files = stat.isDirectory() ? readdirSync(target).filter(f => f.endsWith(".ts") && f !== "index.ts").map(f => join(target, f)) : [target]; const results = []; for (const file of files) { const source = readFileSync(file, "utf-8"); if (!source.includes("proxyActivities") && !source.includes("@temporalio/workflow")) continue; try { const aux = discoverAux(file, source); const acts = discoverActivities(file, source); const processArtifact = explicitProcessArtifact ?? discoverProcessArtifact(file, source); const extractOptions = { auxiliarySources: aux, activitySources: acts }; if (processArtifact) extractOptions.processArtifact = processArtifact; const ir = extractWorkflowIR(source, extractOptions); const name = ir.name || basename(file, ".ts"); results.push({ workflowType: name, ir, sourceFile: file }); process.stderr.write(" " + name + " (" + ir.nodes.length + " nodes, " + ir.edges.length + " edges)\\n"); } catch (err) { process.stderr.write(" skip " + file + ": " + err.message + "\\n"); } } process.stdout.write(JSON.stringify(results)); `; try { const result = execFileSync("npx", ["tsx", "--eval", script], { encoding: "utf-8", stdio: ["pipe", "pipe", "inherit"], maxBuffer: 10 * 1024 * 1024, }); return JSON.parse(result); } catch (err) { logError(`Extraction subprocess failed: ${err instanceof Error ? err.message : String(err)}`); return null; } } function runInventoryExtraction(targetPath, extractorPath) { const script = ` import { extractInventory } from "${extractorPath.replace(/\\/g, "/")}"; import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; import { basename, dirname, join } from "node:path"; ${DISCOVERY_HELPERS_SRC} const target = ${JSON.stringify(targetPath)}; const stat = statSync(target); const files = stat.isDirectory() ? readdirSync(target).filter(f => f.endsWith(".ts") && f !== "index.ts").map(f => join(target, f)) : [target]; const results = []; for (const file of files) { const source = readFileSync(file, "utf-8"); if (!source.includes("proxyActivities") && !source.includes("@temporalio/workflow")) continue; try { const aux = discoverAux(file, source); const acts = discoverActivities(file, source); const inventory = extractInventory(source, { auxiliarySources: aux, activitySources: acts }); results.push(inventory); process.stderr.write(" " + inventory.workflowType + " (" + inventory.commands.length + " commands, " + inventory.queries.length + " queries, " + inventory.activities.length + " activities)\\n"); } catch (err) { process.stderr.write(" skip " + file + ": " + err.message + "\\n"); } } process.stdout.write(JSON.stringify(results)); `; try { const result = execFileSync("npx", ["tsx", "--eval", script], { encoding: "utf-8", stdio: ["pipe", "pipe", "inherit"], maxBuffer: 10 * 1024 * 1024, }); return JSON.parse(result); } catch (err) { logError(`Inventory extraction subprocess failed: ${err instanceof Error ? err.message : String(err)}`); return null; } } function runLintExtraction(targetPath, extractorPath, explicitProcessPath) { let explicitProcessArtifact; if (explicitProcessPath) { try { explicitProcessArtifact = JSON.parse(fs.readFileSync(explicitProcessPath, "utf-8")); } catch (err) { logError(`Could not read/parse --process ${explicitProcessPath}: ${err instanceof Error ? err.message : String(err)}`); return null; } } const script = ` import { extractInventory } from "${extractorPath.replace(/\\/g, "/")}"; import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; import { basename, dirname, join } from "node:path"; ${DISCOVERY_HELPERS_SRC} const target = ${JSON.stringify(targetPath)}; const explicitProcessArtifact = ${JSON.stringify(explicitProcessArtifact ?? null)}; const stat = statSync(target); const files = stat.isDirectory() ? readdirSync(target).filter(f => f.endsWith(".ts") && f !== "index.ts").map(f => join(target, f)) : [target]; const results = []; for (const file of files) { const source = readFileSync(file, "utf-8"); if (!source.includes("proxyActivities") && !source.includes("@temporalio/workflow")) continue; try { const aux = discoverAux(file, source); const acts = discoverActivities(file, source); const inventory = extractInventory(source, { auxiliarySources: aux, activitySources: acts }); const processArtifact = explicitProcessArtifact ?? discoverProcessArtifact(file, source) ?? null; results.push({ inventory, processArtifact, sourceFile: file }); process.stderr.write(" " + inventory.workflowType + " (" + inventory.commands.length + " commands, " + inventory.queries.length + " queries, " + inventory.activities.length + " activities)\\n"); } catch (err) { process.stderr.write(" skip " + file + ": " + err.message + "\\n"); } } process.stdout.write(JSON.stringify(results)); `; try { const result = execFileSync("npx", ["tsx", "--eval", script], { encoding: "utf-8", stdio: ["pipe", "pipe", "inherit"], maxBuffer: 10 * 1024 * 1024, }); return JSON.parse(result); } catch (err) { logError(`Lint extraction subprocess failed: ${err instanceof Error ? err.message : String(err)}`); return null; } } async function uploadToS3(bucket, appName, workflows) { const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3"); const s3 = new S3Client({ region: process.env.AWS_REGION ?? "us-east-2" }); for (const wf of workflows) { const key = `workflow-ir/${appName}/${wf.workflowType}.json`; const body = JSON.stringify(wf.ir, null, 2); logInfo(` Uploading s3://${bucket}/${key} (${body.length} bytes)`); await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: "application/json", })); } const manifest = { extractedAt: new Date().toISOString(), app: appName, workflows: Object.fromEntries(workflows.map((wf) => [ wf.workflowType, { sourceFile: wf.sourceFile, nodeCount: wf.ir.nodes.length, edgeCount: wf.ir.edges.length, }, ])), }; const manifestKey = `workflow-ir/${appName}/_manifest.json`; logInfo(` Uploading s3://${bucket}/${manifestKey}`); await s3.send(new PutObjectCommand({ Bucket: bucket, Key: manifestKey, Body: JSON.stringify(manifest, null, 2), ContentType: "application/json", })); } function parseNameList(value) { return value ? value .split(",") .map((name) => name.trim()) .filter((name) => name.length > 0) : undefined; } export function registerWorkflowCommands(program) { const workflow = program .command("workflow") .description("Workflow tooling — IR extraction, visualization"); workflow .command("extract-ir <path>") .description("Extract WorkflowIR from Temporal workflow source files\n\n" + "Parses TypeScript workflow code into a graph (nodes + edges) for visualization.\n" + "Supports single files or directories.") .option("--app <name>", "Application name (used as S3 key prefix)") .option("--upload <bucket>", "S3 bucket to upload extracted IR (requires --app)") .option("--process <path>", "Path to a process artifact JSON file (docs/plans/2026-07-05-process-artifact-design.md §1); " + "renders substeps/actors/outcomes in place of a BUSINESS_STAGES map. Absent this flag, a sibling " + "process/*.process.json is auto-discovered per workflow file.") .action(async (targetPath, opts) => { try { const resolvedPath = path.resolve(targetPath); if (!fs.existsSync(resolvedPath)) { logError(`Path does not exist: ${resolvedPath}`); process.exitCode = 1; return; } let resolvedProcessPath; if (opts.process) { resolvedProcessPath = path.resolve(opts.process); if (!fs.existsSync(resolvedProcessPath)) { logError(`--process path does not exist: ${resolvedProcessPath}`); process.exitCode = 1; return; } } const extractorPath = resolveExtractorPath(); if (!extractorPath) { logError("Cannot resolve @mesh-tech/workflow-viz/ast-extractor.\n" + "Workflow viz is an OPTIONAL peer of the CLI. In the monorepo it is already linked;\n" + "from a registry install, add it: npm i -g @mesh-tech/workflow-viz"); process.exitCode = 1; return; } logInfo(`Extracting WorkflowIR from ${resolvedPath}`); const results = runExtraction(resolvedPath, extractorPath, resolvedProcessPath); if (!results || results.length === 0) { if (results) logWarn("No workflows found"); process.exitCode = results ? 0 : 1; return; } for (const wf of results) { logSuccess(` ${wf.workflowType} (${wf.ir.nodes.length} nodes, ${wf.ir.edges.length} edges)`); } if (opts.upload && opts.app) { logInfo(`Uploading ${results.length} workflow IR(s) to s3://${opts.upload}/workflow-ir/${opts.app}/`); await uploadToS3(opts.upload, opts.app, results); logSuccess("Upload complete"); } else if (opts.upload || opts.app) { logWarn("Both --app and --upload required for S3 upload"); } else { console.log(JSON.stringify(results.map((r) => r.ir), null, 2)); } } catch (error) { logError(`Extraction failed: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; } }); workflow .command("inventory <path>") .description("Extract the as-built command/query/activity inventory from Temporal workflow source\n\n" + "The inventory ({ workflowType, commands, queries, activities }) is the process-\n" + "conformance lint's code-side input — it is never hand-edited. Supports single\n" + "files or directories.") .action(async (targetPath) => { try { const resolvedPath = path.resolve(targetPath); if (!fs.existsSync(resolvedPath)) { logError(`Path does not exist: ${resolvedPath}`); process.exitCode = 1; return; } const extractorPath = resolveExtractorPath(); if (!extractorPath) { logError("Cannot resolve @mesh-tech/workflow-viz/ast-extractor.\n" + "Workflow viz is an OPTIONAL peer of the CLI. In the monorepo it is already linked;\n" + "from a registry install, add it: npm i -g @mesh-tech/workflow-viz"); process.exitCode = 1; return; } logInfo(`Extracting inventory from ${resolvedPath}`); const results = runInventoryExtraction(resolvedPath, extractorPath); if (!results || results.length === 0) { if (results) logWarn("No workflows found"); process.exitCode = results ? 0 : 1; return; } for (const inv of results) { logSuccess(` ${inv.workflowType} (${inv.commands.length} commands, ${inv.queries.length} queries, ${inv.activities.length} activities)`); } console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2)); } catch (error) { logError(`Inventory extraction failed: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; } }); workflow .command("lint-process <path>") .description("Lint a process artifact's bindings against the as-built code inventory\n\n" + "Reuses the inventory extraction's aux/activity discovery, resolves the process\n" + "artifact (--process, or the same sibling process/*.process.json discovery\n" + "extract-ir uses), then runs parseProcessArtifact + lintProcess against it.\n\n" + "Without --predicates / --selectors, the CLI cannot verify those names against the\n" + "worker's registries (it has no way to execute them) — the corresponding findings\n" + "are skipped and a note is printed; every other rule (bindings, coverage, ids,\n" + "actors, outcomes, workflowType) still runs. Pass both for full conformance, or rely\n" + "on the worker's own process-conformance test which has the registries in-process.") .option("--process <path>", "Path to a process artifact JSON file. Absent this flag, a sibling " + "process/*.process.json is auto-discovered per workflow file.") .option("--predicates <names>", "Comma-separated named-predicate registry (enables UNKNOWN_PREDICATE checks)") .option("--selectors <names>", "Comma-separated named-selector registry (enables UNKNOWN_SELECTOR checks)") .action(async (targetPath, opts) => { try { const resolvedPath = path.resolve(targetPath); if (!fs.existsSync(resolvedPath)) { logError(`Path does not exist: ${resolvedPath}`); process.exitCode = 1; return; } let resolvedProcessPath; if (opts.process) { resolvedProcessPath = path.resolve(opts.process); if (!fs.existsSync(resolvedProcessPath)) { logError(`--process path does not exist: ${resolvedProcessPath}`); process.exitCode = 1; return; } } const extractorPath = resolveExtractorPath(); if (!extractorPath) { logError("Cannot resolve @mesh-tech/workflow-viz/ast-extractor.\n" + "Workflow viz is an OPTIONAL peer of the CLI. In the monorepo it is already linked;\n" + "from a registry install, add it: npm i -g @mesh-tech/workflow-viz"); process.exitCode = 1; return; } let parseProcessArtifact; let lintProcess; try { ({ parseProcessArtifact } = await import("@mesh-tech/workflow-model")); ({ lintProcess } = await import("@mesh-tech/workflow-model")); } catch { logError("Cannot resolve @mesh-tech/workflow-model (parseProcessArtifact / lintProcess).\n" + "It is bundled into the published CLI — on a registry install this means a broken install; reinstall the CLI. In the monorepo, run the scoped install (pnpm bootstrap:worktree @mesh-tech/mesh-cli)."); process.exitCode = 1; return; } const predicateNames = parseNameList(opts.predicates); const selectorNames = parseNameList(opts.selectors); if (!predicateNames) { logWarn("predicate checks skipped — pass --predicates or run the worker conformance test"); } if (!selectorNames) { logWarn("selector checks skipped — pass --selectors or run the worker conformance test"); } logInfo(`Linting process artifact(s) against ${resolvedPath}`); const results = runLintExtraction(resolvedPath, extractorPath, resolvedProcessPath); if (!results || results.length === 0) { if (results) logWarn("No workflows found"); process.exitCode = results ? 0 : 1; return; } let hasError = false; for (const { inventory, processArtifact, sourceFile } of results) { console.log(`\n${inventory.workflowType} (${sourceFile})`); if (!processArtifact) { logError(" No process artifact found (checked --process and sibling process/*.process.json)."); hasError = true; continue; } const { artifact, diagnostics: parseDiagnostics } = parseProcessArtifact(processArtifact); let diagnostics = parseDiagnostics; if (artifact) { const lintDiagnostics = lintProcess(artifact, inventory, predicateNames ?? [], selectorNames ? { selectorNames } : {}); const reportable = predicateNames ? lintDiagnostics : lintDiagnostics.filter((d) => d.code !== "UNKNOWN_PREDICATE"); diagnostics = diagnostics.concat(reportable); } if (diagnostics.length === 0) { logSuccess(" clean"); } else { for (const d of diagnostics) { const line = ` [${d.severity}] ${d.code} ${d.path}: ${d.message}`; if (d.severity === "error") { logError(line); hasError = true; } else { logWarn(line); } } } } process.exitCode = hasError ? 1 : 0; } catch (error) { logError(`Lint failed: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; } }); }