UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

3,067 lines • 141 kB
import { gateSecret } from "../secret-gate.js";
import { serializeDeployManifest, manifestExportUnsupported } from "./manifest-export.js";
import { prototypeBalance } from "./prototype-balance.js";
import { afterFaucet } from "./funded-payment.js";
import { describeLocalPreflight } from "./preflight.js";
import { resolveApplicationScope, materializeTemplates } from "./app-scope.js";
import { scanDeploymentSources } from "./source-scan.js";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { Run402Action } from "../actions.js";
import { RUN402_APP_SCHEMA_ID, RUN402_APP_MANIFEST_FILENAME, compileRun402AppInstallGraph, createRun402AppUpResult, } from "../app-up.js";
import { LocalError, Run402DeployError, isRun402Error } from "../errors.js";
import { assertLocalFileReferencesExist, loadDeployManifest, manifestNotFoundError, normalizeDeployManifest, } from "./deploy-manifest.js";
import { declaredAgentName, detectClientName } from "./client-detect.js";
function deployEventDetails(event) {
    const raw = event;
    const details = { deploy_event: event.type };
    if (typeof raw.id === "string")
        details.deploy_event_id = raw.id;
    if (typeof raw.operation_id === "string")
        details.deploy_operation_id = raw.operation_id;
    if (typeof raw.project_id === "string")
        details.deploy_project_id = raw.project_id;
    if (typeof raw.phase === "string")
        details.deploy_phase = raw.phase;
    if (typeof raw.status === "string")
        details.deploy_status = raw.status;
    if (typeof raw.message === "string")
        details.deploy_message = raw.message;
    if (typeof raw.created_at === "string")
        details.deploy_created_at = raw.created_at;
    if (typeof raw.updated_at === "string")
        details.deploy_updated_at = raw.updated_at;
    if (raw.details && typeof raw.details === "object" && !Array.isArray(raw.details)) {
        const deployDetails = raw.details;
        details.deploy_details = deployDetails;
        if (typeof deployDetails.duration_ms === "number")
            details.deploy_duration_ms = deployDetails.duration_ms;
    }
    return details;
}
const DEFAULT_BOOTSTRAP_TIER = "prototype";
const MANIFEST_CANDIDATES = [
    RUN402_APP_MANIFEST_FILENAME,
    "run402.deploy.json",
    "app.json",
];
const EXECUTABLE_MANIFEST_CANDIDATES = [
    "run402.deploy.ts",
    "run402.deploy.mts",
    "run402.deploy.cts",
    "run402.deploy.js",
    "run402.deploy.mjs",
    "run402.deploy.cjs",
];
const TIER_RANK = {
    prototype: 1,
    hobby: 2,
    team: 3,
};
const DEFAULT_PROPAGATION_BUDGET_SECONDS = 120;
const EDGE_PROPAGATION_FRESH_WINDOW_MS = 300_000;
const EDGE_PROPAGATION_TYPICAL_SECONDS = 60;
const VERIFY_SENTINEL_CODES = new Set([
    "SUBDOMAIN_NOT_CONFIGURED",
    "HOST_NOT_CONFIGURED",
    "NO_SITE_DEPLOYED",
]);
const execFileAsync = promisify(execFile);
/**
 * Node implementation of the action runner. The public CLI should treat this
 * as the orchestration kernel and stay a flag parser / renderer.
 */
export class NodeActions {
    sdk;
    opts;
    constructor(sdk, opts = {}) {
        this.sdk = sdk;
        this.opts = opts;
    }
    async run(input, opts = {}) {
        gateSecret(this.sdk, "actions.run", input, opts);
        const run = new ActionRun(input, opts, this.#targetKind());
        try {
            switch (input.type) {
                case Run402Action.ProjectsProvision:
                    return await this.#runProjectsProvision(input, run);
                case Run402Action.TierSet:
                    return await this.#runTierSet(input, run);
                case Run402Action.Up:
                    return await this.#runUp(input, run);
                default:
                    assertNever(input);
            }
        }
        catch (err) {
            run.failLast(err);
            if (isRun402Error(err)) {
                const body = err.body && typeof err.body === "object" ? err.body : {};
                Object.defineProperty(err, "body", { value: { ...body, workflow: {
                            action: input.type,
                            steps: run.steps.map(({ id, action, state, mutation }) => ({ id, action, state, mutation })),
                        } }, configurable: true });
                throw err;
            }
            throw withActionDetails(new LocalError(err instanceof Error ? err.message : String(err), "running Run402 action", {
                cause: err,
                code: "RUN402_ACTION_FAILED",
                details: { action: input.type, steps: run.steps },
            }), run);
        }
    }
    async up(input = {}, opts) {
        gateSecret(this.sdk, "actions.up", input, opts);
        return this.run({ type: Run402Action.Up, ...input }, opts);
    }
    #targetKind() {
        return this.opts.targetKind ?? "unknown";
    }
    async #runProjectsProvision(input, run) {
        if (this.#targetKind() === "core") {
            throw run.error("Project provisioning through the Cloud gateway is unavailable for a Run402 Core target.", "RUN402_CLOUD_ACTION_UNAVAILABLE", { action: input.type, target: this.#targetKind() });
        }
        const tier = input.tier ?? DEFAULT_BOOTSTRAP_TIER;
        if (run.autoPrerequisites) {
            await this.#ensureCloudTier(run, tier);
        }
        const idempotencyKey = input.idempotencyKey ?? run.rootIdempotencyKey;
        const step = run.addStep({
            action: Run402Action.ProjectsProvision,
            description: "Provision Run402 project",
            mutation: true,
            auto: false,
            details: {
                tier,
                name: input.name ?? null,
                org_id: input.orgId ?? null,
                idempotency_key: idempotencyKey ?? null,
            },
        });
        await run.approve(step, ["projects.provision"], "Provision a new Run402 Cloud project.");
        if (run.dryRun) {
            run.setState(step, "planned");
            return run.result({});
        }
        run.setState(step, "running");
        const result = await this.sdk.projects.provision({
            tier,
            name: input.name,
            orgId: input.orgId,
            idempotencyKey,
        });
        run.setState(step, "succeeded", { project_id: result.project_id });
        return run.result(result);
    }
    async #runTierSet(input, run) {
        if (this.#targetKind() === "core") {
            throw run.error("Tier leases are a Run402 Cloud capability and are skipped on Run402 Core targets.", "RUN402_CLOUD_ACTION_UNAVAILABLE", { action: input.type, target: this.#targetKind() });
        }
        if (run.autoPrerequisites) {
            await this.#ensureWallet(run);
        }
        const idempotencyKey = input.idempotencyKey ?? run.rootIdempotencyKey;
        const step = run.addStep({
            action: Run402Action.TierSet,
            description: `Set Run402 tier to ${input.tier}`,
            mutation: true,
            auto: false,
            details: { tier: input.tier, idempotency_key: idempotencyKey ?? null },
        });
        await run.approve(step, ["tier.set"], `Set the ${input.tier} tier (start, renew, or upgrade).`);
        if (run.dryRun) {
            run.setState(step, "planned");
            return run.result({});
        }
        run.setState(step, "running");
        const result = await this.sdk.tier.set(input.tier, idempotencyKey ? { idempotencyKey } : {});
        run.setState(step, "succeeded", { tier: result.tier, action: result.action });
        return run.result(result);
    }
    async #runUp(input, run) {
        if (input.source && input.dir) {
            throw run.error("Pass either an app source positional/repo URL or --dir, not both.", "RUN402_SOURCE_CONFLICT", { source: input.source, dir: input.dir });
        }
        const startedAt = new Date().toISOString();
        const source = await this.#resolveUpSource(input, run);
        // A repo source is cloned into a temp dir. `cleanupDir` was returned for
        // exactly this purpose and NOTHING read it, so every `run402 up <git-url>`
        // left its full checkout in the OS temp dir — silently, on success and on
        // failure alike. An agent looping deploys (CI, retries) grows /tmp without
        // bound, and on a fixed-wallet container that eventually fails writes
        // with no hint at the cause.
        // Local-directory sources set no cleanupDir and are never touched here.
        try {
            return await this.#runUpFromWorkspace(input, run, source, startedAt);
        }
        finally {
            if (source.cleanupDir) {
                await rm(source.cleanupDir, { recursive: true, force: true }).catch(() => { });
            }
        }
    }
    async #runUpFromWorkspace(input, run, source, startedAt) {
        let workspaceDir = source.workspaceDir;
        const manifest = await this.#discoverAndValidateManifest(input, workspaceDir, source.metadata, run);
        workspaceDir = dirname(manifest.manifestPath);
        const target = !input.verifyOnly ? await resolveDeploymentTarget({
            appRoot: workspaceDir, manifestPath: manifest.manifestPath, projectId: input.projectId,
            manifestProjectId: manifest.manifestProjectId ?? manifest.appSpec?.project.id, name: input.name,
            targetKind: this.#targetKind(), apiBase: this.sdk.apiBase,
            allowUnresolved: run.executionMode === "check" || (run.executionMode === "printSpec" || run.executionMode === "printManifest") || run.dryRun,
        }) : null;
        if (!input.verifyOnly && process.env.RUN402_DEPLOY_SKIP_SCAN !== "1") {
            const scan = scanDeploymentSources(manifest.releaseSpec ?? {}, workspaceDir);
            if (scan.errors.length)
                throw run.error("Source scan blocked this application.", "R402_AUTH_PREFLIGHT_FAILED", scan);
        }
        if (manifest.manifestKind === "app") {
            if (run.executionMode === "printManifest")
                manifestExportUnsupported("app", "app resources and build intent cannot be faithfully represented as a release manifest");
            if (input.verifyOnly) {
                return this.#verifyAppManifestOnly(input, manifest, workspaceDir, run, startedAt);
            }
            const block = this.#firstAppUpBlock(input, manifest, run);
            const appResult = this.#planAppUpResult(input, manifest, run, {
                startedAt,
                status: block ? "blocked" : "planned",
                dryRun: run.dryRun || run.executionMode === "check" || run.executionMode === "printSpec",
                projectId: target?.project_id ?? null,
                diagnostics: block?.diagnostics,
                nextActions: block?.nextActions,
                blockedNodeId: block?.nodeId,
            });
            if (run.executionMode === "check" || run.executionMode === "printSpec" || run.dryRun) {
                return run.result({
                    project_id: target?.project_id ?? null,
                    manifest_path: manifest.manifestPath,
                    app_graph: manifest.appGraph,
                    app_result: appResult,
                    preflight: describeLocalPreflight({ appRoot: workspaceDir, manifestPath: manifest.manifestPath, target: target, authoring: manifest.appSpec?.release, spec: manifest.releaseSpec ?? undefined, buildDeferred: !!manifest.appSpec?.build?.commands?.length, apiBase: this.sdk.apiBase, profile: this.opts.profile }),
                });
            }
            if (block) {
                return run.result({
                    project_id: target?.project_id ?? null,
                    manifest_path: manifest.manifestPath,
                    app_graph: manifest.appGraph,
                    app_result: appResult,
                });
            }
            return this.#applyAppManifest(input, manifest, workspaceDir, run, startedAt);
        }
        if (input.verifyOnly) {
            return this.#verifyDeployManifestOnly(input, manifest, workspaceDir, run);
        }
        if (run.executionMode === "check" || (run.executionMode === "printSpec" || run.executionMode === "printManifest")) {
            if (!manifest.releaseSpec) {
                throw run.error("Internal error: release manifest did not produce a ReleaseSpec.", "RUN402_ACTION_INTERNAL");
            }
            return run.result({
                project_id: target?.project_id ?? null,
                manifest_path: manifest.manifestPath,
                preflight: describeLocalPreflight({ appRoot: workspaceDir, manifestPath: manifest.manifestPath, target: target, authoring: manifest.loaded?.manifest, spec: manifest.releaseSpec, apiBase: this.sdk.apiBase, profile: this.opts.profile }),
                ...(run.executionMode === "printManifest" ? { manifest: serializeDeployManifest(manifest.loaded, workspaceDir, input.projectId) } : {}),
                ...(run.executionMode === "printSpec" ? { spec: { ...manifest.releaseSpec, project_id: target?.project_id ?? undefined } } : {}),
            });
        }
        if (run.executionMode === "plan") {
            run.skipStep({
                action: Run402Action.TierSet,
                description: "Skip recursive tier bootstrap for reviewed-plan mode",
                mutation: false,
                auto: true,
                details: { mode: "plan" },
            });
        }
        else if (this.#targetKind() !== "core") {
            await this.#ensureCloudTier(run, input.tier ?? DEFAULT_BOOTSTRAP_TIER);
        }
        else {
            run.skipStep({
                action: Run402Action.TierSet,
                description: "Skip Cloud wallet and tier prerequisites for Run402 Core",
                mutation: false,
                auto: true,
                details: { target: this.#targetKind() },
            });
        }
        const resolved = await this.#resolveProject(input, manifest, workspaceDir, run);
        const normalized = await loadDeployManifest(manifest.manifestPath, {
            project: resolved.projectId,
        });
        if (!hasDeployableContent(normalized.spec)) {
            throw run.error("Deploy manifest contains no deployable sections.", "MANIFEST_EMPTY", { manifest_path: manifest.manifestPath });
        }
        const releaseSpec = normalized.spec;
        if (resolved.shouldWriteLink && run.executionMode !== "plan") {
            await this.#writeWorkspaceProjectLink(resolved.linkPath, {
                schema_version: "run402.workspace-project.v1",
                project_id: resolved.projectId,
                ...(input.name ? { name: input.name } : {}),
                target: { kind: this.#targetKind(), api_base: this.sdk.apiBase },
                created_at: resolved.link?.created_at ?? new Date().toISOString(),
                updated_at: new Date().toISOString(),
            }, resolved.link, run);
        }
        const deployStep = run.addStep({
            action: "deploy.apply",
            description: run.executionMode === "plan" ? "Create reviewed deploy plan" : "Apply deploy manifest",
            mutation: run.executionMode !== "plan",
            auto: false,
            details: {
                project_id: resolved.projectId === "prj_planned" ? null : resolved.projectId,
                manifest_path: manifest.manifestPath,
            },
        });
        if (run.dryRun) {
            run.setState(deployStep, "planned");
            return run.result({
                project_id: resolved.projectId === "prj_planned" ? null : resolved.projectId,
                manifest_path: manifest.manifestPath,
                ...(resolved.shouldWriteLink ? { workspace_link_path: resolved.linkPath } : {}),
            });
        }
        run.setState(deployStep, "running");
        const projectKeys = await this.#assertLocalProjectKeys(resolved.projectId, run);
        const identity = run.executionMode === "plan"
            ? undefined
            : await this.#ensureIdentity(input, resolved.projectId, run);
        const scoped = this.sdk.project(resolved.projectId);
        const explicitDeployIdempotencyKey = input.idempotencyKey ?? normalized.idempotencyKey ?? manifest.idempotencyKey;
        if (run.executionMode === "plan") {
            const planned = await scoped.apply.plan(releaseSpec, {
                mode: "reviewedPlan",
                idempotencyKey: explicitDeployIdempotencyKey,
            });
            const plan = withUpReviewedPlanNextAction(planned.plan, manifest.manifestPath);
            run.setState(deployStep, "succeeded", {
                plan_id: plan.plan_id,
                plan_fingerprint: plan.plan_fingerprint ?? null,
                plan_expires_at: plan.plan_expires_at ?? null,
            });
            return run.result({
                project_id: resolved.projectId,
                manifest_path: manifest.manifestPath,
                plan,
            });
        }
        const requiredPlan = reviewedPlanRequirement(run.executionMode);
        const applyUp = () => scoped.apply(releaseSpec, {
            idempotencyKey: explicitDeployIdempotencyKey,
            allowWarnings: input.allowWarnings,
            allowWarningCodes: input.allowWarningCodes,
            ...(input.noRehearse ? { noRehearse: true } : {}),
            ...(requiredPlan ? { requiredPlan } : {}),
            target: this.#targetKind() === "core" ? "core" : "cloud",
            onEvent: (event) => {
                run.options.onEvent?.({
                    type: "action.step",
                    action: Run402Action.Up,
                    step: {
                        ...deployStep,
                        details: { ...deployStep.details, ...deployEventDetails(event) },
                    },
                });
            },
        });
        let deploy;
        try {
            deploy = await applyUp();
        }
        catch (err) {
            // A failed rehearsal is a first-class outcome, not a generic step
            // failure: surface the stable code, the report, and its next actions
            // (`commit_plan` / `discard_branch` / `keep_branch`) so the agent can
            // act on them without re-reading logs. Nothing was committed.
            if (err instanceof Run402DeployError && err.code === "REHEARSAL_FAILED") {
                const body = (err.body ?? {});
                throw run.error(err.message, "REHEARSAL_FAILED", {
                    project_id: resolved.projectId,
                    plan_id: err.planId,
                    operation_id: err.operationId,
                    rehearsal: body.rehearsal ?? null,
                    next_actions: body.next_actions ?? [],
                });
            }
            throw err;
        }
        run.setState(deployStep, "succeeded", {
            release_id: deploy.release_id,
            operation_id: deploy.operation_id,
        });
        // Deploy-manifest verify.http[] — same propagation-tolerant checks the
        // app-spec path runs, surfaced as `verification.http[]` + `verify` on
        // the up result.
        const verifyChecks = manifest.verify?.http ?? [];
        let verifyBlock = {};
        if (verifyChecks.length > 0) {
            // The deploy result's own urls are the authoritative origin for THIS
            // release (fresh subdomain claims included); the keystore's cached
            // site_url is only a fallback. Resolving from the stale keystore alone
            // produces silent null-origin check failures.
            const publicOrigin = deploy.urls?.site ?? deploy.urls?.subdomain ?? projectKeys.site_url ?? null;
            const verification = await this.#verifyHttpChecks(verifyChecks, publicOrigin, run, {
                projectId: resolved.projectId,
                projectName: input.name ?? manifest.appSpec?.project.name ?? null,
                claimedHosts: claimedHostsFromRelease(releaseSpec, publicOrigin),
                bindings: deploy.subdomain_bindings ?? [],
                propagationBudgetMs: propagationBudgetMs(input),
                propagationWait: input.propagationWait !== false,
                resolve: (opts) => scoped.apply.resolve(opts),
            });
            verifyBlock = {
                verification: { http: buildVerificationHttpEntries(verifyChecks, verification) },
                verify: appVerifyResult(verification),
            };
        }
        return run.result({
            project_id: resolved.projectId,
            manifest_path: manifest.manifestPath,
            ...(resolved.shouldWriteLink ? { workspace_link_path: resolved.linkPath } : {}),
            ...(identity ? { identity } : {}),
            deploy,
            ...verifyBlock,
        });
    }
    /**
     * principal-display-name (first-deploy-agent-dx): make sure the deploying
     * principal has a display name before the deploy that will be credited to
     * it, then join the project's room under that name. Explicit
     * `identityName` / `RUN402_AGENT_NAME` wins and may rename an already-named
     * agent principal; human and unknown principals are preserved. Otherwise a detected client name is set only when no name
     * exists yet, and reported as `detected`. The detected client is always
     * reported (`detected` + `detection`), even when it was not applied, so an
     * agent whose principal is already named sees why. Best-effort end to
     * end: a whoami, PATCH, or room hiccup never fails the deploy — it reports
     * `source: "unavailable"`.
     */
    async #ensureIdentity(input, projectId, run) {
        if (this.#targetKind() === "core") {
            return { display_name: null, source: "unavailable" };
        }
        const explicitName = input.identityName?.trim() || declaredAgentName() || null;
        const detectedName = detectClientName();
        let current = null;
        let principalId = null;
        let principalType = null;
        try {
            const me = await this.sdk.orgs.whoami();
            principalId = me.principal?.id ?? null;
            principalType = me.principal?.type ?? null;
            // The gateway never seeds display_name from a wallet subject (and
            // nulled the ones it once did), so null here means exactly "unset".
            current = me.principal?.display_name ?? null;
        }
        catch {
            return { display_name: null, source: "unavailable", detected: detectedName,
                principal: { id: null, type: null, display_name: null },
                client: { detected: detectedName, declared_name: explicitName } };
        }
        let displayName = current;
        let source = "existing";
        // Order: explicit input, then the runtime's own declaration
        // (RUN402_AGENT_NAME), then a specifically detected client. A generic
        // guess is never persisted as a name. Detection always runs so the
        // result can say what was seen even when it was not applied.
        if (principalType !== "agent") {
            run.skipStep({ action: "identity.name.set", description: "Preserve authenticated human or unknown principal identity; client detection does not rename it.", mutation: false, auto: true, details: { principal_type: principalType } });
            return { display_name: current, source: "existing",
                principal: { id: principalId, type: principalType, display_name: current },
                client: { detected: detectedName, declared_name: explicitName },
                detected: detectedName, detection: { applied: false, reason: "principal_identity_preserved" } };
        }
        const detectionReport = (applied) => {
            const reason = applied
                ? "applied"
                : !detectedName
                    ? "nothing_detected"
                    : explicitName
                        ? "explicit_name_wins"
                        : "name_already_set";
            return { detected: detectedName, detection: { applied, reason },
                principal: { id: principalId, type: principalType, display_name: displayName },
                client: { detected: detectedName, declared_name: explicitName } };
        };
        if (!displayName || (explicitName && explicitName !== displayName)) {
            const desired = explicitName ?? detectedName;
            if (!desired) {
                let presence = null;
                try {
                    const room = await this.sdk.rooms.forProject(projectId);
                    const registered = await this.sdk.rooms.registerPresence(room.orgId, room.roomKey, { requestedName: "agent", task: "run402 up" });
                    presence = { presence_id: registered.presence_id, name: registered.name };
                }
                catch {
                    presence = null;
                }
                run.skipStep({
                    action: "identity.name.set",
                    description: "No display name is known for this principal; nothing was written (set RUN402_AGENT_NAME=<name>, RUN402_CLIENT=<client>, or run `run402 whoami --set-name <name>`)",
                    mutation: false,
                    auto: true,
                    details: { source: "undetected", detected: null },
                });
                return { display_name: null, source: "undetected", ...detectionReport(false), presence };
            }
            source = explicitName ? "explicit" : "detected";
            const step = run.addStep({
                action: "identity.name.set",
                description: `Set this principal's display name to ${desired}`,
                mutation: true,
                auto: true,
                details: { display_name: desired, source, detected: detectedName },
            });
            await run.approve(step, ["identity.name.set"], `Name this principal "${desired}" (promotion credit and room presence use it; change any time with run402 whoami --set-name).`);
            run.setState(step, "running");
            try {
                const updated = await this.sdk.orgs.setDisplayName(desired);
                displayName = updated.principal?.display_name ?? desired;
                run.setState(step, "succeeded", { display_name: displayName });
            }
            catch (err) {
                run.setState(step, "failed", { error: err instanceof Error ? err.message : String(err) });
                return { display_name: current, source: "unavailable", ...detectionReport(false) };
            }
        }
        else if (detectedName && detectedName !== displayName) {
            // A client was detected but the principal already carries a name: say
            // so, write nothing (an existing name is never overwritten by a guess).
            run.skipStep({
                action: "identity.name.set",
                description: `Detected client "${detectedName}" but the principal is already named "${displayName}"; nothing written — set RUN402_AGENT_NAME=<name> or run \`run402 whoami --set-name <name>\` to rename`,
                mutation: false,
                auto: true,
                details: { source: "existing", display_name: displayName, detected: detectedName, reason: "name_already_set" },
            });
        }
        let presence = null;
        try {
            const room = await this.sdk.rooms.forProject(projectId);
            const registered = await this.sdk.rooms.registerPresence(room.orgId, room.roomKey, {
                requestedName: displayName ?? undefined,
                task: "run402 up",
            });
            presence = { presence_id: registered.presence_id, name: registered.name };
        }
        catch {
            presence = null;
        }
        return { display_name: displayName, source, ...detectionReport(source === "detected"), presence };
    }
    async #verifyDeployManifestOnly(input, manifest, workspaceDir, run) {
        const checks = manifest.verify?.http ?? [];
        if (checks.length === 0) {
            throw run.error("Deploy manifest does not define verify.http checks.", "VERIFY_CHECKS_REQUIRED", { manifest_path: manifest.manifestPath });
        }
        const resolved = await this.#resolveProjectForVerify(input, manifest, workspaceDir, run);
        const projectKeys = await this.sdk.projects.keys(resolved.projectId);
        // Keystore site_url first (no network), else the server-authoritative
        // project record — a project provisioned elsewhere has keys without a
        // cached site_url, and path checks need a real origin.
        let publicOrigin = projectKeys.site_url ?? null;
        if (publicOrigin === null) {
            try {
                const detail = await this.sdk.projects.get(resolved.projectId);
                publicOrigin = detail.site_url ?? null;
            }
            catch {
                // Fall through with null; the checker reports a per-check
                // missing-origin diagnostic instead of failing silently.
            }
        }
        const scoped = this.sdk.project(resolved.projectId);
        const verification = await this.#verifyHttpChecks(checks, publicOrigin, run, {
            projectId: resolved.projectId,
            projectName: input.name ?? null,
            claimedHosts: new Set(),
            bindings: [],
            propagationBudgetMs: propagationBudgetMs(input),
            propagationWait: input.propagationWait !== false,
            resolve: (opts) => scoped.apply.resolve(opts),
        });
        return run.result({
            project_id: resolved.projectId,
            manifest_path: manifest.manifestPath,
            verification: { http: buildVerificationHttpEntries(checks, verification) },
            verify: appVerifyResult(verification),
        });
    }
    async #discoverAndValidateManifest(input, workspaceDir, source, run) {
        const step = run.addStep({
            action: "deploy.discover",
            description: "Discover and validate deploy manifest",
            mutation: false,
            auto: true,
            details: { dir: workspaceDir, manifest: input.manifest ?? null },
        });
        run.setState(step, "running");
        const manifestPath = input.manifest
            ? resolveMaybe(workspaceDir, input.manifest)
            : await findManifest(workspaceDir);
        if (!manifestPath) {
            const executablePath = input.manifest ? null : await findExecutableManifest(workspaceDir);
            if (executablePath) {
                throw run.error("Executable deploy configs are trusted code and must be passed explicitly with --manifest.", "EXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFEST", {
                    dir: workspaceDir,
                    manifest_path: executablePath,
                    next_actions: [{
                            type: "retry",
                            command: `run402 up --manifest ${shellArg(shortPath(executablePath))} --check`,
                            argv: ["run402", "up", "--manifest", executablePath, "--check"],
                        }],
                });
            }
            // Cold-start chain (change: keep-agent-in-loop-on-cold-start): an agent
            // that knows only `run402 up` must be walked, failure by failure, to a
            // deployed result via typed next_actions. This is the FIRST chokepoint a
            // cold builder hits — an empty directory running the documented one-liner
            // — and it was the one hop with no next_actions, while its sibling
            // EXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFEST (above) had them. Naming
            // the missing filenames is not an executable step; the agent has no
            // scaffold verb to reach for, so hand it bytes it can write.
            const starterManifest = {
                site: {
                    replace: {
                        "index.html": { data: "<!doctype html><h1>Hello from Run402</h1>" },
                    },
                },
            };
            // `up` never walks into subdirectories, but an agent standing in a
            // parent workspace (a monorepo root, a checkout that holds the app one
            // level down) usually is one `--dir` away from the manifest it meant.
            // Name what is one directory down so the hop is executable.
            const nearby = await findNearbyManifests(workspaceDir);
            const selectorArgs = [...(input.projectId ? ["--project", input.projectId] : []), ...(input.name ? ["--name", input.name] : [])];
            const candidateActions = await Promise.all(nearby.map(async (entry) => ({
                binding: await nearbyBinding(entry.path),
                cwd: workspaceDir,
                env: { RUN402_API_BASE: this.sdk.apiBase, ...(this.opts.profile ? { RUN402_WALLET: this.opts.profile } : {}) },
                type: "run_in_directory",
                command: ["run402", "up", "--check", "--dir", entry.relative_dir, ...selectorArgs].map(shellArg).join(" "),
                argv: ["run402", "up", "--check", "--dir", entry.relative_dir, ...selectorArgs],
                app_root: dirname(entry.path),
                manifest_path: entry.path,
                safe_to_auto_execute: true,
                path: entry.path,
                dir: entry.relative_dir,
                why: "A manifest exists one directory down; up does not walk into subdirectories.",
            })));
            const nearbyActions = nearby.length > 1 ? [{
                    type: "select_application", safe_to_auto_execute: false,
                    why: "Choose the intended application; directory order does not indicate a recommendation.",
                    candidates: candidateActions.map((entry) => ({ ...entry, recommended: false })),
                }] : candidateActions;
            throw run.error(nearby.length > 0
                ? `No deploy manifest found in ${workspaceDir}, but ${nearby.length === 1 ? "one exists" : `${nearby.length} exist`} one directory down (${nearby.map((entry) => entry.relative_dir).join(", ")}). Re-run with --dir <that directory>, or add run402.json, run402.deploy.json or app.json here.`
                : "No deploy manifest found. Add run402.json, run402.deploy.json or app.json, or pass --manifest for executable configs.", "UP_MANIFEST_REQUIRED", {
                dir: workspaceDir,
                candidates: MANIFEST_CANDIDATES,
                executable_candidates: EXECUTABLE_MANIFEST_CANDIDATES,
                nearby_manifests: nearby,
                next_actions: nearby.length > 1 ? nearbyActions : [
                    ...nearbyActions,
                    {
                        type: "create_manifest",
                        why: "Write a deploy manifest, then re-run. This minimal site manifest deploys as-is; add database/functions/routes slices later.",
                        path: "app.json",
                        content: JSON.stringify(starterManifest, null, 2),
                    },
                    {
                        type: "retry",
                        command: "run402 up -y",
                        argv: ["run402", "up", "-y"],
                        why: "Re-run once the manifest exists.",
                    },
                ],
            });
        }
        if (basename(manifestPath) === RUN402_APP_MANIFEST_FILENAME) {
            const candidate = await loadRun402JsonManifest(manifestPath);
            if (looksLikeRun402AppManifest(candidate)) {
                const loadedAppSpec = candidate;
                const appSpec = input.buildMode
                    ? {
                        ...loadedAppSpec,
                        build: {
                            ...(loadedAppSpec.build ?? {}),
                            mode: input.buildMode,
                        },
                    }
                    : loadedAppSpec;
                let appGraph;
                try {
                    appGraph = await compileRun402AppInstallGraph(appSpec, {
                        source,
                        ...(input.name ? { name: input.name } : {}),
                        ...(input.idempotencyKey ?? run.rootIdempotencyKey
                            ? { root_idempotency_key: input.idempotencyKey ?? run.rootIdempotencyKey }
                            : {}),
                    });
                }
                catch (err) {
                    if (err instanceof LocalError && err.code === "APP_SPEC_INVALID") {
                        const errorDetails = err.details && typeof err.details === "object" && !Array.isArray(err.details)
                            ? err.details
                            : {};
                        throw new LocalError(err.message, "validating Run402 app manifest", {
                            cause: err,
                            code: "APP_SPEC_INVALID",
                            details: { manifest_path: manifestPath, ...errorDetails },
                            next_actions: [{
                                    type: "edit_manifest",
                                    path: manifestPath,
                                    ...(typeof errorDetails.field_path === "string" ? { field_path: errorDetails.field_path } : {}),
                                    command: `run402 up --manifest ${shellArg(shortPath(manifestPath))} --check`,
                                    argv: ["run402", "up", "--manifest", manifestPath, "--check"],
                                    why: "Fix the named app-manifest field, then rerun local validation.",
                                }],
                        });
                    }
                    throw err;
                }
                // The release slice is normally normalized only after the build
                // ran. With no build commands declared nothing on disk changes
                // between now and then, so the slice's filesystem references can be
                // checked right here — in `--check` as much as before a real apply.
                // A declared build may produce the referenced files (there is no
                // output-dir field to narrow by), so the pre-build check is skipped
                // then and the post-build normalization performs it instead.
                const buildProducesFiles = (appSpec.build?.commands ?? []).length > 0;
                const checkedRelease = !buildProducesFiles ? await this.#assertAppReleaseFileReferences(appSpec, manifestPath, run) : undefined;
                run.setState(step, "succeeded", {
                    manifest_kind: "app",
                    manifest_path: manifestPath,
                    app_id: appSpec.app.id,
                    graph_digest: appGraph.graph_digest,
                    file_references: buildProducesFiles ? "deferred_to_post_build" : "checked",
                });
                return {
                    manifestKind: "app",
                    manifestProjectId: appSpec.project.id,
                    manifestPath,
                    releaseSpec: checkedRelease ?? null,
                    appGraph,
                    appSpec,
                    source,
                };
            }
        }
        const loaded = await this.#normalizeWithTypedFileErrors(() => loadDeployManifest(manifestPath, {
            defaultProject: "prj_up_preflight_placeholder",
            validateFiles: true,
        }), run);
        if (!hasDeployableContent(loaded.spec)) {
            throw run.error("Deploy manifest contains no deployable sections.", "MANIFEST_EMPTY", { manifest_path: manifestPath });
        }
        await this.#assertManifestFileReferences(loaded.spec, manifestPath, run);
        run.setState(step, "succeeded", {
            manifest_kind: "release",
            manifest_path: manifestPath,
            project_id: loaded.spec.project_id === "prj_up_preflight_placeholder" ? null : loaded.spec.project_id,
            idempotency_key: loaded.idempotencyKey ?? null,
        });
        return {
            manifestKind: "release",
            manifestPath,
            releaseSpec: loaded.spec,
            loaded,
            idempotencyKey: loaded.idempotencyKey,
            manifestProjectId: loaded.spec.project_id === "prj_up_preflight_placeholder"
                ? undefined
                : loaded.spec.project_id,
            ...(loaded.verify ? { verify: loaded.verify } : {}),
        };
    }
    async #resolveUpSource(input, run) {
        const baseDir = resolvePath(this.opts.cwd ?? process.cwd());
        const rawSource = input.source ?? input.dir ?? ".";
        const step = run.addStep({
            action: "app.source.resolve",
            description: "Resolve app source",
            mutation: false,
            auto: true,
            details: { source: rawSource },
        });
        run.setState(step, "running");
        if (isRepositoryUrl(rawSource)) {
            const root = await mkdtemp(join(tmpdir(), "run402-app-source-"));
            const checkout = join(root, "repo");
            try {
                await execFileAsync("git", ["clone", "--depth", "1", rawSource, checkout], {
                    maxBuffer: 1024 * 1024 * 8,
                });
                const commit = await gitCommit(checkout);
                run.setState(step, "succeeded", {
                    kind: "repo",
                    repo_url: rawSource,
                    commit: commit ?? null,
                    checkout,
                    source_available: true,
                    ...(await sourceGitState(checkout, commit)),
                });
                return {
                    workspaceDir: checkout,
                    metadata: {
                        kind: "repo",
                        repo_url: rawSource,
                        ...(commit ? { commit } : {}),
                    },
                    cleanupDir: root,
                };
            }
            catch (err) {
                await rm(root, { recursive: true, force: true });
                throw run.error(`Failed to resolve repository source ${rawSource}.`, "RUN402_SOURCE_RESOLVE_FAILED", { source: rawSource, error: err instanceof Error ? err.message : String(err) });
            }
        }
        const workspaceDir = resolveMaybe(baseDir, rawSource);
        const stat = await lstat(workspaceDir).catch(() => null);
        if (!stat || !stat.isDirectory()) {
            throw run.error("App source must be an existing directory or repository URL.", "RUN402_SOURCE_INVALID", { source: rawSource, resolved_path: workspaceDir });
        }
        const commit = await gitCommit(workspaceDir);
        run.setState(step, "succeeded", {
            kind: "local",
            path: workspaceDir,
            source_available: true,
            ...(await sourceGitState(workspaceDir, commit)),
            commit: commit ?? null,
        });
        return {
            workspaceDir,
            metadata: {
                kind: "local",
                path: workspaceDir,
                ...(commit ? { commit } : {}),
            },
        };
    }
    /**
     * `stat` every local filesystem reference of a normalized spec and fail
     * with the action-shaped `MANIFEST_FILE_MISSING` envelope (code, message
     * naming the first missing file, `details.missing[]`, one `create_file`
     * next action per file) before any plan or mutation.
     */
    async #assertManifestFileReferences(spec, manifestPath, run) {
        try {
            await assertLocalFileReferencesExist(spec, { manifestPath });
            if (process.env.RUN402_DEPLOY_SKIP_SCAN !== "1") {
                const scan = scanDeploymentSources(spec, dirname(manifestPath));
                if (scan.errors.length)
                    throw run.error("Source scan blocked this application. Fix the reported findings, or explicitly override with RUN402_DEPLOY_SKIP_SCAN=1.", "R402_AUTH_PREFLIGHT_FAILED", scan);
            }
        }
        catch (err) {
            throw this.#retypeManifestFileError(err, run);
        }
    }
    /**
     * Normalize an app release slice and re-shape a `MANIFEST_FILE_MISSING`
     * (a missing migration `sql_path`) into the action envelope; every other
     * normalization error passes through untouched.
     */
    async #normalizeWithTypedFileErrors(fn, run) {
        try {
            return await fn();
        }
        catch (err) {
            if (err instanceof LocalError && err.code === "MANIFEST_FILE_MISSING") {
                throw this.#retypeManifestFileError(err, run);
            }
            throw err;
        }
    }
    #retypeManifestFileError(err, run) {
        if (!(err instanceof LocalError) || err.code !== "MANIFEST_FILE_MISSING")
            return err;
        const details = err.details && typeof err.details === "object" && !Array.isArray(err.details)
            ? err.details
            : {};
        return run.error(err.message, "MANIFEST_FILE_MISSING", {
            ...details,
            next_actions: err.nextActions ?? [],
        });
    }
    /**
     * Pre-build filesystem check for an app manifest's release slice. Templates
     * are materialized against an empty environment (only path-bearing fields
     * matter here) and the slice is normalized exactly as the post-build path
     * would; a `MANIFEST_FILE_MISSING` surfaces, any other normalization error
     * is left for the real apply path to report with its full context.
     */
    async #assertAppReleaseFileReferences(appSpec, manifestPath, run) {
        if (!appSpec.release || typeof appSpec.release !== "object")
            return;
        const releaseInput = materializeTemplates(appSpec.release, {});
        let normalized;
        try {
            normalized = await normalizeDeployManifest(releaseInput, {
                project: "prj_up_preflight_placeholder",
                validateFiles: true,
                manifestPath,
                baseDir: dirname(manifestPath),
            });
        }
        catch (err) {
            if (err instanceof LocalError && err.code === "MANIFEST_FILE_MISSING") {
                throw this.#retypeManifestFileError(err, run);
            }
            return;
        }
        await this.#assertManifestFileReferences(normalized.spec, manifestPath, run);
        return normalized.spec;
    }
    #planAppUpResult(input, manifest, run, opts) {
        if (!manifest.appGraph) {
            throw run.error("Internal error: app manifest did not produce an install graph.", "RUN402_ACTION_INTERNAL");
        }
        return createRun402AppUpResult({
            graph: manifest.appGraph,
            manifest_path: manifest.manifestPath,
            status: opts.status,
            started_at: opts.startedAt,
            dry_run: opts.dryRun,
            project_id: opts.projectId,
            project_name: input.name ?? manifest.appSpec?.project.name ?? null,
            public_origin: appPublicOrigin(input, manifest.appSpec),
            approval_policy: {
                yes: run.approval === "yes",
                allow_prune: input.allowPrune === true,
                max_spend_usd: input.maxSpendUsd ?? null,
                build_mode: input.buildMode ?? manifest.appSpec?.build?.mode ?? null,
                shell_build_approved: input.allowShellBuild === true,
            },
            diagnostics: opts.diagnostics,
            next_actions: opts.nextActions,
            blocked_node_id: opts.blockedNodeId,
        });
    }
    #blockedAppUpResult(input, manifest, run, startedAt) {
        const block = this.#firstAppUpBlock(input, manifest, run);
        if (!block) {
            throw run.error("Internal error: app manifest was marked blocked without a blocking condition.", "RUN402_ACTION_INTERNAL");
        }
        return this.#planAppUpResult(input, manifest, run, {
            startedAt,
            status: "blocked",
            dryRun: false,
            projectId: input.projectId ?? null,
            diagnostics: block.diagnostics,
            nextActions: block.nextActions,
            blockedNodeId: block.nodeId,
        });
    }
    async #verifyAppManifestOnly(input, manifest, workspaceDir, run, startedAt) {
        if (!manifest.appSpec || !manifest.appGraph) {
            throw run.error("Internal error: app manifest did not produce an install graph.", "RUN402_ACTION_INTERNAL");
        }
        if ((manifest.appSpec.verify?.http ?? []).length === 0) {
            throw run.error("App manifest does not define verify.http checks.", "VERIFY_CHECKS_REQUIRED", { manifest_path: manifest.manifestPath });
        }
        const resolved = await this.#resolveProjectForVerify(input, manifest, workspaceDir, run);
        const projectKeys = await this.sdk.projects.keys(resolved.projectId);
        const publicOrigin = appPublicOrigin(input, manifest.appSpec) ?? projectKeys.site_url ?? null;
        const scoped = this.sdk.project(resolved.projectId);
        const verification = await this.#verifyHttpChecks(manifest.appSpec.verify?.http ?? [], publicOrigin, run, {
            projectId: resolved.projectId,
            projectName: input.name ?? manifest.appSpec.project.name ?? resolved.link?.name ?? null,
            claimedHosts: new Set(),
            bindings: [],
            propagationBudgetMs: propagationBudgetMs(input),
            propagationWait: input.propagationWait !== false,
            resolve: (opts) => scoped.apply.resolve(opts),
        });
        markAppGraphNodesForVerify(manifest.appGraph, verification);
        const appStatus = verification.ok
            ? "succeeded"
            : verification.propagationPending
                ? "propagation_pending"
                : "deployed_unverified";
        const appResult = createRun402AppUpResult({
            graph: manifest.appGraph,
            manifest_path: manifest.manifestPath,
            status: appStatus,
            started_at: startedAt,
            dry_run: false,
            project_id: resolved.projectId,
            project_name: input.name ?? manifest.appSpec.project.name ?? resolved.link?.name ?? null,
            public_origin: publicOrigin,
            diagnostics: verification.diagnostics,
            next_actions: verification.nextAction ? [verification.nextAction] : [],
            verify: appVerifyResult(verification),
            approval_policy: {
                yes: run.approval === "yes",
                allow_prune: input.allowPrune === true,
                max_spend_usd: input.maxSpendUsd ?? null,
                build_mode: input.buildMode ?? manifest.appSpec.build?.mode ?? null,
                shell_build_approved: input.allowShellBuild === true,
            },
        });
        for (const check of appResult.verification.http) {
            const actual = verification.results.get(check.id);
            if (actual !== undefined) {
                check.actual_status = actual.status;
                if (actual.observedRelease)
                    check.observed_release = actual.observedRelease;
                check.propagation_wait_ms = actual.propagationWaitMs;
                if (actual.diagnostic)
                    check.diagnostic = actual.diagnostic;
                check.status = actual.ok
                    ? "succeeded"
                    : actual.propagationPending
                        ? "propagation_pending"
                        : "failed";
            }
        }
        return run.result({
            project_id: resolved.projectId,
            manifest_path: manifest.manifestPath,
            app_graph: manifest.appGraph,
            app_result: appResult,
        });
    }
    #firstAppUpBlock(input, manifest, run) {
        const nameBlock = missingRequiredName(input, manifest);
        if (nameBlock)
            return nameBlock;
        const missingSecrets = missingRequiredSecrets(manifest.appSpec);
        if (missingSecrets.length > 0) {
            return {
                nodeId: "secrets.ensure",
                diagnostics: missingSecrets.map((secret) => ({
                    code: "MISSING_SECRET",
                    severity: "error",
                    node_id: "secrets.ensure",
                    field_path: `secrets.${secret.name}`,
                    message: [
                        `Required user secret ${secret.name} is missing from approved env source ${secret.sourceEnv}.`,
                        secret.description ? `Usage: ${secret.description}` : "",
                    ].filter(Boolean).join(" "),
                    details: {
                        source_env: secret.sourceEnv,
                        ...(secret.description ? { usage: secret.description } : {}),
                    },
                })),
                nextActions: missingSecrets.map((secret) => ({
                    type: "set_user_secret",
                    code: "MISSING_SECRET",
                    node_id: "secrets.ensure",
                    field_path: `secrets.${secret.name}`,
                    message: [
                        `Provide ${secret.sourceEnv} in the environment before retrying.`,
                        secret.description ? `Usage: ${secret.description}` : "",
                    ].filter(Boolean).join(" "),
                    command: `${secret.sourceEnv}="<value>" run402 up --name <name> --yes`,
                    argv: ["run402", "secrets", "set", "<project_id>", secret.name, "--env", secret.sourceEnv],
                })),
            };
        }
        if (run.executionMode !== "check" && run.executionMode !== "printSpec" && run.executionMode !== "printManifest" && !run.dryRun) {
            const spendBlock = spendApprovalBlock(input, run);
            if (spendBlock)
                return spendBlock;
        }
        const buildBlock = appBuildBlock(input, manifest);
        if (buildBlock)
            return buildBlock;
        return null;
    }
    async #applyAppManifest(input, manifest, workspaceDir, run, startedAt) {
        if (!manifest.appSpec || !manifest.appGraph) {
            throw run.error("Internal error: app manifest did not produce an install graph.", "RUN402_ACTION_INTERNAL");
        }
        const block = this.#firstAppUpBlock(input, manifest, run);
        if (block) {
            const blocked = this.#planAppUpResult(input, manifest, run, {
                startedAt,
                status: "blocked",
                dryRun: false,
                projectId: input.projectId ?? null,
                diagnostics: block.diagnostics,
                nextActions: block.nextActions,
                blockedNodeId: block.nodeId,
            });
            return run.result({
                project_id: input.projectId ?? null,
                manifest_path: manifest.manifestPath,
                app_graph: manifest.appGraph,
                app_result: blocked,
            });
        }
        if (this.#targetKind() === "core") {
            const blocked = this.#planAppUpResult(input, manifest, run, {
                startedAt,
                status: "blocked",
                dryRun: false,
                projectId: input.projectId ?? null,
                diagnostics: [{
                        code: "UNSUPPORTED_RESOURCE_KIND",
                        severity: "error",
                        node_id: "project.ensure",
                        message: "App-aware up currently targets Run402 Cloud because it provisions Cloud projects, mailboxes, secrets, and managed subdomains.",
                    }],
                nextActions: [{
                        type: "choose_cloud_target",
                        code: "UNSUPPORTED_RESOURCE_KIND",
                        node_id: "project.ensure",
                        message: "Select the Run402 Cloud target, then retry.",
                        argv: ["run402", "init", "--api-base", "https://api.run402.com"],
                    }],
                blockedNodeId: "project.ensure",
            });
            return run.result({
                project_id: input.projectId ?? null,
                manifest_path: manifest.manifestPath,
                app_graph: manifest.appGraph,
                app_result: blocked,
            });
        }
        await this.#ensureCloudTier(run, input.tier ?? DEFAULT_BOOTSTRAP_TIER);
        const resolved = await this.#resolveProject(input, manifest, workspaceDir, run);
        await this.#recordAppInstallState({
            projectId: resolved.projectId,
            manifest,
            status: "applying",
            run,
        });
        if (resolved.shouldWriteLink && run.executionMode !== "plan") {
            await this.#writeWorkspaceProjectLink(resolved.linkPath, {
                schema_version: "run402.workspace-project.v1",
                project_id: resolved.projectId,
                ...(input.name ? { name: input.name } : {}),
                target: { kind: this.#targetKind(), api_base: this.sdk.apiBase },
                created_at: resolved.link?.created_at ?? new Date().toISOString(),
                updated_at: new Date().toISOString(),
            }, resolved.link, run);
        }
        try {
            const projectKeys = await this.sdk.projects.keys(resolved.projectId);
            const publicOrigin = appPublicOrigin(input, manifest.appSpec) ?? projectKeys.site_url ?? null;
            const resources = await this.#ensureAppMailboxes(resolved.projectId, manifest.appSpec, run);
            const env = {
                ...this.#generatedAppEnv(resolved.projectId, projectKeys, publicOrigin, resources),
                ...this.#userSecretEnv(manifest.appSpec),
            };
            resources.env = env;
            await this.#ensureAppSecrets(resolved.projectId, manifest.appSpec, env, run);
            await this.#runAppBuild(workspaceDir, manifest.appSpec, env, run);
            const releaseInput = materializeTemplates(manifest.appSpec.release, {
                ...env,
                "input.name": input.name ?? manifest.appSpec.project.name ?? "",
            });
            const normalized = await this.#normalizeWithTypedFileErrors(() => normalizeDeployManifest(releaseInput, {
                project: resolved.projectId,
                baseDir: dirname(manifest.manifestPath),
            }), run);
            if (!hasDeployableContent(normalized.spec)) {
                throw run.error("App release contains no deployable sections.", "MANIFEST_EMPTY", { manifest_path: manifest.manifestPath });
            }
            await this.#assertManifestFileReferences(normalized.spec, manifest.manifestPath, run);
            const deployStep = run.addStep({
                action: "deploy.apply",
                description: "Apply app release",
                mutation: true,
                auto: false,
                details: {
                    project_id: resolved.projectId,
                    manifest_path: manifest.manifestPath,
                },
            });
            run.setState(deployStep, "running");
            const scoped = this.sdk.project(resolved.projectId);
            const deploy = await scoped.apply(normalized.spec, {
                idempotencyKey: input.idempotencyKey ?? normalized.idempotencyKey,
                allowWarnings: input.allowWarnings,
                allowWarningCodes: input.allowWarningCodes,
                target: "cloud",
                onEvent: (event) => {
                    run.options.onEvent?.({
                        type: "action.step",
                        action: Run402Action.Up,
                        step: {
                            ...deployStep,
                            details: { ...deployStep.details, ...deployEventDetails(event) },
                        },
                    });
                },
            });
            run.setState(deployStep, "succeeded", {
                release_id: deploy.release_id,
                operation_id: deploy.operation_id,
            });
            const webhooks = await this.#ensureAppWebhooks(resolved.projectId, manifest.appSpec, resources, run);
            resources.webhooks = webhooks;
            const verifyContext = {
                projectId: resolved.projectId,
                projectName: input.name ?? manifest.appSpec.project.name ?? null,
                claimedHosts: claimedHostsFromRelease(normalized.spec, publicOrigin),
                bindings: deploy.subdomain_bindings ?? [],
                propagationBudgetMs: propagationBudgetMs(input),
                propagationWait: input.propagationWait !== false,
                resolve: (opts) => scoped.apply.resolve(opts),
            };
            const verification = await this.#verifyHttpChecks(manifest.appSpec.verify?.http ?? [], publicOrigin, run, verifyContext);
            markAppGraphNodesForVerify(manifest.appGraph, verification);
            const appStatus = verification.ok
                ? "succeeded"
                : verification.propagationPending
                    ? "propagation_pending"
                    : "deployed_unverified";
            const appResult = createRun402AppUpResult({
                graph: manifest.appGraph,
                manifest_path: manifest.manifestPath,
                status: appStatus,
                started_at: startedAt,
                dry_run: false,
                project_id: resolved.projectId,
                project_name: input.name ?? manifest.appSpec.project.name ?? null,
                public_origin: publicOrigin,
                operation_id: deploy.operation_id ?? null,
                diagnostics: verification.diagnostics,
                next_actions: verification.nextAction ? [verification.nextAction] : [],
                verify: appVerifyResult(verification),
                approval_policy: {
                    yes: run.approval === "yes",
                    allow_prune: input.allowPrune === true,
                    max_spend_usd: input.maxSpendUsd ?? null,
                    build_mode: input.buildMode ?? manifest.appSpec.build?.mode ?? null,
                    shell_build_approved: input.allowShellBuild === true,
                },
            });
            applyResourceStateToAppResult(appResult, resources);
            appResult.release.operation_id = deploy.operation_id ?? null;
            appResult.release.release_id = deploy.release_id ?? null;
            appResult.release.spec = normalized.manifest;
            for (const check of appResult.verification.http) {
                const actual = verification.results.get(check.id);
                if (actual !== undefined) {
                    check.actual_status = actual.status;
                    if (actual.observedRelease)
                        check.observed_release = actual.observedRelease;
                    check.propagation_wait_ms = actual.propagationWaitMs;
                    if (actual.diagnostic)
                        check.diagnostic = actual.diagnostic;
                    check.status = actual.ok
                        ? "succeeded"
                        : actual.propagationPending
                            ? "propagation_pending"
                            : "failed";
                }
            }
            await this.#recordAppInstallState({
                projectId: resolved.projectId,
                manifest,
                status: verification.ok ? "active" : "failed",
                run,
                resources,
                lastOperationId: deploy.operation_id ?? null,
                error: verification.ok ? null : { code: "VERIFY_FAILED", diagnostics: verification.diagnostics },
            });
            return run.result({
                project_id: resolved.projectId,
                manifest_path: manifest.manifestPath,
                ...(resolved.shouldWriteLink ? { workspace_link_path: resolved.linkPath } : {}),
                app_graph: manifest.appGraph,
                deploy,
                app_result: appResult,
            });
        }
        catch (err) {
            await this.#recordAppInstallState({
                projectId: resolved.projectId,
                manifest,
                status: "failed",
                run,
                error: actionErrorDetails(err),
            });
            throw err;
        }
    }
    async #recordAppInstallState(input) {
        if (!input.manifest.appSpec || !input.manifest.appGraph)
            return;
        const step = input.run.addStep({
            action: "app.install",
            description: input.status === "applying"
                ? "Record app install state"
                : "Record app install result",
            mutation: true,
            auto: true,
            details: {
                project_id: input.projectId,
                app_key: input.manifest.appSpec.app.id,
                status: input.status,
            },
        });
        input.run.setState(step, "running");
        try {
            const payload = {
                project_id: input.projectId,
                app_key: input.manifest.appSpec.app.id,
                status: input.status,
                manifest_digest: `sha256:${sha256Hex(stableJson(input.manifest.appSpec))}`,
                graph_digest: input.manifest.appGraph.graph_digest,
                source: input.manifest.source,
                manifest: input.manifest.appSpec,
                resources: input.resources
                    ? {
                        mailboxes: input.resources.mailboxes,
                        webhooks: input.resources.webhooks,
                    }
                    : {},
                bindings: input.resources
                    ? { env: Object.keys(input.resources.env).sort() }
                    : {},
                ...(input.lastOperationId ? { last_operation_id: input.lastOperationId } : {}),
                ...(input.error ? { error: input.error } : {}),
            };
            await this.sdk.apps.upsertInstallState(payload);
            input.run.setState(step, "succeeded");
        }
        catch (err) {
            input.run.setState(step, "skipped", {
                reason: "app_install_state_unavailable",
                error: err instanceof Error ? err.message : String(err),
            });
        }
    }
    async #ensureAppMailboxes(projectId, spec, run) {
        const state = { mailboxes: {}, webhooks: {}, env: {} };
        const entries = Object.entries(spec.resources?.mailboxes ?? {}).sort(([a], [b]) => a.localeCompare(b));
        if (entries.length === 0)
            return state;
        const step = run.addStep({
            action: "app.mailbox.ensure",
            description: "Ensure app mailboxes",
            mutation: true,
            auto: true,
            details: { project_id: projectId, count: entries.length },
        });
        await run.approve(step, ["app.mailbox.ensure"], "Create or reuse app mailboxes.");
        run.setState(step, "running");
        let envelope = await this.sdk.email.listMailboxes(projectId);
        for (const [logicalName, mailboxSpec] of entries) {
            const slug = appMailboxSlug(logicalName, mailboxSpec);
            let mailbox = envelope.mailboxes.find((candidate) => candidate.slug === slug);
            if (!mailbox) {
                mailbox = await this.sdk.email.createMailbox(projectId, slug);
                envelope = await this.sdk.email.listMailboxes(projectId);
            }
            state.mailboxes[logicalName] = {
                id: mailbox.mailbox_id,
                slug: mailbox.slug,
                address: mailbox.address,
                ...(mailbox.managed_address ? { managed_address: mailbox.managed_address } : {}),
            };
        }
        const defaults = {};
        for (const [logicalName, mailboxSpec] of entries) {
            const mailbox = state.mailboxes[logicalName];
            if (!mailbox)
                continue;
            if (mailboxSpec.roles?.includes("default_outbound"))
                defaults.default_outbound_mailbox_id = mailbox.id;
            if (mailboxSpec.roles?.includes("auth_sender"))
                defaults.auth_sender_mailbox_id = mailbox.id;
        }
        if ((defaults.default_outbound_mailbox_id && envelope.mailbox_settings?.default_outbound_mailbox_id !== defaults.default_outbound_mailbox_id) ||
            (defaults.auth_sender_mailbox_id && envelope.mailbox_settings?.auth_sender_mailbox_id !== defaults.auth_sender_mailbox_id)) {
            await this.sdk.email.setMailboxDefaults(projectId, defaults);
        }
        run.setState(step, "succeeded", {
            mailboxes: Object.fromEntries(Object.entries(state.mailboxes).map(([name, mailbox]) => [
                name,
                { mailbox_id: mailbox.id, slug: mailbox.slug, address: mailbox.address },
            ])),
        });
        return state;
    }
    #generatedAppEnv(projectId, projectKeys, publicOrigin, resources) {
        const env = {
            RUN402_PROJECT_ID: projectId,
            RUN402_ANON_KEY: projectKeys.anon_key,
            RUN402_SERVICE_KEY: projectKeys.service_key,
            RUN402_API_BASE: this.sdk.apiBase,
            RUN402_API_BASE_URL: this.sdk.apiBase,
        };
        if (publicOrigin)
            env.RUN402_PUBLIC_ORIGIN = publicOrigin;
        for (const [logicalName, mailbox] of Object.entries(resources.mailboxes)) {
            const suffix = logicalName.toUpperCase();
            env[`RUN402_MAILBOX_${suffix}_ID`] = mailbox.id;
            env[`RUN402_MAILBOX_${suffix}_ADDRESS`] = mailbox.address;
            if (mailbox.managed_address)
                env[`RUN402_MAILBOX_${suffix}_MANAGED_ADDRESS`] = mailbox.managed_address;
        }
        return env;
    }
    #userSecretEnv(spec) {
        const env = {};
        for (const [name, secret] of Object.entries(spec.secrets ?? {})) {
            const sourceEnv = secret.source_env ?? name;
            const value = process.env[sourceEnv];
            if (value !== undefined)
                env[name] = value;
        }
        return env;
    }
    async #ensureAppSecrets(projectId, spec, env, run) {
        const required = new Set();
        for (const [name, secret] of Object.entries(spec.secrets ?? {})) {
            if (secret.required === true || process.env[secret.source_env ?? name] !== undefined)
                required.add(name);
        }
        for (const name of releaseRequiredSecretNames(spec.release))
            required.add(name);
        const names = [...required].sort();
        if (names.length === 0)
            return;
        const step = run.addStep({
            action: "app.secret.ensure",
            description: "Ensure app runtime secrets",
            mutation: true,
            auto: true,
            details: { project_id: projectId, keys: names },
        });
        await run.approve(step, ["app.secret.ensure"], "Set generated and user-provided app runtime secrets.");
        run.setState(step, "running");
        for (const name of names) {
            const value = env[name];
            if (value === undefined) {
                throw run.error(`App secret ${name} is required by the release but no generated or user-provided value is available.`, "MISSING_SECRET", { key: name });
            }
            await this.sdk.secrets.set(projectId, name, { value });
        }
        run.setState(step, "succeeded", { keys: names });
    }
    async #runAppBuild(workspaceDir, spec, env, run) {
        const commands = spec.build?.commands ?? [];
        if (commands.length === 0)
            return;
        const mode = spec.build?.mode ?? "local";
        if (mode !== "local") {
            throw run.error(`${mode} build mode is not executable by this SDK build.`, "REMOTE_BUILD_UNSUPPORTED", { mode });
        }
        const step = run.addStep({
            action: "app.build",
            description: "Run app build commands",
            mutation: true,
            auto: false,
            details: { mode, commands: commands.map((command) => command.id) },
        });
        run.setState(step, "running");
        for (const [index, command] of commands.entries()) {
            const cwd = resolveMaybe(workspaceDir, command.cwd ?? ".");
            const startedAt = Date.now();
            run.setState(step, "running", {
                current_command_id: command.id,
                current_command_index: index + 1,
                command_count: commands.length,
                command_cwd: relative(workspaceDir, cwd) || ".",
            });
            if (command.argv && command.argv.length > 0) {
                try {
                    const [file, ...args] = command.argv;
                    await execFileAsync(file, args, {
                        cwd,
                        env: { ...process.env, ...env },
                        maxBuffer: 1024 * 1024 * 16,
                    });
                }
                catch (err) {
                    run.setState(step, "failed", {
                        failed_command_id: command.id,
                        command_duration_ms: Date.now() - startedAt,
                        error: err instanceof Error ? err.message : String(err),
                    });
                    throw err;
                }
            }
            else if (command.shell) {
                try {
                    await execFileAsync("/bin/sh", ["-c", command.shell], {
                        cwd,
                        env: { ...process.env, ...env },
                        maxBuffer: 1024 * 1024 * 16,
                    });
                }
                catch (err) {
                    run.setState(step, "failed", {
                        failed_command_id: command.id,
                        command_duration_ms: Date.now() - startedAt,
                        error: err instanceof Error ? err.message : String(err),
                    });
                    throw err;
                }
            }
            else {
                throw run.error(`Build command ${command.id} must define argv or shell.`, "APP_SPEC_INVALID", { command_id: command.id });
            }
            run.setState(step, "running", {
                last_command_id: command.id,
                last_command_duration_ms: Date.now() - startedAt,
            });
        }
        run.setState(step, "succeeded");
    }
    async #ensureAppWebhooks(projectId, spec, resources, run) {
        const entries = Object.entries(spec.resources?.webhooks ?? {}).sort(([a], [b]) => a.localeCompare(b));
        const webhooks = {};
        if (entries.length === 0)
            return webhooks;
        const step = run.addStep({
            action: "app.webhook.ensure",
            description: "Ensure app mailbox webhooks",
            mutation: true,
            auto: true,
            details: { project_id: projectId, count: entries.length },
        });
        await run.approve(step, ["app.webhook.ensure"], "Create or reuse app mailbox webhooks.");
        run.setState(step, "running");
        for (const [logicalName, webhook] of entries) {
            const mailbox = resources.mailboxes[webhook.mailbox];
            if (!mailbox) {
                throw run.error(`Webhook ${logicalName} references unknown mailbox ${webhook.mailbox}.`, "APP_SPEC_INVALID", { webhook: logicalName, mailbox: webhook.mailbox });
            }
            const url = materializeTemplates(webhook.url, resources.env);
            const events = [...webhook.events].sort();
            const current = await this.sdk.email.webhooks.list(projectId, { mailbox: mailbox.id });
            const existing = current.webhooks.find((candidate) => candidate.url === url && sameStringSet(candidate.events ?? [], events));
            const summary = existing ?? await this.sdk.email.webhooks.register(projectId, {
                mailbox: mailbox.id,
                url,
                events,
            });
            webhooks[logicalName] = {
                id: summary.webhook_id,
                mailbox: webhook.mailbox,
                url,
                events,
                created: existing === undefined,
            };
        }
        run.setState(step, "succeeded", { webhooks });
        return webhooks;
    }
    async #verifyHttpChecks(checks, publicOrigin, run, context) {
        const results = new Map();
        const diagnostics = [];
        const warnings = [];
        let nextAction = null;
        let propagationWaitMs = 0;
        if (checks.length === 0) {
            return { ok: true, propagationPending: false, results, diagnostics, warnings, nextAction, propagationWaitMs };
        }
        const step = run.addStep({
            action: "app.verify",
            description: "Verify app HTTP checks",
            mutation: false,
            auto: true,
            details: {
                count: checks.length,
                propagation_budget_s: Math.floor(context.propagationBudgetMs / 1000),
                propagation_wait: context.propagationWait,
            },
        });
        run.setState(step, "running");
        const verifyStarted = Date.now();
        for (const check of checks) {
            const url = check.url ?? (publicOrigin && check.path ? new URL(check.path, publicOrigin).toString() : null);
            if (!url) {
                const message = `HTTP verification ${check.id} needs either url or project public origin + path.`;
                results.set(check.id, {
                    id: check.id,
                    ok: false,
                    propagationPending: false,
                    status: null,
                    propagationWaitMs: 0,
                    // Surface WHY on the per-check entry itself — an unexplained
                    // `actual_status: null` reads as a mystery network failure.
                    diagnostic: {
                        error: "missing_public_origin",
                        message,
                        hint: "Path checks resolve against the project's public origin. Use an absolute `url` in the check, or deploy/add a subdomain so the project has a site URL.",
                    },
                });
                diagnostics.push({
                    code: "VERIFY_FAILED",
                    severity: "error",
                    node_id: `verify.http.${check.id}`,
                    message,
                });
                nextAction ??= addSubdomainNextAction(context.projectName ?? null, check.id);
                continue;
            }
            const retries = Math.max(1, check.retries ?? 1);
            let status = null;
            let attempt = 0;
            let propagationAttempts = 0;
            let checkPropagationWaitMs = 0;
            let pendingDiagnosis = null;
            while (true) {
                const observed = await fetchAppVerifyUrl(url);
                status = observed.status;
                const classification = await classifyAppVerifyAttempt({
                    attempt: observed,
                    check,
                    url,
                    context,
                });
                if (classification.kind === "success") {
                    results.set(check.id, {
                        id: check.id,
                        ok: true,
                        propagationPending: false,
                        status,
                        propagationWaitMs: checkPropagationWaitMs,
                        observedRelease: observed.observedRelease,
                    });
                    break;
                }
                if (classification.kind === "propagating") {
                    propagationAttempts += 1;
                    pendingDiagnosis = classification.diagnosis ?? null;
                    const elapsed = Date.now() - verifyStarted;
                    const remaining = Math.max(0, context.propagationBudgetMs - elapsed);
                    run.setState(step, "running", {
                        verify_event: "propagating",
                        check_id: check.id,
                        url,
                        reason: classification.reason ?? "edge_propagation",
                        binding_age_s: classification.bindingAgeSeconds ?? null,
                        typical_s: EDGE_PROPAGATION_TYPICAL_SECONDS,
                        budget_remaining_s: Math.ceil(remaining / 1000),
                        propagation_wait_ms: propagationWaitMs,
                        edge_propagation: classification.edge ?? pendingDiagnosis?.edge_propagation ?? null,
                    });
                    if (!context.propagationWait || remaining <= 0) {
                        const warning = propagationWarning(check.id, url, classification, remaining);
                        warnings.push(warning);
                        diagnostics.push(warning);
                        nextAction ??= verifyNextAction();
                        results.set(check.id, {
                            id: check.id,
                            ok: false,
                            propagationPending: true,
                            status,
                            propagationWaitMs: checkPropagationWaitMs,
                            observedRelease: observed.observedRelease,
                            ...(pendingDiagnosis ? { diagnostic: { edge_propagation: pendingDiagnosis.edge_propagation ?? null, resolve: pendingDiagnosis } } : {}),
                        });
                        break;
                    }
                    const sleepMs = Math.min(remaining, propagationBackoffMs(propagationAttempts));
                    await delay(sleepMs);
                    propagationWaitMs += sleepMs;
                    checkPropagationWaitMs += sleepMs;
                    continue;
                }
                attempt += 1;
                if (attempt < retries) {
                    await delay(1_000);
                    continue;
                }
                const diagnosis = classification.diagnosis ?? await diagnoseAppVerifyFailure(context, url, check);
                diagnostics.push({
                    code: "VERIFY_FAILED",
                    severity: "error",
                    node_id: `verify.http.${check.id}`,
                    message: `HTTP verification ${check.id} expected ${check.expect.status}, got ${status ?? "network_error"}.`,
                    details: {
                        url,
                        expected_status: check.expect.status,
                        actual_status: status,
                        ...(observed.error ? { error: observed.error } : {}),
                        ...(diagnosis ? { resolve: diagnosis, edge_propagation: diagnosis.edge_propagation ?? null } : {}),
                    },
                });
                results.set(check.id, {
                    id: check.id,
                    ok: false,
                    propagationPending: false,
                    status,
                    propagationWaitMs: checkPropagationWaitMs,
                    observedRelease: observed.observedRelease,
                    // A network-level fetch failure (status null) must carry its error
                    // on the entry, not only in the diagnostics array.
                    ...(diagnosis || observed.error
                        ? {
                            diagnostic: {
                                ...(observed.error ? { error: observed.error } : {}),
                                ...(diagnosis
                                    ? { edge_propagation: diagnosis.edge_propagation ?? null, resolve: diagnosis }
                                    : {}),
                            },
                        }
                        : {}),
                });
                break;
            }
        }
        const propagationPending = [...results.values()].some((result) => result.propagationPending);
        const failed = [...results.values()].some((result) => !result.ok && !result.propagationPending);
        run.setState(step, failed ? "failed" : propagationPending ? "propagation_pending" : "succeeded", {
            checks: Object.fromEntries([...results].map(([id, result]) => [id, result.status])),
            propagation_wait_ms: propagationWaitMs,
        });
        return {
            ok: !failed && !propagationPending,
            propagationPending,
            results,
            diagnostics,
            warnings,
            nextAction,
            propagationWaitMs,
        };
    }
    async #ensureWallet(run, opts = { fund: false }) {
        const status = await this.sdk.wallets.status();
        if (status.configured) {
            run.skipStep({
                action: "wallets.create",
                description: "Local wallet already exists",
                mutation: false,
                auto: true,
                details: { address: status.address, path: status.path ?? null },
            });
            if (!opts.fund)
                return false;
            const balance = await prototypeBalance(status.address);
            if (balance > 0n) {
                run.skipStep({
                    action: "wallets.faucet",
                    description: "Wallet already has Base Sepolia USDC",
                    mutation: false,
                    auto: true,
                    details: { address: status.address, balance_usd_micros: balance.toString() },
                });
                return false;
            }
        }
        else {
            const createStep = run.addStep({
                action: "wallets.create",
                description: "Create local wallet",
                mutation: true,
                auto: true,
            });
            await run.approve(createStep, ["wallets.create"], "Create a local wallet.");
            if (run.dryRun) {
                run.setState(createStep, "planned");
                return false;
            }
            run.setState(createStep, "running");
            const created = await this.sdk.wallets.create();
            run.setState(createStep, "succeeded", {
                address: created.address,
                path: created.path ?? null,
            });
            if (!opts.fund)
                return false;
        }
        const faucetStep = run.addStep({
            action: "wallets.faucet",
            description: "Request testnet faucet funds for wallet",
            mutation: true,
            auto: true,
            details: { idempotency_key: run.childKey("wallets.faucet") },
        });
        await run.approve(faucetStep, ["wallets.faucet"], "Request testnet USDC for the local wallet.");
        if (run.dryRun) {
            run.setState(faucetStep, "planned");
            return false;
        }
        run.setState(faucetStep, "running");
        const faucet = await this.sdk.wallets.faucet({
            idempotencyKey: run.childKey("wallets.faucet"),
        });
        run.setState(faucetStep, "succeeded", {
            transaction_hash: faucet.transactionHash,
            amount: faucet.amount,
            network: faucet.network,
        });
        return true;
    }
    async #ensureCloudTier(run, desiredTier) {
        await this.#ensureWallet(run, { fund: false });
        if (run.dryRun) {
            const faucetStep = run.addStep({
                action: "wallets.faucet",
                description: "Request testnet faucet funds if tier payment is needed",
                mutation: true,
                auto: true,
            });
            await run.approve(faucetStep, ["wallets.faucet"], "Request testnet USDC if tier bootstrap needs payment.");
            run.setState(faucetStep, "planned");
            const step = run.addStep({
                action: Run402Action.TierSet,
                description: `Ensure active ${desiredTier} tier`,
                mutation: true,
                auto: true,
                details: {
                    tier: desiredTier,
                    idempotency_key: run.childKey("tier.set"),
                    reason: "dry_run",
                },
            });
            await run.approve(step, ["tier.set"], `Set the ${desiredTier} tier if no active tier exists.`);
            run.setState(step, "planned");
            return;
        }
        let status;
        try {
            status = await this.sdk.tier.status();
        }
        catch {
            status = null;
        }
        const current = normalizeTier(status?.tier);
        if (status?.active && current && TIER_RANK[current] >= TIER_RANK[desiredTier]) {
            run.skipStep({
                action: Run402Action.TierSet,
                description: "Active tier already satisfies bootstrap requirement",
                mutation: false,
                auto: true,
                details: { current_tier: current, desired_tier: desiredTier },
            });
            return;
        }
        if (status?.active && current && TIER_RANK[current] > TIER_RANK[desiredTier]) {
            run.skipStep({
                action: Run402Action.TierSet,
                description: "Existing active tier is higher than bootstrap default",
                mutation: false,
                auto: true,
                details: { current_tier: current, desired_tier: desiredTier },
            });
            return;
        }
        const idempotencyKey = run.childKey("tier.set");
        const justFunded = await this.#ensureWallet(run, { fund: true });
        const step = run.addStep({
            action: Run402Action.TierSet,
            description: `Ensure active ${desiredTier} tier`,
            mutation: true,
            auto: true,
            details: {
                tier: desiredTier,
                current_tier: current ?? null,
                active: status?.active ?? null,
                idempotency_key: idempotencyKey,
            },
        });
        await run.approve(step, ["tier.set"], `Set the ${desiredTier} tier (start, renew, or upgrade).`);
        run.setState(step, "running");
        const pay = () => this.sdk.tier.set(desiredTier, { idempotencyKey });
        const result = await (justFunded ? afterFaucet(pay) : pay());
        run.setState(step, "succeeded", { tier: result.tier, action: result.action });
    }
    async #resolveProject(input, manifest, workspaceDir, run) {
        const linkPath = join(workspaceDir, ".run402", "project.json");
        const link = await readWorkspaceProjectLink(linkPath);
        const step = run.addStep({
            action: "project.resolve",
            description: "Resolve project for workspace",
            mutation: false,
            auto: true,
            details: {
                explicit_project_id: input.projectId ?? null,
                linked_project_id: link?.project_id ?? null,
                manifest_project_id: manifest.manifestProjectId ?? null,
            },
        });
        run.setState(step, "running");
        const linkConflict = workspaceLinkConflict(link, input, this.#targetKind(), linkPath);
        if (linkConflict) {
            throw run.error(linkConflict.message, "RUN402_WORKSPACE_LINK_CONFLICT", linkConflict.details);
        }
        let projectId = null;
        let source = null;
        let shouldWriteLink = false;
        if (input.projectId) {
            projectId = input.projectId;
            source = "explicit";
            shouldWriteLink = !link || link.project_id !== projectId;
        }
        else if (link?.project_id) {
            projectId = link.project_id;
            source = "workspace_link";
        }
        else if (manifest.manifestProjectId) {
            projectId = manifest.manifestProjectId;
            source = "manifest";
        }
        if (projectId && manifest.manifestProjectId && manifest.manifestProjectId !== projectId) {
            throw run.error(`Project conflict: resolved ${projectId} from ${source}, but manifest declares ${manifest.manifestProjectId}.`, "RUN402_PROJECT_CONFLICT", {
                resolved_project_id: projectId,
                resolved_source: source,
                manifest_project_id: manifest.manifestProjectId,
            });
        }
        if (projectId) {
            run.setState(step, "succeeded", { project_id: projectId, source, link_path: linkPath });
            return { projectId, source: source ?? "explicit", link, linkPath, shouldWriteLink };
        }
        if (input.name) {
            if (run.executionMode === "plan") {
                throw run.error("`up --plan` does not provision projects. Pass --project, add project_id/project to the manifest, or run `run402 up --name ...` first.", "RUN402_PROJECT_REQUIRED", { link_path: linkPath, manifest_path: manifest.manifestPath, mode: "plan" });
            }
            if (this.#targetKind() === "core") {
                throw run.error("Run402 Core cannot provision a Cloud project during `up`. Pass --project or add project_id to the manifest.", "RUN402_CORE_PROJECT_REQUIRED", { target: this.#targetKind() });
            }
            if (!run.dryRun) {
                const collision = await this.#findNameCollision(input.name, input.orgId);
                if (collision) {
                    throw run.error(`Project name "${input.name}" is already in use by ${collision.id}. Pass --project ${collision.id} to deploy to that project, or choose a different --name.`, "RUN402_PROJECT_NAME_COLLISION", {
                        name: input.name,
                        existing_project_id: collision.id,
                        existing_site_url: collision.site_url ?? null,
                        next_actions: [
                            {
                                type: "use_existing_project",
                                message: `Deploy to the existing project with --project ${collision.id}.`,
                                command: `run402 up --project ${collision.id} --yes`,
                            },
                            {
                                type: "choose_new_name",
                                message: "Choose a different app instance name.",
                                command: "run402 up --name <new-name> --yes",
                            },
                        ],
                    });
                }
            }
            const idempotencyKey = input.idempotencyKey ?? null;
            const provisionStep = run.addStep({
                action: Run402Action.ProjectsProvision,
                description: "Provision project for workspace",
                mutation: true,
                auto: true,
                details: {
                    name: input.name,
                    tier: input.tier ?? DEFAULT_BOOTSTRAP_TIER,
                    org_id: input.orgId ?? null,
                    idempotency_key: idempotencyKey,
                },
            });
            await run.approve(provisionStep, ["projects.provision"], `Provision project '${input.name}'.`);
            if (run.dryRun) {
                run.setState(provisionStep, "planned");
                run.setState(step, "planned", { source: "created", link_path: linkPath });
                return {
                    projectId: "prj_planned",
                    source: "created",
                    link,
                    linkPath,
                    shouldWriteLink: true,
                };
            }
            run.setState(provisionStep, "running");
            const provisioned = await this.sdk.projects.provision({
                name: input.name,
                tier: input.tier ?? DEFAULT_BOOTSTRAP_TIER,
                orgId: input.orgId,
                ...(idempotencyKey ? { idempotencyKey } : {}),
            });
            run.setState(provisionStep, "succeeded", { project_id: provisioned.project_id });
            run.setState(step, "succeeded", {
                project_id: provisioned.project_id,
                source: "created",
                link_path: linkPath,
            });
            return {
                projectId: provisioned.project_id,
                source: "created",
                link,
                linkPath,
                shouldWriteLink: true,
            };
        }
        throw projectSelectionRequired(workspaceDir, manifest.manifestPath);
    }
    async #resolveProjectForVerify(input, manifest, workspaceDir, run) {
        const linkPath = join(workspaceDir, ".run402", "project.json");
        const link = await readWorkspaceProjectLink(linkPath);
        const step = run.addStep({
            action: "project.resolve",
            description: "Resolve project for app verification",
            mutation: false,
            auto: true,
            details: {
                explicit_project_id: input.projectId ?? null,
                linked_project_id: link?.project_id ?? null,
                manifest_project_id: manifest.manifestProjectId ?? manifest.appSpec?.project.id ?? null,
                verify_only: true,
            },
        });
        run.setState(step, "running");
        const projectId = input.projectId ?? link?.project_id ?? manifest.manifestProjectId ?? manifest.appSpec?.project.id ?? await this.sdk.projects.active();
        if (!projectId) {
            throw run.error("`run402 up verify` needs an existing project. Pass --project, keep .run402/project.json, add project.id to run402.json, or select an active project.", "RUN402_PROJECT_REQUIRED", { link_path: linkPath, manifest_path: manifest.manifestPath, verify_only: true });
        }
        run.setState(step, "succeeded", {
            project_id: projectId,
            source: input.projectId
                ? "explicit"
                : link?.project_id
                    ? "workspace_link"
                    : manifest.manifestProjectId ?? manifest.appSpec?.project.id
                        ? "manifest"
                        : "active",
            link_path: linkPath,
        });
        return {
            projectId,
            source: input.projectId
                ? "explicit"
                : link?.project_id
                    ? "workspace_link"
                    : manifest.manifestProjectId ?? manifest.appSpec?.project.id
                        ? "manifest"
                        : "active",
            link,
            linkPath,
            shouldWriteLink: false,
        };
    }
    async #findNameCollision(name, orgId) {
        const result = await this.sdk.projects.list(orgId ? { org: orgId } : {});
        const normalized = name.trim().toLowerCase();
        return result.projects.find((project) => {
            const status = project.status ?? project.effective_status;
            if (status === "deleted" || status === "archived")
                return false;
            return project.name.trim().toLowerCase() === normalized;
        }) ?? null;
    }
    async #writeWorkspaceProjectLink(path, link, expectedExisting, run) {
        const step = run.addStep({
            action: "workspace.link.write",
            description: "Write workspace project link",
            mutation: true,
            auto: true,
            details: { path, project_id: run.dryRun && link.project_id === "prj_planned" ? null : link.project_id, name: link.name ?? null },
        });
        await run.approve(step, ["workspace.link.write"], `Write ${shortPath(path)}.`);
        if (run.dryRun) {
            run.setState(step, "planned");
            return;
        }
        run.setState(step, "running");
        await assertNotSymlinkPath(path);
        const current = await readWorkspaceProjectLink(path);
        if (!workspaceProjectLinksEqual(current, expectedExisting ?? null)) {
            throw run.error("Workspace project link changed while `up` was running; refusing to overwrite it.", "RUN402_WORKSPACE_LINK_CONFLICT", {
                path,
                expected_project_id: expectedExisting?.project_id ?? null,
                current_project_id: current?.project_id ?? null,
            });
        }
        await mkdir(dirname(path), { recursive: true });
        const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
        await writeFile(tmp, `${JSON.stringify(link, null, 2)}\n`, { mode: 0o600 });
        try {
            await rename(tmp, path);
        }
        catch (err) {
            await rm(tmp, { force: true });
            throw err;
        }
        run.setState(step, "succeeded", { path });
    }
    async #assertLocalProjectKeys(projectId, run) {
        try {
            return await this.sdk.projects.keys(projectId);
        }
        catch (err) {
            throw withActionDetails(new LocalError(`Project ${projectId} is selected, but its keys are not available locally. Use a project in the local keystore or provision/link from this machine.`, "resolving project keys for up", {
                cause: err,
                code: "RUN402_PROJECT_KEYS_REQUIRED",
                details: { project_id: projectId, steps: run.steps },
            }), run);
        }
    }
}
class ActionRun {
    input;
    options;
    target;
    steps = [];
    dryRun;
    executionMode;
    autoPrerequisites;
    approval;
    constructor(input, options, target) {
        this.input = input;
        this.options = options;
        this.target = target;
        this.executionMode = options.mode ?? (options.dryRun === true ? "legacyDryRun" : "apply");
        this.dryRun = options.dryRun === true;
        this.autoPrerequisites = options.autoPrerequisites ?? input.type === Run402Action.Up;
        this.approval = options.approval ?? "never";
    }
    get rootIdempotencyKey() {
        return this.input.idempotencyKey ?? this.options.idempotencyKey;
    }
    childKey(scope) {
        const root = this.rootIdempotencyKey ?? deriveStableRootKey(this.input);
        if (!root) {
            throw this.error(`Cannot derive an idempotency key for recursive mutation ${scope}.`, "RUN402_IDEMPOTENCY_REQUIRED", { scope, action: this.input.type });
        }
        return `action:${root}:${scope}`;
    }
    addStep(init) {
        const step = {
            id: `step_${String(this.steps.length + 1).padStart(2, "0")}`,
            action: init.action,
            description: init.description,
            state: "planned",
            mutation: init.mutation,
            auto: init.auto,
            ...(init.details ? { details: init.details } : {}),
        };
        this.steps.push(step);
        this.emit(step);
        return step;
    }
    skipStep(init) {
        const step = this.addStep({
            ...init,
            mutation: init.mutation ?? false,
        });
        this.setState(step, "skipped", init.details);
        return step;
    }
    setState(step, state, details) {
        step.state = state;
        if (details)
            step.details = { ...(step.details ?? {}), ...details };
        this.emit(step);
    }
    failLast(err) {
        const step = [...this.steps].reverse().find((candidate) => candidate.state === "running" || candidate.state === "planned");
        if (!step)
            return;
        this.setState(step, "failed", {
            error: err instanceof Error ? err.message : String(err),
        });
    }
    async approve(step, mutations, message) {
        if (!step.auto)
            return;
        if (this.dryRun)
            return;
        if (!step.mutation && mutations.length === 0) {
            if (this.approval === "never" && step.action === "project.resolve") {
                throw this.error("Using the active project for `up` requires approval. Pass --project, create a workspace link, or run with -y / an interactive prompt.", "RUN402_APPROVAL_REQUIRED", { step, mutations });
            }
            return;
        }
        if (this.approval === "yes")
            return;
        if (this.approval !== "never") {
            const approved = await this.approval.approve({
                action: this.input.type,
                step,
                message,
                mutations,
            });
            if (approved)
                return;
        }
        this.setState(step, "blocked", { approval_required: true, mutations });
        throw this.error(`${message} Approval is required before this action can continue.`, "RUN402_APPROVAL_REQUIRED", { step, mutations });
    }
    result(result) {
        return {
            action: this.input.type,
            mode: this.input.type === Run402Action.Up && this.input.verifyOnly ? "verify" : this.executionMode,
            ...(this.input.type === Run402Action.Up && this.input.verifyOnly ? { read_only: true } : {}),
            dry_run: this.dryRun || this.executionMode === "check" || (this.executionMode === "printSpec" || this.executionMode === "printManifest") || this.executionMode === "plan",
            target: this.target,
            steps: this.steps,
            result,
        };
    }
    error(message, code, details) {
        // `details.next_actions` stays where consumers already read it; hoist a
        // copy to the envelope's top-level `next_actions` so `err.nextActions` /
        // the CLI's `next_actions` field carry it too.
        const nextActions = Array.isArray(details?.next_actions) ? details.next_actions : undefined;
        return withActionDetails(new LocalError(message, "running Run402 action", {
            code,
            details: { action: this.input.type, ...(details ?? {}), steps: this.steps },
            ...(nextActions ? { next_actions: nextActions } : {}),
        }), this);
    }
    emit(step) {
        this.options.onEvent?.({
            type: "action.step",
            action: this.input.type,
            step: { ...step, details: step.details ? { ...step.details } : undefined },
        });
    }
}
function withActionDetails(err, run) {
    Object.defineProperty(err, "steps", {
        value: run.steps,
        enumerable: true,
        configurable: true,
    });
    return err;
}
function isRepositoryUrl(value) {
    return /^https?:\/\/.+/.test(value) || /^git@[^:]+:.+/.test(value) || /^ssh:\/\/.+/.test(value) || /^file:\/\/.+/.test(value);
}
async function sourceGitState(dir, commit) {
    if (commit)
        return { git_state: "has_commit" };
    try {
        await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"]);
        const { stdout } = await execFileAsync("git", ["-C", dir, "symbolic-ref", "--quiet", "HEAD"]);
        try {
            await execFileAsync("git", ["-C", dir, "show-ref", "--verify", "--quiet", stdout.trim()]);
        }
        catch (error) {
            if (error.code === 1) {
                return { git_state: "unborn", git_message: "Local source directory exists; this repository has no commits." };
            }
        }
    }
    catch (error) {
        if (/not a git repository/i.test(String(error.stderr ?? ""))) {
            return { git_state: "not_repository", git_message: "Local source directory exists outside a Git repository." };
        }
    }
    return { git_state: "unavailable", git_message: "Local source directory exists; Git commit information could not be read." };
}
async function gitCommit(dir) {
    try {
        const { stdout } = await execFileAsync("git", ["-C", dir, "rev-parse", "HEAD"], {
            maxBuffer: 1024 * 1024,
        });
        const commit = String(stdout).trim();
        return /^[0-9a-f]{40}$/i.test(commit) ? commit : undefined;
    }
    catch {
        return undefined;
    }
}
function appPublicOrigin(input, spec) {
    const template = spec?.project.origin?.subdomain;
    if (!template)
        return null;
    const name = input.name ?? spec?.project.name ?? null;
    const subdomain = template.replace(/\$\{input\.name\}/g, name ?? "");
    if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain))
        return null;
    return `https://${subdomain}.run402.com`;
}
function appMailboxSlug(logicalName, spec) {
    return spec.slug ?? logicalName.replace(/_/g, "-");
}
function releaseRequiredSecretNames(release) {
    const secrets = release.secrets;
    if (!secrets || typeof secrets !== "object" || Array.isArray(secrets))
        return [];
    const required = secrets.require;
    return Array.isArray(required)
        ? required.filter((value) => typeof value === "string")
        : [];
}
function sameStringSet(a, b) {
    if (a.length !== b.length)
        return false;
    const left = [...a].sort();
    const right = [...b].sort();
    return left.every((value, index) => value === right[index]);
}
function markAppGraphNodes(graph, status) {
    for (const node of graph.nodes) {
        node.status = status;
    }
}
function markAppGraphNodesForVerify(graph, verification) {
    const terminal = verification.ok || verification.propagationPending ? "succeeded" : "failed";
    markAppGraphNodes(graph, terminal);
    for (const node of graph.nodes) {
        if (node.kind !== "verify.http")
            continue;
        const id = node.id.replace(/^verify\.http\./, "");
        const result = verification.results.get(id);
        if (!result)
            continue;
        node.status = result.ok
            ? "succeeded"
            : result.propagationPending
                ? "propagation_pending"
                : "failed";
    }
}
function buildVerificationHttpEntries(checks, verification) {
    return checks.map((check) => {
        const actual = verification.results.get(check.id);
        const status = actual === undefined
            ? "planned"
            : actual.ok
                ? "succeeded"
                : actual.propagationPending
                    ? "propagation_pending"
                    : "failed";
        return {
            id: check.id,
            status,
            ...(check.path !== undefined ? { path: check.path } : {}),
            ...(check.url !== undefined ? { url: check.url } : {}),
            expected_status: check.expect.status,
            ...(actual !== undefined ? { actual_status: actual.status } : {}),
            ...(actual !== undefined ? { propagation_wait_ms: actual.propagationWaitMs } : {}),
            ...(actual?.observedRelease ? { observed_release: actual.observedRelease } : {}),
            ...(actual?.diagnostic ? { diagnostic: actual.diagnostic } : {}),
        };
    });
}
function appVerifyResult(verification) {
    return {
        status: verification.ok
            ? "verified"
            : verification.propagationPending
                ? "propagation_pending"
                : "failed",
        warnings: verification.warnings,
        next_action: verification.nextAction,
        propagation_wait_ms: verification.propagationWaitMs,
    };
}
function propagationBudgetMs(input) {
    const seconds = input.propagationBudgetSeconds ?? DEFAULT_PROPAGATION_BUDGET_SECONDS;
    if (!Number.isFinite(seconds) || seconds < 0)
        return DEFAULT_PROPAGATION_BUDGET_SECONDS * 1000;
    return Math.floor(seconds * 1000);
}
async function fetchAppVerifyUrl(url) {
    try {
        const res = await fetch(url, { method: "GET" });
        const text = await res.text().catch(() => "");
        return {
            status: res.status,
            observedRelease: observedRelease(res.headers, res.url || url),
            edgeCode: edgeCodeFromBody(text),
            edgeHeader: res.headers.get("x-run402-edge"),
            error: null,
        };
    }
    catch (err) {
        return {
            status: null,
            observedRelease: { ...observedRelease(new Headers(), url), unavailable_reason: "request_failed" },
            edgeCode: null,
            edgeHeader: null,
            error: err instanceof Error ? err.message : String(err),
        };
    }
}
function observedRelease(headers, url) {
    const releaseId = headers.get("x-run402-release-id")?.trim() || null;
    const rawGeneration = headers.get("x-run402-release-generation")?.trim() ?? "";
    const parsed = /^\d+$/.test(rawGeneration) ? Number(rawGeneration) : NaN;
    const generation = Number.isSafeInteger(parsed) ? parsed : null;
    return {
        release_id: releaseId,
        generation,
        source: "response_headers",
        url,
        observed_at: new Date().toISOString(),
        unavailable_reason: releaseId !== null && generation !== null ? null : "headers_missing_or_invalid",
    };
}
function edgeCodeFromBody(text) {
    if (!text || text.length > 4096)
        return null;
    try {
        const parsed = JSON.parse(text);
        const code = typeof parsed.code === "string"
            ? parsed.code
            : typeof parsed.error?.code === "string"
                ? parsed.error.code
                : null;
        return code && VERIFY_SENTINEL_CODES.has(code) ? code : null;
    }
    catch {
        return null;
    }
}
async function classifyAppVerifyAttempt(input) {
    if (input.attempt.status === input.check.expect.status) {
        return { kind: "success" };
    }
    if ((input.attempt.status === 400 || input.attempt.status === 429) && input.attempt.edgeCode === null && !input.attempt.edgeHeader) {
        return { kind: "failed" };
    }
    const host = hostnameFromUrl(input.url);
    const freshness = host ? freshnessForHost(input.context, host) : { fresh: false, ageSeconds: null };
    if (freshness.fresh &&
        (input.attempt.edgeCode !== null ||
            input.attempt.edgeHeader === "kvs-miss" ||
            input.attempt.edgeHeader === "kv-miss")) {
        return {
            kind: "propagating",
            reason: input.attempt.edgeCode ?? input.attempt.edgeHeader ?? "edge_miss",
            bindingAgeSeconds: freshness.ageSeconds,
        };
    }
    const diagnosis = await diagnoseAppVerifyFailure(input.context, input.url, input.check);
    const edge = diagnosis?.edge_propagation ?? null;
    if (edge && edge.status !== "settled") {
        return {
            kind: "propagating",
            reason: edge.status,
            bindingAgeSeconds: bindingAgeSeconds(edge.claimed_at),
            edge,
            diagnosis,
        };
    }
    return { kind: "failed", diagnosis };
}
async function diagnoseAppVerifyFailure(context, url, check) {
    if (!context.resolve)
        return null;
    try {
        return await context.resolve({
            project: context.projectId,
            url,
            method: "GET",
        });
    }
    catch {
        try {
            return await context.resolve({
                project: context.projectId,
                url,
                method: String(check.expect.status) === "405" ? "POST" : "GET",
            });
        }
        catch {
            return null;
        }
    }
}
function propagationWarning(checkId, url, classification, remainingMs) {
    const edge = classification.edge;
    const visibleBy = edge?.expected_visible_by ? ` Expected visible by ${edge.expected_visible_by}.` : "";
    const reason = classification.reason ?? edge?.status ?? "edge_propagation";
    return {
        code: "VERIFY_PROPAGATION_PENDING",
        severity: "warning",
        node_id: `verify.http.${checkId}`,
        message: `HTTP verification ${checkId} is waiting on edge propagation for ${url} (${reason}).${visibleBy}`,
        details: {
            url,
            reason,
            binding_age_s: classification.bindingAgeSeconds ?? null,
            typical_s: EDGE_PROPAGATION_TYPICAL_SECONDS,
            budget_remaining_s: Math.ceil(Math.max(0, remainingMs) / 1000),
            edge_propagation: edge ?? null,
        },
    };
}
/**
 * Slug a human project name into an addable `<name>.run402.com` label
 * (3-63 chars, lowercase alphanumeric + hyphens), or null when it cannot be.
 */
export function subdomainSlugFromProjectName(name) {
    if (typeof name !== "string")
        return null;
    const slug = name
        .toLowerCase()
        .replace(/[^a-z0-9-]+/g, "-")
        .replace(/-{2,}/g, "-")
        .replace(/^-+|-+$/g, "")
        .slice(0, 63)
        .replace(/-+$/g, "");
    return slug.length >= 3 ? slug : null;
}
/**
 * The `missing_public_origin` remedy: a path check cannot resolve without a
 * public origin, and the fix is to bind a subdomain — either right now via
 * `run402 subdomains add <name>` (which binds the live release with no
 * further flags) or declaratively in the manifest's `subdomains.set`.
 */
export function addSubdomainNextAction(projectName, checkId) {
    const name = subdomainSlugFromProjectName(projectName) ?? "<name>";
    return {
        type: "add_subdomain",
        code: "VERIFY_FAILED",
        node_id: `verify.http.${checkId}`,
        message: `The project has no public origin, so path checks cannot resolve. Claim a subdomain with \`run402 subdomains add ${name}\` (binds the live release), or add \`"subdomains": { "set": ["${name}"] }\` to the deploy manifest and redeploy; then rerun \`run402 up verify\`.`,
        command: `run402 subdomains add ${name}`,
        argv: ["run402", "subdomains", "add", name],
    };
}
function verifyNextAction() {
    return {
        type: "retry_verify",
        code: "VERIFY_PROPAGATION_PENDING",
        message: "Rerun app verification after edge propagation settles.",
        command: "run402 up verify",
        argv: ["run402", "up", "verify"],
    };
}
function propagationBackoffMs(attempt) {
    const base = Math.min(10_000, 1_000 * 2 ** Math.min(4, attempt - 1));
    const jitter = Math.floor(Math.random() * 250);
    return base + jitter;
}
function hostnameFromUrl(url) {
    try {
        return new URL(url).hostname.toLowerCase();
    }
    catch {
        return null;
    }
}
function freshnessForHost(context, host) {
    const normalized = host.toLowerCase();
    const binding = context.bindings.find((candidate) => candidate.host.toLowerCase() === normalized);
    const ageSeconds = binding ? bindingAgeSeconds(binding.claimed_at) : null;
    if (context.claimedHosts.has(normalized))
        return { fresh: true, ageSeconds };
    if (ageSeconds === null)
        return { fresh: false, ageSeconds: null };
    return { fresh: ageSeconds * 1000 < EDGE_PROPAGATION_FRESH_WINDOW_MS, ageSeconds };
}
function bindingAgeSeconds(claimedAtIso) {
    if (!claimedAtIso)
        return null;
    const ms = Date.parse(claimedAtIso);
    if (!Number.isFinite(ms))
        return null;
    return Math.max(0, Math.floor((Date.now() - ms) / 1000));
}
function claimedHostsFromRelease(spec, publicOrigin) {
    const hosts = new Set();
    const subdomains = spec.subdomains;
    for (const value of [...arrayOfStrings(subdomains?.set), ...arrayOfStrings(subdomains?.add)]) {
        hosts.add(`${value}.run402.com`.toLowerCase());
    }
    if (publicOrigin) {
        const host = hostnameFromUrl(publicOrigin);
        if (host)
            hosts.add(host);
    }
    return hosts;
}
function arrayOfStrings(value) {
    return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
}
function applyResourceStateToAppResult(appResult, resources) {
    for (const [name, mailbox] of Object.entries(resources.mailboxes)) {
        if (!appResult.resources.mailboxes[name])
            continue;
        appResult.resources.mailboxes[name].id = mailbox.id;
        appResult.resources.mailboxes[name].address = mailbox.address;
    }
    for (const [name, webhook] of Object.entries(resources.webhooks)) {
        appResult.resources.webhooks[name] = {
            id: webhook.id,
            mailbox: webhook.mailbox,
            url: webhook.url,
            events: webhook.events,
            enabled: true,
        };
    }
    appResult.resources.bindings = appResult.resources.bindings.map((binding) => {
        const value = resources.env[binding.env];
        return value === undefined
            ? binding
            : { ...binding, value, redacted: /KEY|SECRET|TOKEN/.test(binding.env) };
    });
    appResult.resources.user_secrets = Object.fromEntries(Object.entries(appResult.resources.user_secrets).map(([name, secret]) => [
        name,
        { ...secret, satisfied: resources.env[name] !== undefined },
    ]));
}
function spendApprovalBlock(input, run) {
    const expectedPrototypeSpendUsd = 0.10;
    if (input.maxSpendUsd !== undefined && input.maxSpendUsd < expectedPrototypeSpendUsd) {
        return {
            nodeId: "account.ensure",
            diagnostics: [{
                    code: "SPEND_APPROVAL_REQUIRED",
                    severity: "error",
                    node_id: "account.ensure",
                    message: `The app install may need up to $${expectedPrototypeSpendUsd.toFixed(2)} for tier readiness, above --max-spend-usd ${input.maxSpendUsd}.`,
                }],
            nextActions: [{
                    type: "approve_spend",
                    code: "SPEND_APPROVAL_REQUIRED",
                    node_id: "account.ensure",
                    message: "Raise --max-spend-usd or preconfigure an active tier, then retry with approval.",
                    spend: {
                        amount_usd_micros: 100_000,
                        currency: "USD",
                        description: "prototype tier readiness",
                    },
                }],
        };
    }
    if (run.approval !== "yes") {
        return {
            nodeId: "account.ensure",
            diagnostics: [{
                    code: "SPEND_APPROVAL_REQUIRED",
                    severity: "error",
                    node_id: "account.ensure",
                    message: "App-aware up needs explicit approval before it can perform spend-impacting readiness steps.",
                }],
            nextActions: [{
                    type: "approve_spend",
                    code: "SPEND_APPROVAL_REQUIRED",
                    node_id: "account.ensure",
                    message: "Retry with --yes and an appropriate --max-spend-usd cap, or run --dry-run to inspect the plan.",
                    argv: ["run402", "up", "--yes", "--max-spend-usd", String(expectedPrototypeSpendUsd)],
                    spend: {
                        amount_usd_micros: 100_000,
                        currency: "USD",
                        description: "prototype tier readiness",
                    },
                }],
        };
    }
    return null;
}
function missingRequiredName(input, manifest) {
    const spec = manifest.appSpec;
    if (!spec || input.name || input.projectId || spec.project.id)
        return null;
    const needsName = spec.project.name?.includes("${input.name}") === true ||
        spec.project.origin?.subdomain?.includes("${input.name}") === true;
    if (!needsName)
        return null;
    return {
        nodeId: "project.ensure",
        diagnostics: [{
                code: "PROJECT_REQUIRED",
                severity: "error",
                node_id: "project.ensure",
                field_path: "project.name",
                message: "This app needs an instance name so Run402 can create the project, public subdomain, and project-scoped mail host.",
                details: {
                    manifest_path: manifest.manifestPath,
                    template: "${input.name}",
                },
            }],
        nextActions: [{
                type: "set_project_name",
                code: "PROJECT_REQUIRED",
                node_id: "project.ensure",
                field_path: "project.name",
                message: "Retry with --name, for example: run402 up --name <name> --yes.",
                command: "run402 up --name <name> --yes",
                argv: ["run402", "up", "--name", "<name>", "--yes"],
            }],
    };
}
function missingRequiredSecrets(spec) {
    const missing = [];
    for (const [name, secret] of Object.entries(spec?.secrets ?? {})) {
        if (secret.required !== true)
            continue;
        const sourceEnv = secret.source_env ?? name;
        if (process.env[sourceEnv])
            continue;
        missing.push({
            name,
            sourceEnv,
            ...(secret.description ? { description: secret.description } : {}),
        });
    }
    return missing;
}
function appBuildBlock(input, manifest) {
    const build = manifest.appSpec?.build;
    if (!build)
        return null;
    const mode = input.buildMode ?? build.mode ?? "local";
    const nodeId = `build.${mode}`;
    const shellCommand = build.commands?.find((command) => command.shell);
    if (shellCommand && input.allowShellBuild !== true) {
        return {
            nodeId,
            diagnostics: [{
                    code: "BUILD_APPROVAL_REQUIRED",
                    severity: "error",
                    node_id: nodeId,
                    field_path: `build.commands.${shellCommand.id}.shell`,
                    message: "Shell-string build commands require explicit shell build approval.",
                }],
            nextActions: [{
                    type: "approve_shell_build",
                    code: "BUILD_APPROVAL_REQUIRED",
                    node_id: nodeId,
                    field_path: `build.commands.${shellCommand.id}.shell`,
                    message: "Review the shell command, then retry with --allow-shell-build or convert it to argv.",
                }],
        };
    }
    if (mode === "remote" || mode === "sandbox") {
        return {
            nodeId,
            diagnostics: [{
                    code: "REMOTE_BUILD_UNSUPPORTED",
                    severity: "error",
                    node_id: nodeId,
                    message: `${mode} build mode is planned but not available in this SDK build yet.`,
                }],
            nextActions: [{
                    type: "choose_build_mode",
                    code: "REMOTE_BUILD_UNSUPPORTED",
                    node_id: nodeId,
                    message: "Retry with --build-mode local after preparing the local toolchain, or wait for Run402 remote build support.",
                    argv: ["run402", "up", "--build-mode", "local", "--yes"],
                }],
        };
    }
    return null;
}
async function findManifest(workspaceDir) {
    return (await resolveApplicationScope({ dir: workspaceDir })).manifest_path;
}
const NEARBY_MANIFEST_SCAN_LIMIT = 200;
/**
 * Manifests one directory down from `workspaceDir` (immediate children
 * only; `node_modules`, `.git` and every other dot-directory skipped; the
 * scan reads at most {@link NEARBY_MANIFEST_SCAN_LIMIT} entries). Surfaced in
 * `UP_MANIFEST_REQUIRED` so an agent standing in a parent directory learns
 * where `up` would have found a manifest instead of a bare ENOENT.
 */
async function findNearbyManifests(workspaceDir) {
    let entries;
    try {
        entries = await readdir(workspaceDir, { withFileTypes: true });
    }
    catch {
        return [];
    }
    const found = [];
    for (const entry of entries.slice(0, NEARBY_MANIFEST_SCAN_LIMIT)) {
        if (!entry.isDirectory())
            continue;
        if (entry.name === "node_modules" || entry.name.startsWith("."))
            continue;
        const manifestPath = await findManifest(join(workspaceDir, entry.name));
        if (manifestPath)
            found.push({ path: manifestPath, relative_dir: entry.name });
    }
    return found.sort((a, b) => a.relative_dir.localeCompare(b.relative_dir));
}
async function findExecutableManifest(workspaceDir) {
    for (const candidate of EXECUTABLE_MANIFEST_CANDIDATES) {
        const path = join(workspaceDir, candidate);
        try {
            await lstat(path);
            return path;
        }
        catch {
            // try next candidate
        }
    }
    return null;
}
async function loadRun402JsonManifest(path) {
    let raw;
    try {
        raw = await readFile(path, "utf-8");
    }
    catch (err) {
        if (err?.code === "ENOENT") {
            throw manifestNotFoundError(path, "loading Run402 manifest");
        }
        throw new LocalError(`Failed to read Run402 manifest '${path}': ${err.message}`, "loading Run402 manifest", err);
    }
    try {
        return JSON.parse(raw);
    }
    catch (err) {
        throw new LocalError(`Run402 manifest is not valid JSON: ${err.message}`, "parsing Run402 manifest", {
            code: "APP_SPEC_INVALID",
            details: { path },
        });
    }
}
function looksLikeRun402AppManifest(value) {
    if (!value || typeof value !== "object" || Array.isArray(value))
        return false;
    const candidate = value;
    return candidate.$schema === RUN402_APP_SCHEMA_ID ||
        "spec_version" in candidate ||
        "app" in candidate ||
        "release" in candidate ||
        "resources" in candidate ||
        "build" in candidate ||
        "lifecycle" in candidate;
}
function reviewedPlanRequirement(mode) {
    return typeof mode === "object" && mode.kind === "applyReviewed"
        ? { planId: mode.planId, ...(mode.planFingerprint ? { planFingerprint: mode.planFingerprint } : {}) }
        : undefined;
}
function withUpReviewedPlanNextAction(plan, manifestPath) {
    if (!plan.plan_id)
        return plan;
    const commandManifest = shortPath(manifestPath);
    const argv = ["run402", "up", "--manifest", manifestPath, "--require-plan", plan.plan_id];
    const commandParts = ["run402", "up", "--manifest", shellArg(commandManifest), "--require-plan", shellArg(plan.plan_id)];
    if (plan.plan_fingerprint) {
        argv.push("--plan-fingerprint", plan.plan_fingerprint);
        commandParts.push("--plan-fingerprint", shellArg(plan.plan_fingerprint));
    }
    return {
        ...plan,
        next_actions: [{
                type: "retry",
                command: commandParts.join(" "),
                argv,
                why: "Apply exactly this reviewed plan from the same repo surface before it expires.",
            }],
    };
}
function shellArg(value) {
    if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value))
        return value;
    return `'${value.replaceAll("'", "'\\''")}'`;
}
async function readWorkspaceProjectLink(path) {
    let raw;
    try {
        raw = await readFile(path, "utf-8");
    }
    catch (err) {
        if (err.code !== "ENOENT")
            throw err;
        return null;
    }
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch (err) {
        throw new LocalError(`Workspace project link is not valid JSON: ${err.message}`, "reading workspace project link", { code: "RUN402_WORKSPACE_LINK_INVALID", details: { path } });
    }
    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
        throw new LocalError("Workspace project link must be a JSON object.", "reading workspace project link", { code: "RUN402_WORKSPACE_LINK_INVALID", details: { path } });
    }
    const record = parsed;
    if (record.schema_version !== "run402.workspace-project.v1" ||
        typeof record.project_id !== "string" ||
        !record.project_id) {
        throw new LocalError("Workspace project link must contain schema_version=run402.workspace-project.v1 and project_id.", "reading workspace project link", { code: "RUN402_WORKSPACE_LINK_INVALID", details: { path } });
    }
    return record;
}
async function assertNotSymlinkPath(path) {
    const parts = path.split(/[\\/]+/);
    let cursor = isAbsolute(path) ? "/" : process.cwd();
    for (const part of parts) {
        if (!part || part === ".")
            continue;
        cursor = cursor === "/" ? `/${part}` : join(cursor, part);
        try {
            const stat = await lstat(cursor);
            if (stat.isSymbolicLink()) {
                throw new LocalError(`Refusing to write workspace project link through symlink: ${cursor}`, "writing workspace project link", { code: "RUN402_WORKSPACE_LINK_SYMLINK", details: { path, symlink: cursor } });
            }
        }
        catch (err) {
            if (err.code === "ENOENT") {
                try {
                    await lstat(dirname(cursor));
                }
                catch {
                    // parent also absent; mkdir will create it later
                }
            }
            else {
                throw err;
            }
        }
    }
    await mkdir(dirname(path), { recursive: true });
    try {
        await lstat(path);
    }
    catch (err) {
        if (err.code !== "ENOENT")
            throw err;
    }
}
function resolveMaybe(baseDir, path) {
    return isAbsolute(path) ? path : resolvePath(baseDir, path);
}
function shortPath(path) {
    const rel = relative(process.cwd(), path);
    return rel && !rel.startsWith("..") ? rel : path;
}
function normalizeTier(tier) {
    return tier === "prototype" || tier === "hobby" || tier === "team" ? tier : null;
}
function workspaceProjectLinksEqual(a, b) {
    if (a === null || b === null)
        return a === b;
    return (a.schema_version === b.schema_version &&
        a.project_id === b.project_id &&
        (a.name ?? null) === (b.name ?? null) &&
        (a.target?.kind ?? null) === (b.target?.kind ?? null) &&
        (a.target?.api_base ?? null) === (b.target?.api_base ?? null));
}
function workspaceLinkConflict(link, input, targetKind, linkPath) {
    if (!link)
        return null;
    const linkedTarget = link.target?.kind;
    if (linkedTarget && targetKind !== "unknown" && linkedTarget !== targetKind) {
        return {
            message: "Workspace project link was created for a different Run402 target.",
            details: {
                reason: "target_mismatch",
                linked_project_id: link.project_id,
                linked_target: linkedTarget,
                target: targetKind,
                link_path: linkPath,
            },
        };
    }
    if (!input.name)
        return null;
    if (!link.name) {
        return {
            message: "--name was supplied, but this workspace is already linked to a project without a recorded name.",
            details: {
                reason: "name_missing",
                linked_project_id: link.project_id,
                name: input.name,
                link_path: linkPath,
            },
        };
    }
    if (input.name !== link.name) {
        return {
            message: "--name conflicts with the existing workspace project link.",
            details: {
                reason: "name_mismatch",
                linked_project_id: link.project_id,
                linked_name: link.name,
                name: input.name,
                link_path: linkPath,
            },
        };
    }
    return null;
}
function deriveStableRootKey(input) {
    if (input.type === Run402Action.ProjectsProvision && input.name) {
        return `projects.provision:${input.name}`;
    }
    if (input.type === Run402Action.Up) {
        const stable = JSON.stringify({
            type: input.type,
            dir: input.dir ?? ".",
            manifest: input.manifest ?? null,
            projectId: input.projectId ?? null,
            name: input.name ?? null,
            tier: input.tier ?? DEFAULT_BOOTSTRAP_TIER,
            orgId: input.orgId ?? null,
        });
        return `up:${createHash("sha256").update(stable).digest("hex").slice(0, 24)}`;
    }
    return null;
}
function hasDeployableContent(spec) {
    const meaningful = ["database", "site", "functions", "secrets", "subdomains", "routes", "checks", "i18n", "assets"];
    return meaningful.some((key) => hasContent(spec[key]));
}
function hasContent(value) {
    if (value === null)
        return true;
    if (value === undefined)
        return false;
    if (Array.isArray(value))
        return value.length > 0;
    if (typeof value === "object") {
        return Object.values(value).some(hasContent);
    }
    if (typeof value === "string")
        return value.length > 0;
    return true;
}
function stableJson(value) {
    if (value === undefined)
        return "null";
    if (value === null || typeof value !== "object")
        return JSON.stringify(value) ?? "null";
    if (Array.isArray(value))
        return `[${value.map(stableJson).join(",")}]`;
    const entries = Object.entries(value)
        .filter(([, v]) => v !== undefined)
        .sort(([a], [b]) => a.localeCompare(b));
    return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`).join(",")}}`;
}
function sha256Hex(value) {
    return createHash("sha256").update(value, "utf8").digest("hex");
}
function actionErrorDetails(err) {
    const maybe = err;
    return {
        message: err instanceof Error ? err.message : String(err),
        ...(typeof maybe.code === "string" ? { code: maybe.code } : {}),
        ...(maybe.details && typeof maybe.details === "object" ? { details: maybe.details } : {}),
    };
}
function assertNever(value) {
    throw new LocalError(`Unknown Run402 action ${value.type}`, "running Run402 action", { code: "RUN402_ACTION_UNKNOWN" });
}
/** Resolve only local destination intent. Approval and global active state are never selectors. */
export async function resolveDeploymentTarget(input) {
    const linkPath = join(input.appRoot, ".run402", "project.json");
    const link = await readWorkspaceProjectLink(linkPath);
    const conflict = workspaceLinkConflict(link, input, input.targetKind ?? "unknown", linkPath);
    if (conflict)
        throw new LocalError(conflict.message, "resolving deployment target", { code: "RUN402_WORKSPACE_LINK_CONFLICT", details: conflict.details });
    if (link?.target?.api_base && input.apiBase && link.target.api_base.replace(/\/$/, "") !== input.apiBase.replace(/\/$/, "")) {
        throw new LocalError("Workspace link belongs to a different API target.", "resolving deployment target", { code: "RUN402_WORKSPACE_LINK_CONFLICT", details: { reason: "api_base_mismatch", link_path: linkPath } });
    }
    const selectors = [input.projectId, input.environmentProjectId, link?.project_id, input.manifestProjectId].filter(Boolean);
    if (new Set(selectors).size > 1) {
        throw new LocalError("Deployment project selectors disagree.", "resolving deployment target", { code: "RUN402_PROJECT_CONFLICT", details: { explicit_project_id: input.projectId ?? null, linked_project_id: link?.project_id ?? null, manifest_project_id: input.manifestProjectId ?? null } });
    }
    const projectId = input.projectId ?? input.environmentProjectId ?? link?.project_id ?? input.manifestProjectId ?? null;
    if (projectId && input.name && (!link || link.name !== input.name)) {
        throw new LocalError("--name is creation intent and cannot select or rename an existing project. Pass --project alone.", "resolving deployment target", { code: "RUN402_PROJECT_CONFLICT", details: { project_id: projectId, name: input.name } });
    }
    if (!projectId && !input.name && !input.allowUnresolved)
        throw projectSelectionRequired(input.appRoot, input.manifestPath);
    return { project_id: projectId, project_name: input.name ?? link?.name ?? null,
        source: input.projectId || input.environmentProjectId ? "explicit" : link ? "workspace_link" : input.manifestProjectId ? "manifest" : input.name ? "create" : "unresolved" };
}
function projectSelectionRequired(appRoot, manifestPath) {
    return new LocalError("No deployment destination is selected. Pass --project for an existing project or --name to create one; -y never selects the global active project.", "resolving deployment target", {
        code: "UP_PROJECT_REQUIRED", details: { app_root: appRoot, manifest_path: manifestPath ?? null, mutation_state: "not_started" },
        next_actions: [{ type: "select_project", safe_to_auto_execute: false, why: "Select the intended project explicitly, or name a new project, then retry in this application directory.", app_root: appRoot }],
    });
}
async function nearbyBinding(manifestPath) {
    try {
        const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
        const link = await readWorkspaceProjectLink(join(dirname(manifestPath), ".run402", "project.json"));
        return { app_name: manifest.app?.name ?? manifest.app?.id ?? null, manifest_project_id: manifest.project_id ?? manifest.project?.id ?? null, linked_project_id: link?.project_id ?? null, target: link?.target ?? null };
    }
    catch {
        return { status: "unknown", reason: "Local binding could not be read; select the application and run its check." };
    }
}
//# sourceMappingURL=actions-node.js.map