UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

256 lines (255 loc) 9.64 kB
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); }