@workflow-manager/runner
Version:
CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes
256 lines (255 loc) • 9.64 kB
JavaScript
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { parseWorkflowFile, validateWorkflow } from "../parser.js";
import { publishRemoteWorkflow, pullRemoteWorkflow, searchRemoteWorkflows, fetchWhoAmI } from "./api.js";
import { clearRemoteConfig, saveRemoteConfig } from "./config.js";
function getFlag(args, name) {
const idx = args.indexOf(name);
if (idx >= 0 && idx + 1 < args.length) {
return args[idx + 1];
}
return undefined;
}
function hasFlag(args, name) {
return args.includes(name);
}
function slugify(value) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63);
}
function splitOwnerSlug(value) {
const [owner, slug, extra] = value.split("/");
if (!owner || !slug || extra) {
throw new Error("Expected workflow reference in the form <owner>/<slug>");
}
return { owner, slug };
}
function sourceFormatFromPath(filePath) {
return path.extname(filePath).toLowerCase() === ".json" ? "json" : "markdown";
}
function hashContentSha256(content) {
return createHash("sha256").update(content).digest("hex");
}
function isAllowedLocalSkillSourcePath(source) {
if (!source || path.isAbsolute(source) || source.includes("\\") || source.includes(".."))
return false;
const normalized = path.posix.normalize(source);
const withoutDot = normalized.startsWith("./") ? normalized.slice(2) : normalized;
if (!withoutDot.startsWith("skills/"))
return false;
return withoutDot.endsWith("/SKILL.md");
}
function resolveAllowedLocalSkillSourcePath(workflowDir, source) {
if (!isAllowedLocalSkillSourcePath(source)) {
throw new Error(`Skill source must be under ./skills/**/SKILL.md: ${source}`);
}
const sourcePath = path.resolve(workflowDir, source);
const allowedRoot = path.resolve(workflowDir, "skills");
if (!sourcePath.startsWith(`${allowedRoot}${path.sep}`)) {
throw new Error(`Skill source escapes ./skills directory: ${source}`);
}
if (path.basename(sourcePath) !== "SKILL.md") {
throw new Error(`Skill source must point to SKILL.md: ${source}`);
}
return sourcePath;
}
export function bundleSkills(workflow, workflowFilePath) {
if (!workflow.skills)
return workflow;
const workflowDir = path.dirname(path.resolve(workflowFilePath));
const bundled = {};
for (const [name, entry] of Object.entries(workflow.skills)) {
if (entry.content && entry.content.trim()) {
const content = entry.content;
bundled[name] = {
...entry,
content,
contentSha256: hashContentSha256(content),
};
continue;
}
if (!entry.source) {
throw new Error(`Skill "${name}" has neither content nor source`);
}
const sourcePath = resolveAllowedLocalSkillSourcePath(workflowDir, entry.source);
if (!fs.existsSync(sourcePath)) {
throw new Error(`Skill "${name}" source file not found: ${sourcePath}`);
}
const content = fs.readFileSync(sourcePath, "utf-8");
bundled[name] = {
source: entry.source,
upstream: entry.upstream,
content,
contentSha256: hashContentSha256(content),
};
}
return { ...workflow, skills: bundled };
}
function normalizeTags(raw) {
if (!raw) {
return [];
}
return [...new Set(raw.split(",").map((tag) => tag.trim().toLowerCase()).filter(Boolean))];
}
export async function cmdAuth(args) {
const subcommand = args[0];
if (subcommand === "login") {
const token = getFlag(args, "--token");
if (!token) {
console.error("Missing required flag: --token");
return 1;
}
saveRemoteConfig({ token });
try {
const profile = await fetchWhoAmI();
console.log(`Authenticated as ${profile.username ?? profile.userId}`);
return 0;
}
catch (error) {
clearRemoteConfig();
console.error(`Auth error: ${error.message}`);
return 1;
}
}
if (subcommand === "whoami") {
try {
const profile = await fetchWhoAmI();
console.log(JSON.stringify(profile, null, 2));
return 0;
}
catch (error) {
console.error(`Auth error: ${error.message}`);
return 1;
}
}
if (subcommand === "logout") {
clearRemoteConfig();
console.log("Removed local remote authentication token");
return 0;
}
console.error("Usage: wfm auth <login|whoami|logout>");
return 1;
}
export async function cmdSearch(args) {
try {
const query = args.join(" ").trim();
const result = await searchRemoteWorkflows(query);
if (result.items.length === 0) {
console.log("No workflows found");
return 0;
}
for (const item of result.items) {
console.log(`${item.owner}/${item.slug} - ${item.title}`);
if (item.description) {
console.log(` ${item.description}`);
}
console.log(` version=${item.latestVersion ?? "n/a"} visibility=${item.visibility} format=${item.sourceFormat ?? "n/a"}`);
}
return 0;
}
catch (error) {
console.error(`Search error: ${error.message}`);
return 1;
}
}
export async function cmdPublish(filePath, args) {
try {
const resolvedPath = path.resolve(filePath);
const rawSource = fs.readFileSync(resolvedPath, "utf-8");
const workflow = parseWorkflowFile(resolvedPath);
const errors = validateWorkflow(workflow);
if (errors.length > 0) {
console.error(`Invalid workflow: ${errors.join("; ")}`);
return 1;
}
const slug = slugify(getFlag(args, "--slug") ?? workflow.key);
const title = getFlag(args, "--title")?.trim() || workflow.title;
const description = getFlag(args, "--description")?.trim() || workflow.description || null;
const versionLabel = getFlag(args, "--version")?.trim() || `v${Date.now()}`;
const visibility = (getFlag(args, "--visibility")?.trim().toLowerCase() ?? "private");
const publishedState = hasFlag(args, "--draft") ? "draft" : "published";
const tags = normalizeTags(getFlag(args, "--tag"));
const changelog = getFlag(args, "--changelog")?.trim() || null;
const bundled = bundleSkills(workflow, resolvedPath);
const hasSkills = Object.keys(bundled.skills ?? {}).length > 0;
const sourceFormat = hasSkills ? "json" : sourceFormatFromPath(resolvedPath);
const publishSource = hasSkills ? JSON.stringify(bundled, null, 2) : rawSource;
const result = await publishRemoteWorkflow({
slug,
title,
description,
visibility,
versionLabel,
sourceFormat,
rawSource: publishSource,
definition: bundled,
tags,
changelog,
publishedState,
});
console.log(JSON.stringify(result, null, 2));
return 0;
}
catch (error) {
console.error(`Publish error: ${error.message}`);
return 1;
}
}
export async function cmdPull(reference, args) {
try {
const { owner, slug } = splitOwnerSlug(reference);
const version = getFlag(args, "--version");
const pulled = await pullRemoteWorkflow(owner, slug, version);
const outputPath = getFlag(args, "--output") ??
path.resolve(`${slug}.${pulled.sourceFormat === "json" ? "json" : "md"}`);
fs.writeFileSync(outputPath, pulled.rawSource, "utf-8");
const parsed = parseWorkflowFile(outputPath);
const validationErrors = validateWorkflow(parsed);
if (validationErrors.length > 0) {
fs.rmSync(outputPath);
console.error(`Pulled workflow failed local validation: ${validationErrors.join("; ")}`);
return 1;
}
const missingEmbeddedSkills = Object.entries(parsed.skills ?? {})
.filter(([, entry]) => !entry.content?.trim())
.map(([name]) => name);
if (missingEmbeddedSkills.length > 0) {
fs.rmSync(outputPath);
console.error(`Pulled workflow is missing embedded content for skills: ${missingEmbeddedSkills.join(", ")}`);
return 1;
}
console.log(`Pulled ${owner}/${slug}@${pulled.version} -> ${path.resolve(outputPath)}`);
return 0;
}
catch (error) {
console.error(`Pull error: ${error.message}`);
return 1;
}
}
export async function cmdRemoteInfo(reference) {
try {
const { owner, slug } = splitOwnerSlug(reference);
const pulled = await pullRemoteWorkflow(owner, slug);
console.log(JSON.stringify(pulled, null, 2));
return 0;
}
catch (error) {
console.error(`Remote info error: ${error.message}`);
return 1;
}
}
export function pullOutputPathForTest(reference, output, sourceFormat = "markdown") {
const { slug } = splitOwnerSlug(reference);
return output ?? path.resolve(`${slug}.${sourceFormat === "json" ? "json" : "md"}`);
}
export function slugifyForTest(value) {
return slugify(value);
}
export function sourceFormatFromPathForTest(filePath) {
return sourceFormatFromPath(filePath);
}