/** * Save and load reusable workflow commands. */ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { workflowProjectPaths, workflowUserSavedDir } from "./workflow-paths.js"; export type SavedWorkflowHostCapability = "foundationGate" | "validateEditScope"; export interface SavedWorkflowExecutionPolicy { /** Require manager-created run-level worktree isolation. */ worktreeRequired?: boolean; /** Package-authorized, narrowly typed host callbacks. */ authorizedHostCapabilities?: SavedWorkflowHostCapability[]; /** Stable contract version used in resume identity. */ policyVersion?: number; } export interface SavedWorkflowDefinition { /** Command name (filename without extension). */ name: string; /** Human-readable description. */ description: string; /** The workflow script. */ script: string; /** Optional parameter schema for parameterized workflows. */ parameters?: Record; /** Package-owned execution-policy floor; overrides may tighten but never weaken it. */ executionPolicy?: SavedWorkflowExecutionPolicy; } export interface SavedWorkflow extends SavedWorkflowDefinition { /** Where this workflow comes from. */ location: "project" | "user" | "bundled"; /** Full file path, or a bundled: URI for package defaults. */ path: string; /** When it was saved, or "bundled" for package defaults. */ savedAt: string; } export interface WorkflowStorage { /** Save a project or user override. */ save(workflow: SavedWorkflowDefinition, location?: "project" | "user"): SavedWorkflow; /** Load a workflow by name. */ load(name: string): SavedWorkflow | null; /** List all saved workflows. */ list(): SavedWorkflow[]; /** Delete a saved workflow. */ delete(name: string, location?: "project" | "user"): boolean; } export function isSafeSavedWorkflowName(name: string): boolean { return ( name.length > 0 && name.length <= 128 && name.trim() === name && name !== "." && name !== ".." && !/[/\\\0]/.test(name) ); } export function assertSafeSavedWorkflowName(name: string): void { if (!isSafeSavedWorkflowName(name)) { throw new Error("Saved workflow name must be a non-empty path-safe name without slashes."); } } const HOST_CAPABILITIES = new Set(["foundationGate", "validateEditScope"]); function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function normalizeExecutionPolicy( value: unknown, packageMinimum?: SavedWorkflowExecutionPolicy, ): SavedWorkflowExecutionPolicy | undefined { if (value === undefined && packageMinimum === undefined) return undefined; if (value !== undefined && !isRecord(value)) throw new Error("executionPolicy must be an object"); const raw = (value ?? {}) as Record; if (raw.worktreeRequired !== undefined && typeof raw.worktreeRequired !== "boolean") { throw new Error("executionPolicy.worktreeRequired must be boolean"); } if (raw.policyVersion !== undefined && (!Number.isInteger(raw.policyVersion) || (raw.policyVersion as number) < 1)) { throw new Error("executionPolicy.policyVersion must be a positive integer"); } if ( raw.authorizedHostCapabilities !== undefined && (!Array.isArray(raw.authorizedHostCapabilities) || raw.authorizedHostCapabilities.some((capability) => typeof capability !== "string")) ) { throw new Error("executionPolicy.authorizedHostCapabilities must be an array of strings"); } const packageCapabilities = new Set(packageMinimum?.authorizedHostCapabilities ?? []); const requestedCapabilities = (raw.authorizedHostCapabilities ?? []) as string[]; for (const capability of requestedCapabilities) { if (!HOST_CAPABILITIES.has(capability as SavedWorkflowHostCapability)) { throw new Error(`Unknown saved-workflow host capability: ${capability}`); } // Host capabilities are package-owned. Persisted project/user JSON may repeat // a bundled minimum but cannot self-authorize a callback for another command. if (!packageCapabilities.has(capability as SavedWorkflowHostCapability)) { throw new Error(`Host capability ${capability} is not authorized by a bundled policy`); } } const capabilities = [...packageCapabilities].sort(); const worktreeRequired = packageMinimum?.worktreeRequired === true || raw.worktreeRequired === true; const policyVersion = packageMinimum?.policyVersion ?? (raw.policyVersion as number | undefined); return { ...(worktreeRequired ? { worktreeRequired: true } : {}), ...(capabilities.length > 0 ? { authorizedHostCapabilities: capabilities } : {}), ...(policyVersion !== undefined ? { policyVersion } : {}), }; } export function savedWorkflowExecutionPolicyKey(workflow: SavedWorkflowDefinition): string | undefined { const policy = workflow.executionPolicy; if (!policy) return undefined; return JSON.stringify({ name: workflow.name, policyVersion: policy.policyVersion ?? 1, worktreeRequired: policy.worktreeRequired === true, authorizedHostCapabilities: [...(policy.authorizedHostCapabilities ?? [])].sort(), }); } export function createWorkflowStorage( cwd: string, bundledWorkflows: readonly SavedWorkflowDefinition[] = [], ): WorkflowStorage { const paths = workflowProjectPaths(cwd); const projectDir = paths.savedDir; const legacyProjectDir = paths.legacySavedDir; const userDir = workflowUserSavedDir(); const bundledByName = new Map(); for (const workflow of bundledWorkflows) { assertSafeSavedWorkflowName(workflow.name); if (bundledByName.has(workflow.name)) throw new Error(`Duplicate bundled workflow: ${workflow.name}`); bundledByName.set(workflow.name, { ...workflow, executionPolicy: normalizeExecutionPolicy(workflow.executionPolicy, workflow.executionPolicy), }); } const bundledWorkflow = (name: string): SavedWorkflow | null => { const workflow = bundledByName.get(name); return workflow ? { ...workflow, location: "bundled", path: `bundled:${workflow.name}`, savedAt: "bundled" } : null; }; const ensureDir = (dir: string) => { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } }; const workflowPath = (name: string, location: "project" | "user") => { assertSafeSavedWorkflowName(name); const dir = location === "project" ? projectDir : userDir; return join(dir, `${name}.json`); }; const legacyProjectWorkflowPath = (name: string) => { assertSafeSavedWorkflowName(name); return join(legacyProjectDir, `${name}.json`); }; const loadFromFile = (path: string, location: "project" | "user"): SavedWorkflow | null => { try { if (!existsSync(path)) return null; const data = JSON.parse(readFileSync(path, "utf-8")); if (!data || typeof data !== "object" || !isSafeSavedWorkflowName((data as { name?: string }).name ?? "")) { return null; } const definition = data as SavedWorkflowDefinition; const packageMinimum = bundledByName.get(definition.name)?.executionPolicy; return { ...definition, executionPolicy: normalizeExecutionPolicy(definition.executionPolicy, packageMinimum), location, path, savedAt: typeof data.savedAt === "string" ? data.savedAt : "unknown", }; } catch { return null; } }; return { save(workflow, location = "project") { assertSafeSavedWorkflowName(workflow.name); const dir = location === "project" ? projectDir : userDir; ensureDir(dir); const path = workflowPath(workflow.name, location); const packageMinimum = bundledByName.get(workflow.name)?.executionPolicy; const saved: SavedWorkflow = { ...workflow, executionPolicy: normalizeExecutionPolicy(workflow.executionPolicy, packageMinimum), location, path, savedAt: new Date().toISOString(), }; writeFileSync(path, JSON.stringify(saved, null, 2)); return saved; }, load(name: string): SavedWorkflow | null { if (!isSafeSavedWorkflowName(name)) return null; // Project takes precedence over user const projectPath = workflowPath(name, "project"); const project = loadFromFile(projectPath, "project"); if (project) return project; const legacyProject = loadFromFile(legacyProjectWorkflowPath(name), "project"); if (legacyProject) return legacyProject; const userPath = workflowPath(name, "user"); return loadFromFile(userPath, "user") ?? bundledWorkflow(name); }, list(): SavedWorkflow[] { const workflows: SavedWorkflow[] = []; const seen = new Set(); const addDir = (dir: string, location: "project" | "user") => { if (!existsSync(dir)) return; for (const file of readdirSync(dir).filter((f) => f.endsWith(".json"))) { const wf = loadFromFile(join(dir, file), location); if (wf && !seen.has(wf.name)) { seen.add(wf.name); workflows.push(wf); } } }; // Priority order mirrors load(): project > legacy project > user. addDir(projectDir, "project"); addDir(legacyProjectDir, "project"); addDir(userDir, "user"); for (const name of bundledByName.keys()) { if (seen.has(name)) continue; const workflow = bundledWorkflow(name); if (workflow) workflows.push(workflow); } return workflows.sort((a, b) => a.name.localeCompare(b.name)); }, delete(name: string, location?: "project" | "user"): boolean { if (!isSafeSavedWorkflowName(name)) return false; const locations = location ? [location] : (["project", "user"] as const); let deleted = false; for (const loc of locations) { const path = workflowPath(name, loc); if (existsSync(path)) { unlinkSync(path); deleted = true; } if (loc === "project") { const legacyPath = legacyProjectWorkflowPath(name); if (existsSync(legacyPath)) { unlinkSync(legacyPath); deleted = true; } } } return deleted; }, }; }