@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
515 lines (507 loc) • 23.2 kB
JavaScript
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;
}
});
}