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,051 lines • 141 kB
JavaScript
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 faucetKey = run.childKey("wallets.faucet");
const faucetStep = run.addStep({
action: "wallets.faucet",
description: "Request testnet faucet funds for wallet",
mutation: true,
auto: true,
details: { idempotency_key: faucetKey ?? null },
});
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(faucetKey ? { idempotencyKey: faucetKey } : {});
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") ?? null,
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 ?? null,
},
});
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 ? { 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;
}
/**
* A recursive mutation's key exists only under a caller-supplied root. A key
* marks one payment or creation intent, a boundary only the caller knows: a
* key derived from the inputs alone would answer a deliberate second
* purchase with the first one's cached receipt.
*/
childKey(scope) {
const root = this.rootIdempotencyKey;
return root ? `action:${root}:${scope}` : undefined;
}
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 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