/* RFC-0806: leitstand.service.dev-deploy command handler — deploys a service to the dev channel (wrangler.dev.jsonc) with pre-deploy gates, lock, wrangler deploy, health check, and dev state recording. Do not deploy to production — use leitstand.service.promote for that. Do not run subdomain validation — dev Workers use *.workers.dev URLs only. RFC-0806: initial dev-deploy command handler. RFC-1188: Web Bot Auth outbound identity: wba-sign primitives, directory emit+sweep, AGS-WBA-01, leitstand signed fetches RFC-1188: fo-review fix — signing failures surface through logger.warn via onSigningFailure RFC-1254: drop unused io-migration leftovers */ import path from "node:path"; import type { KernelCommandInput, KernelCommandResult } from "@warpgogol/werkstatt-engine/kernel"; import { readServicesRegistry, findServiceEntry } from "../sternsystem/registry-io.ts"; import { resolveOutboundAgentClient } from "./wba-signer.ts"; import { flagString, flagBoolean, generateOperationId, runWranglerDeploy, extractWorkersDevUrl, runHealthCheck, parseEnvFile, runPreDeployGates, runBuildCheck, runSmokeCheck, runIntegrationTests, acquireServiceLock, releaseServiceLock, recordDevDeployState, updateWorkersDevUrl, type ServiceDevDeployData, type KernelRuntimeContext, } from "./service-deploy-helpers.ts"; export async function runLeitstandServiceDevDeploy( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const serviceId = flagString(input, "service"); const skipHealthCheck = flagBoolean(input, "skip-health-check"); if (!serviceId) { throw new Error("[leitstand.service.dev-deploy] --service is required"); } const operationId = generateOperationId(); const startedAt = new Date().toISOString(); const registry = await readServicesRegistry(workspaceRoot); const serviceEntry = findServiceEntry(registry, serviceId); if (!serviceEntry) { throw new Error( `[leitstand.service.dev-deploy] service '${serviceId}' not found in services/registry.yaml`, ); } const serviceDir = path.join(workspaceRoot, "services", serviceId); if (!(await context.io.exists(serviceDir))) { throw new Error(`[leitstand.service.dev-deploy] services/${serviceId}/ does not exist`); } const devConfigPath = path.join(serviceDir, "wrangler.dev.jsonc"); if (!(await context.io.exists(devConfigPath))) { throw new Error( `[leitstand.service.dev-deploy] services/${serviceId}/wrangler.dev.jsonc not found — cannot dev-deploy without dev config`, ); } await acquireServiceLock(workspaceRoot, serviceId, operationId, "leitstand.service.dev-deploy"); // Auto-create .env.dev from .env if .env.dev is missing but .env and .env.dev.example exist const envDevPath = path.join(serviceDir, ".env.dev"); const envPath = path.join(serviceDir, ".env"); const envDevExamplePath = path.join(serviceDir, ".env.dev.example"); if ( !(await context.io.exists(envDevPath)) && (await context.io.exists(envPath)) && (await context.io.exists(envDevExamplePath)) ) { try { await context.io.copyFile(envPath, envDevPath); logger.info(`[leitstand.service.dev-deploy] auto-created .env.dev from .env`); } catch { // best-effort — deploy.preflight will report the error } } try { // 1. Pre-deploy gates: service.naming.validate, service.registry.validate, services.check.run, build:check, deploy.preflight --dev const gates = [ { commandName: "service.naming.validate", argv: [] }, { commandName: "service.registry.validate", argv: [] }, { commandName: "services.check.run", argv: [] }, { commandName: "deploy.preflight", argv: ["--service", serviceId, "--dev"] }, ]; const gateResults = await runPreDeployGates(workspaceRoot, gates, logger); // 1b. build:check gate (not a kernel command — runs pnpm script directly) const buildCheckResult = await runBuildCheck(serviceDir, logger); gateResults.push(buildCheckResult); const failedGate = gateResults.find((g) => !g.passed); if (failedGate) { const failedData: ServiceDevDeployData = { command: "leitstand.service.dev-deploy", serviceId, workerName: serviceEntry.workerName, deployState: "failed", workersDevUrl: serviceEntry.workersDevUrl ?? "", healthState: "unknown", preDeployGates: gateResults, startedAt, completedAt: new Date().toISOString(), operationId, }; return { data: failedData, exitCode: 1, summary: `[leitstand.service.dev-deploy] ${serviceId}: pre-deploy gate '${failedGate.command}' failed — ${failedGate.summary}`, nextSteps: [ { action: `Fix the failing gate '${failedGate.command}' for ${serviceId}, then re-run leitstand.service.dev-deploy`, kind: "required", }, ], }; } // 2. Read .env.dev for secrets const envDevPath = path.join(serviceDir, ".env.dev"); const deployEnv = await parseEnvFile(envDevPath); // 3. Wrangler deploy with dev config logger.info( `[leitstand.service.dev-deploy] deploying ${serviceEntry.workerName}-dev via wrangler…`, ); const wranglerResult = await runWranglerDeploy(serviceDir, "wrangler.dev.jsonc", deployEnv); if (wranglerResult.exitCode !== 0) { await recordDevDeployState(workspaceRoot, serviceId, { at: new Date().toISOString(), state: "failed", operationId, }); const failedData: ServiceDevDeployData = { command: "leitstand.service.dev-deploy", serviceId, workerName: serviceEntry.workerName, deployState: "failed", workersDevUrl: extractWorkersDevUrl(wranglerResult.stdout) ?? "", healthState: "unknown", preDeployGates: gateResults, startedAt, completedAt: new Date().toISOString(), operationId, }; return { data: failedData, exitCode: 1, summary: `[leitstand.service.dev-deploy] ${serviceId}: wrangler deploy failed — ${wranglerResult.stderr.slice(-200)}`, nextSteps: [ { action: `Check the wrangler deploy error for ${serviceId}, fix the issue, then re-run leitstand.service.dev-deploy`, kind: "required", }, ], }; } // 4. Resolve workersDevUrl from wrangler output const deployedUrl = extractWorkersDevUrl(wranglerResult.stdout) ?? ""; // 5. Health check (skip if --skip-health-check or no URL) let healthState: "healthy" | "unhealthy" | "unknown" = "unknown"; if (!skipHealthCheck && deployedUrl) { logger.info(`[leitstand.service.dev-deploy] running health check on ${deployedUrl}…`); // RFC-1188: platform-originated agent-directed probe — sign when the // identity resolves (WBA_IDENTITY_SITE), degrade to unsigned otherwise. const outbound = await resolveOutboundAgentClient(workspaceRoot, undefined, undefined, { onSigningFailure: (e) => logger.warn( `[leitstand.service.dev-deploy] outbound signing failed, request sent unsigned: ${e instanceof Error ? e.message : String(e)}`, ), }); healthState = await runHealthCheck( deployedUrl, serviceEntry.healthCheckPath, outbound.client.fetch, ); } // 6. Smoke check (RFC-0825) let smokeResult: ServiceDevDeployData["smokeResult"]; if (deployedUrl) { smokeResult = await runSmokeCheck(workspaceRoot, serviceId, deployedUrl, logger); } // 6b. Integration tests (RFC-0826) — non-fatal, warnings only let integrationResult: ServiceDevDeployData["integrationResult"]; if (deployedUrl) { integrationResult = await runIntegrationTests(workspaceRoot, serviceId, deployedUrl, logger); } // 7. Record dev deploy state await recordDevDeployState(workspaceRoot, serviceId, { at: new Date().toISOString(), state: "succeeded", operationId, }); // 8. Update workersDevUrl in registry if resolved if (deployedUrl) { await updateWorkersDevUrl(workspaceRoot, serviceId, deployedUrl); } const completedAt = new Date().toISOString(); const data: ServiceDevDeployData = { command: "leitstand.service.dev-deploy", serviceId, workerName: serviceEntry.workerName, deployState: "succeeded", workersDevUrl: deployedUrl, healthState, smokeResult, integrationResult, preDeployGates: gateResults, startedAt, completedAt, operationId, }; return { data, exitCode: 0, summary: `[leitstand.service.dev-deploy] ${serviceId}: dev-deployed (${healthState})`, nextSteps: [ { action: `Promote service to production: pnpm exec werkstatt run leitstand.service.promote --service ${serviceId}`, kind: "optional", }, ], }; } finally { await releaseServiceLock(workspaceRoot, serviceId); } }