From aa4f0cfe83fa85a8afe49a76bfab090cb3d549a3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 31 May 2026 11:49:54 -0400 Subject: [PATCH] refactor: extract CLI recipe dry-run helpers --- packages/cli/src/index.ts | 505 +--------------------------- packages/cli/src/recipe-dry-run.ts | 520 +++++++++++++++++++++++++++++ 2 files changed, 525 insertions(+), 500 deletions(-) create mode 100644 packages/cli/src/recipe-dry-run.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dbb7908..9261f93 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,14 +3,15 @@ import { readFile } from "node:fs/promises" import { spawn } from "node:child_process" import { basename, dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" -import { SANDBOX_DMC_PARENT_ONLY_ABILITIES, SANDBOX_DMC_SAFE_ABILITIES, SANDBOX_WORKSPACE_ROOT, checkWorkspacePolicy, commandRegistry, createRuntime, createWorkspaceRecipeJsonSchema, recipeCommandDefinitions, validateRuntimePolicy, verifyArtifactBundle, type ArtifactBundle, type ArtifactBundleVerificationResult, type CommandDefinition, type ExecutionResult, type MountSpec, type Runtime, type RuntimeInfo, type RuntimePolicy, type SandboxWorkspaceContract, type SandboxWorkspaceMode, type WorkspacePolicyResult, type WorkspaceRecipe, type WorkspaceRecipeJsonSchema, type WorkspaceRecipePluginRuntime, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeSiteSeed, type WorkspaceRecipeWorkspace } from "@chubes4/wp-codebox-core" +import { SANDBOX_DMC_PARENT_ONLY_ABILITIES, SANDBOX_DMC_SAFE_ABILITIES, SANDBOX_WORKSPACE_ROOT, checkWorkspacePolicy, commandRegistry, createRuntime, createWorkspaceRecipeJsonSchema, recipeCommandDefinitions, verifyArtifactBundle, type ArtifactBundle, type ArtifactBundleVerificationResult, type CommandDefinition, type ExecutionResult, type MountSpec, type Runtime, type RuntimeInfo, type RuntimePolicy, type SandboxWorkspaceContract, type SandboxWorkspaceMode, type WorkspacePolicyResult, type WorkspaceRecipe, type WorkspaceRecipeJsonSchema, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeSiteSeed } from "@chubes4/wp-codebox-core" import { createPlaygroundRuntimeBackend } from "@chubes4/wp-codebox-playground" import { agentRuntimeProbeCode, agentSandboxRunCode, resolveSandboxTaskCode } from "./agent-code.js" import { captureStdout, printArtifactVerifyHumanOutput, printBatchHumanOutput, printBlueprintValidateHumanOutput, printBootHumanOutput, printCommandCatalogHumanOutput, printHelp, printHumanOutput, printRecipeHumanOutput, printRecipeSchemaHumanOutput, printRecipeValidateHumanOutput, serializeError } from "./output.js" import { parsePreviewBind, parsePreviewHoldSeconds, parsePreviewPort, parsePreviewPublicUrl } from "./preview-options.js" +import { dryRunRecipe, pluginRuntimeHealthProbeStepIndex, pluginRuntimeSetupStepIndex, recipeDryRunSiteSeeds, siteSeedScopesAreBounded, type RecipeDryRunOutput, type RecipeDryRunSiteSeed, type RecipeDryRunStagedFile } from "./recipe-dry-run.js" import { collectAndFinalizeFailedRecipeArtifacts, finalizeAgentSandboxEvidence, finalizeRecipeArtifactEvidence, recipeAgentResultOutput, recipeArtifactEvidenceFailure } from "./recipe-evidence.js" -import { activateExtraPluginsCode, cleanupRecipePreparedSources, defaultWorkspaceTarget, installMuPluginsCode, pluginTarget, prepareRecipeExtraPlugins, prepareRecipeStagedFiles, prepareRecipeWorkspaces, recipeExtraPluginFile, recipeExtraPluginSlug, recipeExtraPlugins, recipeMountType, recipeSource, recipeSourceProvenance, resolveRecipeExtraPluginFile, stagedFileMountType, stagedFileProvenance, type PreparedExtraPlugin, type PreparedStagedFile, type PreparedWorkspaceMount, type RecipeSourceProvenance, type RecipeSourceType, type RecipeStagedFileProvenance } from "./recipe-sources.js" -import { defaultPolicy, hasExplicitSiteSeedSelectors, parseWorkspaceRecipe, pluginRuntimeHealthProbeStep, recipePolicy, recipeWorkflowSteps, runPolicy, validateWorkspaceRecipe, type RecipeValidationIssue, type RecipeWorkflowPhase } from "./recipe-validation.js" +import { activateExtraPluginsCode, cleanupRecipePreparedSources, installMuPluginsCode, prepareRecipeExtraPlugins, prepareRecipeStagedFiles, prepareRecipeWorkspaces, recipeExtraPlugins, recipeMountType, type PreparedExtraPlugin, type PreparedStagedFile, type PreparedWorkspaceMount } from "./recipe-sources.js" +import { defaultPolicy, parseWorkspaceRecipe, pluginRuntimeHealthProbeStep, recipePolicy, recipeWorkflowSteps, runPolicy, validateWorkspaceRecipe, type RecipeValidationIssue, type RecipeWorkflowPhase } from "./recipe-validation.js" interface CommandCatalogOutput { schema: "wp-codebox/command-catalog/v1" @@ -180,106 +181,14 @@ interface RecipeRunOutput { error?: RunOutput["error"] } -interface RecipeDryRunOutput { - success: boolean - schema: "wp-codebox/recipe-run-dry-run/v1" - recipePath?: string - dryRun: true - valid: boolean - validation: { - issues: RecipeValidationIssue[] - } - plan?: RecipeDryRunPlan - error?: RunOutput["error"] -} - type RecipeRunCommandOutput = RecipeRunOutput | RecipeDryRunOutput -interface RecipeDryRunPlan { - runtime: { - backend: string - name: string - wp: string - blueprint: unknown - } - artifacts: { - directory?: string - } - mounts: RecipeDryRunMount[] - workspaces: RecipeDryRunWorkspace[] - extra_plugins: RecipeDryRunExtraPlugin[] - pluginRuntime?: RecipeDryRunPluginRuntime - siteSeeds: RecipeDryRunSiteSeed[] - stagedFiles: RecipeDryRunStagedFile[] - secretEnv: Array<{ name: string; available: boolean }> - policy: RuntimePolicy & { - valid: boolean - issues: ReturnType["issues"] - } - workflow: { - before?: RecipeDryRunStep[] - steps: RecipeDryRunStep[] - after?: RecipeDryRunStep[] - } -} - type RecipeExecutionResult = ExecutionResult & { recipePhase?: RecipeWorkflowPhase recipeStepIndex?: number recipeCommand?: string } -interface RecipeDryRunMount { - type: MountSpec["type"] - source?: string - target: string - mode: "readonly" | "readwrite" - metadata?: Record - planned?: "existing" | "generated" -} - -interface RecipeDryRunWorkspace { - index: number - source?: string - target: string - mode: "readonly" | "readwrite" - sourceMode: SandboxWorkspaceMode - seed: WorkspaceRecipeWorkspace["seed"] - generated: boolean - metadata: Record -} - -interface RecipeDryRunExtraPlugin { - source: string - sourceRef: string - sourceType: RecipeSourceType - slug: string - target: string - pluginFile: string - activate: boolean - loadAs: "plugin" | "mu-plugin" - provenance: RecipeSourceProvenance -} - -interface RecipeDryRunPluginRuntime { - label?: string - php?: WorkspaceRecipePluginRuntime["php"] - wpConfigDefines?: WorkspaceRecipePluginRuntime["wpConfigDefines"] - setup: RecipeDryRunStep[] - healthProbes: RecipeDryRunPluginRuntimeHealthProbe[] -} - -interface RecipeDryRunPluginRuntimeHealthProbe { - index: number - name: string - type: WorkspaceRecipePluginRuntimeHealthProbe["type"] - command: string - args: string[] - resolvedCommand: string - resolvedArgs: string[] - policy: RecipeDryRunStep["policy"] -} - interface RecipeRuntimeDiagnostic { schema: "wp-codebox/plugin-runtime-diagnostic/v1" severity: "error" @@ -291,24 +200,6 @@ interface RecipeRuntimeDiagnostic { executionIndex?: number } -interface RecipeDryRunSiteSeed { - index: number - type: WorkspaceRecipeSiteSeed["type"] - name: string - source?: string - format?: WorkspaceRecipeSiteSeed["format"] - importer?: string - scopes: WorkspaceRecipeSiteSeed["scopes"] - bounded: boolean - dryRunOnly: boolean - privacy: { - exportsParentSiteData: boolean - importsIntoSandbox: boolean - includesRecordData: boolean - secrets: "excluded-by-default" - } -} - interface RecipeRunSiteSeed extends Omit { action: "imported" | "skipped" reason?: string @@ -317,38 +208,10 @@ interface RecipeRunSiteSeed extends Omit { provenance?: Record } -interface RecipeDryRunStagedFile { - index: number - source: string - sourceRef: string - target: string - type: MountSpec["type"] - provenance: RecipeStagedFileProvenance -} - interface RecipeRunStagedFile extends RecipeDryRunStagedFile { action: "staged" } -interface RecipeDryRunStep { - phase: RecipeWorkflowPhase - index: number - command: string - args: string[] - parsedArgs: Record - resolvedCommand: string - resolvedArgs: string[] - resolvedParsedArgs: Record - policy: { - status: "allowed" | "denied" - command: string - allowedCommands: string[] - approvals: RuntimePolicy["approvals"] - filesystem: RuntimePolicy["filesystem"] - secrets: RuntimePolicy["secrets"] - } -} - interface AgentRuntimeProbeOptions { agentsApiPath: string dataMachinePath: string @@ -461,7 +324,7 @@ async function main(args: string[]): Promise { const options = parseRecipeRunOptions(args) const interruption = options.dryRun ? undefined : createRecipeInterruptionController() interruption?.install() - const execute = (): Promise => options.dryRun ? dryRunRecipe(options) : runRecipe(options, interruption) + const execute = (): Promise => options.dryRun ? dryRunRecipe(options, { defaultWordPressVersion: DEFAULT_WORDPRESS_VERSION, resolveExecutionSpec: recipeExecutionSpec }) : runRecipe(options, interruption) try { if (!options.json) { @@ -771,148 +634,6 @@ function recipeSchemaOutput(): RecipeSchemaOutput { } } -async function dryRunRecipe(options: RecipeRunOptions): Promise { - const recipePath = resolve(options.recipePath) - try { - const recipeDirectory = dirname(recipePath) - const raw = await readFile(recipePath, "utf8") - const recipe = parseWorkspaceRecipe(raw, recipePath) - const issues = await validateWorkspaceRecipe(recipe, recipePath) - - if (issues.length > 0) { - return { - success: false, - schema: "wp-codebox/recipe-run-dry-run/v1", - recipePath, - dryRun: true, - valid: false, - validation: { issues }, - error: { - name: "RecipeValidationError", - message: `Recipe validation failed with ${issues.length} issue${issues.length === 1 ? "" : "s"}.`, - }, - } - } - - return { - success: true, - schema: "wp-codebox/recipe-run-dry-run/v1", - recipePath, - dryRun: true, - valid: true, - validation: { issues }, - plan: await recipeDryRunPlan(recipe, recipeDirectory, options), - } - } catch (error) { - return { - success: false, - schema: "wp-codebox/recipe-run-dry-run/v1", - recipePath, - dryRun: true, - valid: false, - validation: { - issues: [ - { - code: "invalid-recipe", - path: "$", - message: error instanceof SyntaxError ? `Recipe JSON is invalid: ${error.message}` : error instanceof Error ? error.message : String(error), - }, - ], - }, - error: serializeError(error), - } - } -} - -async function recipeDryRunPlan(recipe: WorkspaceRecipe, recipeDirectory: string, options: RecipeRunOptions): Promise { - const policy = recipePolicy(recipe) - const policyValidation = validateRuntimePolicy(policy) - const workspaces = recipeDryRunWorkspaces(recipe, recipeDirectory) - const extraPlugins = recipeDryRunExtraPlugins(recipe, recipeDirectory) - const pluginRuntime = await recipeDryRunPluginRuntime(recipe, recipeDirectory, policy) - const siteSeeds = recipeDryRunSiteSeeds(recipe, recipeDirectory) - const stagedFiles = await recipeDryRunStagedFiles(recipe, recipeDirectory) - const workflowSteps = await recipeDryRunSteps(recipe, recipeDirectory, policy) - const recipeMounts = await Promise.all((recipe.inputs?.mounts ?? []).map(async (mount) => { - const source = resolve(recipeDirectory, mount.source) - return { - type: await recipeMountType(source, mount.type), - source, - target: mount.target, - mode: mount.mode ?? "readwrite" as const, - ...(mount.metadata ? { metadata: mount.metadata } : {}), - planned: "existing" as const, - } - })) - const mounts: RecipeDryRunMount[] = [ - ...workspaces.map((workspace) => ({ - type: "directory" as const, - ...(workspace.source ? { source: workspace.source } : {}), - target: workspace.target, - mode: workspace.mode, - metadata: workspace.metadata, - planned: workspace.generated ? "generated" as const : "existing" as const, - })), - ...extraPlugins.map((plugin) => ({ - type: "directory" as const, - source: plugin.source, - target: plugin.target, - mode: "readonly" as const, - metadata: { - kind: "extra-plugin", - slug: plugin.slug, - source: plugin.provenance, - }, - planned: "existing" as const, - })), - ...recipeMounts, - ...stagedFiles.map((stagedFile) => ({ - type: stagedFile.type, - source: stagedFile.source, - target: stagedFile.target, - mode: "readwrite" as const, - metadata: { - kind: "staged-file", - index: stagedFile.index, - source: stagedFile.provenance, - }, - planned: "generated" as const, - })), - ] - - return { - runtime: { - backend: recipe.runtime?.backend ?? "wordpress-playground", - name: recipe.runtime?.name ?? "wp-codebox-recipe", - wp: recipe.runtime?.wp ?? DEFAULT_WORDPRESS_VERSION, - blueprint: recipe.runtime?.blueprint ?? { steps: [] }, - }, - artifacts: stripUndefined({ - directory: options.artifactsDirectory ?? recipe.artifacts?.directory, - }), - mounts, - workspaces, - extra_plugins: extraPlugins, - ...(pluginRuntime ? { pluginRuntime } : {}), - siteSeeds, - stagedFiles, - secretEnv: (recipe.inputs?.secretEnv ?? []).map((name) => ({ - name, - available: process.env[name] !== undefined, - })), - policy: { - ...policy, - valid: policyValidation.valid, - issues: policyValidation.issues, - }, - workflow: { - ...(recipe.workflow.before ? { before: workflowSteps.filter((step) => step.phase === "before") } : {}), - steps: workflowSteps, - ...(recipe.workflow.after ? { after: workflowSteps.filter((step) => step.phase === "after") } : {}), - }, - } -} - function printJsonFailureDiagnostic(output: { success: boolean; error?: { message?: string }; logs?: string[] }): void { if (output.success) { return @@ -2304,14 +2025,6 @@ async function executeRecipePluginRuntimeHealthProbe(runtime: Runtime, probe: Wo } } -function pluginRuntimeSetupStepIndex(index: number): number { - return -1000 - index -} - -function pluginRuntimeHealthProbeStepIndex(index: number): number { - return -2000 - index -} - function recipeRuntimeDiagnostics(recipe: WorkspaceRecipe, executions: RecipeExecutionResult[], error: unknown): RecipeRuntimeDiagnostic[] | undefined { const diagnostics: RecipeRuntimeDiagnostic[] = executions .map((execution, executionIndex) => ({ execution, executionIndex })) @@ -2352,75 +2065,6 @@ function recipeStepMetadata(step: WorkspaceRecipe["workflow"]["steps"][number]): return { command: step.command, args: step.args ?? [] } } -async function recipeDryRunSteps(recipe: WorkspaceRecipe, recipeDirectory: string, policy: RuntimePolicy): Promise { - const steps: Array> = [] - const dryRunExtraPlugins = await Promise.all(recipeExtraPlugins(recipe).map(async (plugin) => { - const slug = recipeExtraPluginSlug(plugin) - return { - source: plugin.source, - slug, - target: pluginTarget(slug, plugin.loadAs ?? "plugin"), - pluginFile: await resolveRecipeExtraPluginFile(plugin, recipeDirectory), - activate: plugin.activate !== false, - loadAs: plugin.loadAs ?? "plugin", - cleanupPaths: [], - provenance: recipeSourceProvenance(recipeSource(plugin.source, plugin.sha256), recipeDirectory), - } - })) - const muPluginInstallCode = installMuPluginsCode(dryRunExtraPlugins) - if (muPluginInstallCode) { - steps.push(recipeDryRunStep({ command: "wordpress.run-php", args: [`code=${muPluginInstallCode}`] }, recipeDirectory, policy, "setup", -2, "install-mu-plugins")) - } - const pluginActivationCode = activateExtraPluginsCode(dryRunExtraPlugins) - if (pluginActivationCode) { - steps.push(recipeDryRunStep({ command: "wordpress.run-php", args: [`code=${pluginActivationCode}`] }, recipeDirectory, policy, "setup", -1, "activate-extra-plugins")) - } - - for (const workflowStep of recipeWorkflowSteps(recipe)) { - steps.push(recipeDryRunStep(workflowStep.step, recipeDirectory, policy, workflowStep.phase, workflowStep.index)) - } - - return Promise.all(steps) -} - -async function recipeDryRunStep(step: WorkspaceRecipe["workflow"]["steps"][number], recipeDirectory: string, policy: RuntimePolicy, phase: RecipeWorkflowPhase, index: number, label?: string): Promise { - const resolved = await recipeExecutionSpec(step, recipeDirectory) - const allowed = policy.commands.includes(resolved.command) - return { - phase, - index, - command: label ?? step.command, - args: step.args ?? [], - parsedArgs: parseRecipeArgs(step.args ?? []), - resolvedCommand: resolved.command, - resolvedArgs: resolved.args, - resolvedParsedArgs: parseRecipeArgs(resolved.args), - policy: { - status: allowed ? "allowed" : "denied", - command: resolved.command, - allowedCommands: policy.commands, - approvals: policy.approvals, - filesystem: policy.filesystem, - secrets: policy.secrets, - }, - } -} - -function parseRecipeArgs(args: string[]): Record { - const parsed: Record = {} - for (const arg of args) { - const separator = arg.indexOf("=") - if (separator === -1) { - parsed[arg] = true - continue - } - - parsed[arg.slice(0, separator)] = arg.slice(separator + 1) - } - - return parsed -} - function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspaceMounts: PreparedWorkspaceMount[], extraPlugins: PreparedExtraPlugin[], stagedFiles: PreparedStagedFile[], previewPublicUrl: string | undefined, previewPort: number | undefined, previewBind: string | undefined): Record { const extraPluginMetadata = extraPlugins.map((plugin) => ({ source: plugin.source, @@ -2493,119 +2137,6 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac } } -function recipeDryRunWorkspaces(recipe: WorkspaceRecipe, recipeDirectory: string): RecipeDryRunWorkspace[] { - return (recipe.inputs?.workspaces ?? []).map((workspace, index) => { - const slug = workspace.seed.slug ?? basename(resolve(recipeDirectory, workspace.seed.source ?? `workspace-${index}`)) - const target = workspace.target ?? defaultWorkspaceTarget(workspace, slug) - const generated = workspace.seed.type !== "directory" - const sourceMode = workspace.sourceMode ?? "repo-backed" - const metadata = { - kind: "recipe-workspace", - index, - seed: workspace.seed, - target, - workspaceRoot: SANDBOX_WORKSPACE_ROOT, - sourceMode, - dryRun: true, - } - - return { - index, - ...(generated ? {} : { source: resolve(recipeDirectory, workspace.seed.source ?? "") }), - target, - mode: workspace.mode ?? "readwrite", - sourceMode, - seed: workspace.seed, - generated, - metadata, - } - }) -} - -function recipeDryRunExtraPlugins(recipe: WorkspaceRecipe, recipeDirectory: string): RecipeDryRunExtraPlugin[] { - return recipeExtraPlugins(recipe).map((plugin) => { - const slug = recipeExtraPluginSlug(plugin) - const source = recipeSource(plugin.source, plugin.sha256) - const provenance = recipeSourceProvenance(source, recipeDirectory) - return { - source: source.type === "local" ? resolve(recipeDirectory, plugin.source) : source.resolvedUrl, - sourceRef: plugin.source, - sourceType: source.type, - slug, - target: pluginTarget(slug, plugin.loadAs ?? "plugin"), - pluginFile: recipeExtraPluginFile(plugin), - activate: plugin.activate !== false, - loadAs: plugin.loadAs ?? "plugin", - provenance, - } - }) -} - -async function recipeDryRunPluginRuntime(recipe: WorkspaceRecipe, recipeDirectory: string, policy: RuntimePolicy): Promise { - const pluginRuntime = recipe.inputs?.pluginRuntime - if (!pluginRuntime) { - return undefined - } - - const setup = await Promise.all((pluginRuntime.setup ?? []).map((step, index) => recipeDryRunStep(step, recipeDirectory, policy, "setup", pluginRuntimeSetupStepIndex(index), `plugin-runtime.setup:${index}`))) - const healthProbes = await Promise.all((pluginRuntime.healthProbes ?? []).map(async (probe, index) => { - const step = pluginRuntimeHealthProbeStep(probe) - const dryRunStep = await recipeDryRunStep(step, recipeDirectory, policy, "setup", pluginRuntimeHealthProbeStepIndex(index), `plugin-runtime.health:${probe.name}`) - return { - index, - name: probe.name, - type: probe.type, - command: dryRunStep.command, - args: dryRunStep.args, - resolvedCommand: dryRunStep.resolvedCommand, - resolvedArgs: dryRunStep.resolvedArgs, - policy: dryRunStep.policy, - } - })) - - return stripUndefined({ - label: pluginRuntime.label, - php: pluginRuntime.php, - wpConfigDefines: pluginRuntime.wpConfigDefines, - setup, - healthProbes, - }) -} - -function recipeDryRunSiteSeeds(recipe: WorkspaceRecipe, recipeDirectory: string): RecipeDryRunSiteSeed[] { - return (recipe.inputs?.siteSeeds ?? []).map((siteSeed, index) => ({ - index, - type: siteSeed.type, - name: siteSeed.name, - ...(siteSeed.source ? { source: resolve(recipeDirectory, siteSeed.source) } : {}), - ...(siteSeed.format ? { format: siteSeed.format } : {}), - ...(siteSeed.type === "fixture" ? { importer: siteSeed.format ?? "json" } : {}), - scopes: siteSeed.scopes, - bounded: siteSeedScopesAreBounded(siteSeed), - dryRunOnly: siteSeed.type !== "fixture", - privacy: { - exportsParentSiteData: false, - importsIntoSandbox: siteSeed.type === "fixture", - includesRecordData: siteSeed.type === "fixture", - secrets: "excluded-by-default", - }, - })) -} - -async function recipeDryRunStagedFiles(recipe: WorkspaceRecipe, recipeDirectory: string): Promise { - return Promise.all((recipe.inputs?.stagedFiles ?? []).map(async (stagedFile, index) => { - const source = resolve(recipeDirectory, stagedFile.source) - return { - index, - source, - sourceRef: stagedFile.source, - target: stagedFile.target, - type: await stagedFileMountType(source), - provenance: stagedFileProvenance(stagedFile, recipeDirectory), - } - })) -} - function recipeRunStagedFile(stagedFile: PreparedStagedFile): RecipeRunStagedFile { const index = typeof stagedFile.metadata.index === "number" ? stagedFile.metadata.index : 0 return { @@ -3098,32 +2629,6 @@ function matchesNumberSelector(record: Record, allowed: number[ return values.some((value) => allowed.includes(value)) } -function siteSeedScopesAreBounded(siteSeed: WorkspaceRecipeSiteSeed): boolean { - for (const [scopeName, scope] of Object.entries(siteSeed.scopes)) { - if (!scope || scope === true) { - continue - } - - if (scopeName === "options" && (!scope.names || scope.names.length === 0)) { - return false - } - - if (siteSeed.type === "parent_site" && scope.maxRecords === undefined && !hasExplicitSiteSeedSelectors(scope)) { - return false - } - - if (scopeName === "users" && scope.anonymize === false) { - return false - } - - if (scopeName === "media" && scope.includeFiles === true && siteSeed.type === "parent_site") { - return false - } - } - - return Object.values(siteSeed.scopes).some((scope) => scope !== undefined && scope !== false) -} - function sandboxWorkspaceContract(workspaceMounts: PreparedWorkspaceMount[], mounts: NonNullable["mounts"]): SandboxWorkspaceContract { const mountRefs = [ ...workspaceMounts.map((mount) => workspaceMountRef(mount.target, mount.mode, mount.metadata)), diff --git a/packages/cli/src/recipe-dry-run.ts b/packages/cli/src/recipe-dry-run.ts new file mode 100644 index 0000000..2367a17 --- /dev/null +++ b/packages/cli/src/recipe-dry-run.ts @@ -0,0 +1,520 @@ +import { readFile } from "node:fs/promises" +import { basename, dirname, resolve } from "node:path" +import { SANDBOX_WORKSPACE_ROOT, validateRuntimePolicy, type MountSpec, type RuntimePolicy, type SandboxWorkspaceMode, type WorkspaceRecipe, type WorkspaceRecipePluginRuntime, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeSiteSeed, type WorkspaceRecipeWorkspace } from "@chubes4/wp-codebox-core" +import { serializeError } from "./output.js" +import { activateExtraPluginsCode, defaultWorkspaceTarget, installMuPluginsCode, pluginTarget, recipeExtraPluginFile, recipeExtraPluginSlug, recipeExtraPlugins, recipeMountType, recipeSource, recipeSourceProvenance, resolveRecipeExtraPluginFile, stagedFileMountType, stagedFileProvenance, type RecipeSourceProvenance, type RecipeSourceType, type RecipeStagedFileProvenance } from "./recipe-sources.js" +import { hasExplicitSiteSeedSelectors, parseWorkspaceRecipe, pluginRuntimeHealthProbeStep, recipePolicy, recipeWorkflowSteps, validateWorkspaceRecipe, type RecipeValidationIssue, type RecipeWorkflowPhase } from "./recipe-validation.js" + +export interface RecipeDryRunOptions { + recipePath: string + artifactsDirectory?: string +} + +export interface RecipeDryRunContext { + defaultWordPressVersion: string + resolveExecutionSpec(step: WorkspaceRecipe["workflow"]["steps"][number], recipeDirectory: string): Promise<{ command: string; args: string[] }> +} + +export interface RecipeDryRunOutput { + success: boolean + schema: "wp-codebox/recipe-run-dry-run/v1" + recipePath?: string + dryRun: true + valid: boolean + validation: { + issues: RecipeValidationIssue[] + } + plan?: RecipeDryRunPlan + error?: { + name: string + message: string + code?: string + } +} + +export interface RecipeDryRunPlan { + runtime: { + backend: string + name: string + wp: string + blueprint: unknown + } + artifacts: { + directory?: string + } + mounts: RecipeDryRunMount[] + workspaces: RecipeDryRunWorkspace[] + extra_plugins: RecipeDryRunExtraPlugin[] + pluginRuntime?: RecipeDryRunPluginRuntime + siteSeeds: RecipeDryRunSiteSeed[] + stagedFiles: RecipeDryRunStagedFile[] + secretEnv: Array<{ name: string; available: boolean }> + policy: RuntimePolicy & { + valid: boolean + issues: ReturnType["issues"] + } + workflow: { + before?: RecipeDryRunStep[] + steps: RecipeDryRunStep[] + after?: RecipeDryRunStep[] + } +} + +interface RecipeDryRunMount { + type: MountSpec["type"] + source?: string + target: string + mode: "readonly" | "readwrite" + metadata?: Record + planned?: "existing" | "generated" +} + +interface RecipeDryRunWorkspace { + index: number + source?: string + target: string + mode: "readonly" | "readwrite" + sourceMode: SandboxWorkspaceMode + seed: WorkspaceRecipeWorkspace["seed"] + generated: boolean + metadata: Record +} + +interface RecipeDryRunExtraPlugin { + source: string + sourceRef: string + sourceType: RecipeSourceType + slug: string + target: string + pluginFile: string + activate: boolean + loadAs: "plugin" | "mu-plugin" + provenance: RecipeSourceProvenance +} + +interface RecipeDryRunPluginRuntime { + label?: string + php?: WorkspaceRecipePluginRuntime["php"] + wpConfigDefines?: WorkspaceRecipePluginRuntime["wpConfigDefines"] + setup: RecipeDryRunStep[] + healthProbes: RecipeDryRunPluginRuntimeHealthProbe[] +} + +interface RecipeDryRunPluginRuntimeHealthProbe { + index: number + name: string + type: WorkspaceRecipePluginRuntimeHealthProbe["type"] + command: string + args: string[] + resolvedCommand: string + resolvedArgs: string[] + policy: RecipeDryRunStep["policy"] +} + +export interface RecipeDryRunSiteSeed { + index: number + type: WorkspaceRecipeSiteSeed["type"] + name: string + source?: string + format?: WorkspaceRecipeSiteSeed["format"] + importer?: string + scopes: WorkspaceRecipeSiteSeed["scopes"] + bounded: boolean + dryRunOnly: boolean + privacy: { + exportsParentSiteData: boolean + importsIntoSandbox: boolean + includesRecordData: boolean + secrets: "excluded-by-default" + } +} + +export interface RecipeDryRunStagedFile { + index: number + source: string + sourceRef: string + target: string + type: MountSpec["type"] + provenance: RecipeStagedFileProvenance +} + +interface RecipeDryRunStep { + phase: RecipeWorkflowPhase + index: number + command: string + args: string[] + parsedArgs: Record + resolvedCommand: string + resolvedArgs: string[] + resolvedParsedArgs: Record + policy: { + status: "allowed" | "denied" + command: string + allowedCommands: string[] + approvals: RuntimePolicy["approvals"] + filesystem: RuntimePolicy["filesystem"] + secrets: RuntimePolicy["secrets"] + } +} + +export async function dryRunRecipe(options: RecipeDryRunOptions, context: RecipeDryRunContext): Promise { + const recipePath = resolve(options.recipePath) + try { + const recipeDirectory = dirname(recipePath) + const raw = await readFile(recipePath, "utf8") + const recipe = parseWorkspaceRecipe(raw, recipePath) + const issues = await validateWorkspaceRecipe(recipe, recipePath) + + if (issues.length > 0) { + return { + success: false, + schema: "wp-codebox/recipe-run-dry-run/v1", + recipePath, + dryRun: true, + valid: false, + validation: { issues }, + error: { + name: "RecipeValidationError", + message: `Recipe validation failed with ${issues.length} issue${issues.length === 1 ? "" : "s"}.`, + }, + } + } + + return { + success: true, + schema: "wp-codebox/recipe-run-dry-run/v1", + recipePath, + dryRun: true, + valid: true, + validation: { issues }, + plan: await recipeDryRunPlan(recipe, recipeDirectory, options, context), + } + } catch (error) { + return { + success: false, + schema: "wp-codebox/recipe-run-dry-run/v1", + recipePath, + dryRun: true, + valid: false, + validation: { + issues: [ + { + code: "invalid-recipe", + path: "$", + message: error instanceof SyntaxError ? `Recipe JSON is invalid: ${error.message}` : error instanceof Error ? error.message : String(error), + }, + ], + }, + error: serializeError(error), + } + } +} + +async function recipeDryRunPlan(recipe: WorkspaceRecipe, recipeDirectory: string, options: RecipeDryRunOptions, context: RecipeDryRunContext): Promise { + const policy = recipePolicy(recipe) + const policyValidation = validateRuntimePolicy(policy) + const workspaces = recipeDryRunWorkspaces(recipe, recipeDirectory) + const extraPlugins = recipeDryRunExtraPlugins(recipe, recipeDirectory) + const pluginRuntime = await recipeDryRunPluginRuntime(recipe, recipeDirectory, policy, context) + const siteSeeds = recipeDryRunSiteSeeds(recipe, recipeDirectory) + const stagedFiles = await recipeDryRunStagedFiles(recipe, recipeDirectory) + const workflowSteps = await recipeDryRunSteps(recipe, recipeDirectory, policy, context) + const recipeMounts = await Promise.all((recipe.inputs?.mounts ?? []).map(async (mount) => { + const source = resolve(recipeDirectory, mount.source) + return { + type: await recipeMountType(source, mount.type), + source, + target: mount.target, + mode: mount.mode ?? "readwrite" as const, + ...(mount.metadata ? { metadata: mount.metadata } : {}), + planned: "existing" as const, + } + })) + const mounts: RecipeDryRunMount[] = [ + ...workspaces.map((workspace) => ({ + type: "directory" as const, + ...(workspace.source ? { source: workspace.source } : {}), + target: workspace.target, + mode: workspace.mode, + metadata: workspace.metadata, + planned: workspace.generated ? "generated" as const : "existing" as const, + })), + ...extraPlugins.map((plugin) => ({ + type: "directory" as const, + source: plugin.source, + target: plugin.target, + mode: "readonly" as const, + metadata: { + kind: "extra-plugin", + slug: plugin.slug, + source: plugin.provenance, + }, + planned: "existing" as const, + })), + ...recipeMounts, + ...stagedFiles.map((stagedFile) => ({ + type: stagedFile.type, + source: stagedFile.source, + target: stagedFile.target, + mode: "readwrite" as const, + metadata: { + kind: "staged-file", + index: stagedFile.index, + source: stagedFile.provenance, + }, + planned: "generated" as const, + })), + ] + + return { + runtime: { + backend: recipe.runtime?.backend ?? "wordpress-playground", + name: recipe.runtime?.name ?? "wp-codebox-recipe", + wp: recipe.runtime?.wp ?? context.defaultWordPressVersion, + blueprint: recipe.runtime?.blueprint ?? { steps: [] }, + }, + artifacts: stripUndefined({ + directory: options.artifactsDirectory ?? recipe.artifacts?.directory, + }), + mounts, + workspaces, + extra_plugins: extraPlugins, + ...(pluginRuntime ? { pluginRuntime } : {}), + siteSeeds, + stagedFiles, + secretEnv: (recipe.inputs?.secretEnv ?? []).map((name) => ({ + name, + available: process.env[name] !== undefined, + })), + policy: { + ...policy, + valid: policyValidation.valid, + issues: policyValidation.issues, + }, + workflow: { + ...(recipe.workflow.before ? { before: workflowSteps.filter((step) => step.phase === "before") } : {}), + steps: workflowSteps, + ...(recipe.workflow.after ? { after: workflowSteps.filter((step) => step.phase === "after") } : {}), + }, + } +} + +async function recipeDryRunSteps(recipe: WorkspaceRecipe, recipeDirectory: string, policy: RuntimePolicy, context: RecipeDryRunContext): Promise { + const steps: Array> = [] + const dryRunExtraPlugins = await Promise.all(recipeExtraPlugins(recipe).map(async (plugin) => { + const slug = recipeExtraPluginSlug(plugin) + return { + source: plugin.source, + slug, + target: pluginTarget(slug, plugin.loadAs ?? "plugin"), + pluginFile: await resolveRecipeExtraPluginFile(plugin, recipeDirectory), + activate: plugin.activate !== false, + loadAs: plugin.loadAs ?? "plugin", + cleanupPaths: [], + provenance: recipeSourceProvenance(recipeSource(plugin.source, plugin.sha256), recipeDirectory), + } + })) + const muPluginInstallCode = installMuPluginsCode(dryRunExtraPlugins) + if (muPluginInstallCode) { + steps.push(recipeDryRunStep({ command: "wordpress.run-php", args: [`code=${muPluginInstallCode}`] }, recipeDirectory, policy, "setup", -2, context, "install-mu-plugins")) + } + const pluginActivationCode = activateExtraPluginsCode(dryRunExtraPlugins) + if (pluginActivationCode) { + steps.push(recipeDryRunStep({ command: "wordpress.run-php", args: [`code=${pluginActivationCode}`] }, recipeDirectory, policy, "setup", -1, context, "activate-extra-plugins")) + } + + for (const workflowStep of recipeWorkflowSteps(recipe)) { + steps.push(recipeDryRunStep(workflowStep.step, recipeDirectory, policy, workflowStep.phase, workflowStep.index, context)) + } + + return Promise.all(steps) +} + +async function recipeDryRunStep(step: WorkspaceRecipe["workflow"]["steps"][number], recipeDirectory: string, policy: RuntimePolicy, phase: RecipeWorkflowPhase, index: number, context: RecipeDryRunContext, label?: string): Promise { + const resolved = await context.resolveExecutionSpec(step, recipeDirectory) + const allowed = policy.commands.includes(resolved.command) + return { + phase, + index, + command: label ?? step.command, + args: step.args ?? [], + parsedArgs: parseRecipeArgs(step.args ?? []), + resolvedCommand: resolved.command, + resolvedArgs: resolved.args, + resolvedParsedArgs: parseRecipeArgs(resolved.args), + policy: { + status: allowed ? "allowed" : "denied", + command: resolved.command, + allowedCommands: policy.commands, + approvals: policy.approvals, + filesystem: policy.filesystem, + secrets: policy.secrets, + }, + } +} + +function parseRecipeArgs(args: string[]): Record { + const parsed: Record = {} + for (const arg of args) { + const separator = arg.indexOf("=") + if (separator === -1) { + parsed[arg] = true + continue + } + + parsed[arg.slice(0, separator)] = arg.slice(separator + 1) + } + + return parsed +} + +function recipeDryRunWorkspaces(recipe: WorkspaceRecipe, recipeDirectory: string): RecipeDryRunWorkspace[] { + return (recipe.inputs?.workspaces ?? []).map((workspace, index) => { + const slug = workspace.seed.slug ?? basename(resolve(recipeDirectory, workspace.seed.source ?? `workspace-${index}`)) + const target = workspace.target ?? defaultWorkspaceTarget(workspace, slug) + const generated = workspace.seed.type !== "directory" + const sourceMode = workspace.sourceMode ?? "repo-backed" + const metadata = { + kind: "recipe-workspace", + index, + seed: workspace.seed, + target, + workspaceRoot: SANDBOX_WORKSPACE_ROOT, + sourceMode, + dryRun: true, + } + + return { + index, + ...(generated ? {} : { source: resolve(recipeDirectory, workspace.seed.source ?? "") }), + target, + mode: workspace.mode ?? "readwrite", + sourceMode, + seed: workspace.seed, + generated, + metadata, + } + }) +} + +function recipeDryRunExtraPlugins(recipe: WorkspaceRecipe, recipeDirectory: string): RecipeDryRunExtraPlugin[] { + return recipeExtraPlugins(recipe).map((plugin) => { + const slug = recipeExtraPluginSlug(plugin) + const source = recipeSource(plugin.source, plugin.sha256) + const provenance = recipeSourceProvenance(source, recipeDirectory) + return { + source: source.type === "local" ? resolve(recipeDirectory, plugin.source) : source.resolvedUrl, + sourceRef: plugin.source, + sourceType: source.type, + slug, + target: pluginTarget(slug, plugin.loadAs ?? "plugin"), + pluginFile: recipeExtraPluginFile(plugin), + activate: plugin.activate !== false, + loadAs: plugin.loadAs ?? "plugin", + provenance, + } + }) +} + +async function recipeDryRunPluginRuntime(recipe: WorkspaceRecipe, recipeDirectory: string, policy: RuntimePolicy, context: RecipeDryRunContext): Promise { + const pluginRuntime = recipe.inputs?.pluginRuntime + if (!pluginRuntime) { + return undefined + } + + const setup = await Promise.all((pluginRuntime.setup ?? []).map((step, index) => recipeDryRunStep(step, recipeDirectory, policy, "setup", pluginRuntimeSetupStepIndex(index), context, `plugin-runtime.setup:${index}`))) + const healthProbes = await Promise.all((pluginRuntime.healthProbes ?? []).map(async (probe, index) => { + const step = pluginRuntimeHealthProbeStep(probe) + const dryRunStep = await recipeDryRunStep(step, recipeDirectory, policy, "setup", pluginRuntimeHealthProbeStepIndex(index), context, `plugin-runtime.health:${probe.name}`) + return { + index, + name: probe.name, + type: probe.type, + command: dryRunStep.command, + args: dryRunStep.args, + resolvedCommand: dryRunStep.resolvedCommand, + resolvedArgs: dryRunStep.resolvedArgs, + policy: dryRunStep.policy, + } + })) + + return stripUndefined({ + label: pluginRuntime.label, + php: pluginRuntime.php, + wpConfigDefines: pluginRuntime.wpConfigDefines, + setup, + healthProbes, + }) +} + +export function recipeDryRunSiteSeeds(recipe: WorkspaceRecipe, recipeDirectory: string): RecipeDryRunSiteSeed[] { + return (recipe.inputs?.siteSeeds ?? []).map((siteSeed, index) => ({ + index, + type: siteSeed.type, + name: siteSeed.name, + ...(siteSeed.source ? { source: resolve(recipeDirectory, siteSeed.source) } : {}), + ...(siteSeed.format ? { format: siteSeed.format } : {}), + ...(siteSeed.type === "fixture" ? { importer: siteSeed.format ?? "json" } : {}), + scopes: siteSeed.scopes, + bounded: siteSeedScopesAreBounded(siteSeed), + dryRunOnly: siteSeed.type !== "fixture", + privacy: { + exportsParentSiteData: false, + importsIntoSandbox: siteSeed.type === "fixture", + includesRecordData: siteSeed.type === "fixture", + secrets: "excluded-by-default", + }, + })) +} + +async function recipeDryRunStagedFiles(recipe: WorkspaceRecipe, recipeDirectory: string): Promise { + return Promise.all((recipe.inputs?.stagedFiles ?? []).map(async (stagedFile, index) => { + const source = resolve(recipeDirectory, stagedFile.source) + return { + index, + source, + sourceRef: stagedFile.source, + target: stagedFile.target, + type: await stagedFileMountType(source), + provenance: stagedFileProvenance(stagedFile, recipeDirectory), + } + })) +} + +export function pluginRuntimeSetupStepIndex(index: number): number { + return -1000 - index +} + +export function pluginRuntimeHealthProbeStepIndex(index: number): number { + return -2000 - index +} + +export function siteSeedScopesAreBounded(siteSeed: WorkspaceRecipeSiteSeed): boolean { + for (const [scopeName, scope] of Object.entries(siteSeed.scopes)) { + if (!scope || scope === true) { + continue + } + + if (scopeName === "options" && (!scope.names || scope.names.length === 0)) { + return false + } + + if (siteSeed.type === "parent_site" && scope.maxRecords === undefined && !hasExplicitSiteSeedSelectors(scope)) { + return false + } + + if (scopeName === "users" && scope.anonymize === false) { + return false + } + + if (scopeName === "media" && scope.includeFiles === true && siteSeed.type === "parent_site") { + return false + } + } + + return Object.values(siteSeed.scopes).some((scope) => scope !== undefined && scope !== false) +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T +}