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.
507 lines • 24.8 kB
JavaScript
/**
* `r.init()` — set up this machine for Run402, owned by the Node SDK: the
* config directory, the active profile's wallet on its payment rail, testnet
* funding, an optional promo voucher, the Lightning wallet, the tier read,
* the local project count, the vault git remote, the organization, and the
* one next action. Idempotent when re-run on the same rail; switching rails
* needs `switchRail`.
*
* `r.init({ apiBase })` instead configures the active profile to talk to a
* Run402 Core (or another) API base and returns without touching a wallet.
*
* Progress lines (Config / Wallet / Balance / Tier / Next …) go to `onLine`,
* never to stdout; the returned summary is the structured answer.
* `run402 init` is a shim over this.
*/
import { mkdirSync } from "node:fs";
import { configureApiBase, getActiveProfile, getConfigDir } from "../../core-dist/config.js";
import { saveWallet } from "../../core-dist/wallet.js";
import { readLocalWallet } from "./wallets.js";
import { getActiveProjectId, loadKeyStore } from "../../core-dist/keystore.js";
import { readMeta } from "../../core-dist/profiles.js";
import { LocalError } from "../errors.js";
import { gateSecret } from "../secret-gate.js";
import { fundingBlocksBootstrap, fundingRecovery } from "./funding-recovery.js";
import { deployAction, upDeployAction } from "./local-actions.js";
import { describeLightning, ensureLightningWallet, readLightningBalance } from "./lightning-wallet.js";
import { hardenedGit } from "./vault-snapshot.js";
const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
const PATH_USD = "0x20c0000000000000000000000000000000000000";
const TEMPO_RPC = "https://rpc.moderato.tempo.xyz/";
function short(addr) {
return addr.slice(0, 6) + "..." + addr.slice(-4);
}
function sameOrigin(a, b) {
try {
return new URL(a).origin === new URL(b).origin;
}
catch {
return false;
}
}
function errorMessage(err) {
const body = err?.body;
if (body && typeof body === "object") {
const b = body;
return b.message || b.error || err.message;
}
return err?.message || String(err);
}
function errorCode(err, fallback) {
return err?.body?.code ?? err?.code ?? fallback;
}
/**
* Which project the vault scaffold acts on: `RUN402_PROJECT_ID`, then the
* active project — and, when neither, why nothing was done, stated.
*/
export function resolveScaffoldProject(env = process.env, activeProjectId = getActiveProjectId()) {
const fromEnv = typeof env.RUN402_PROJECT_ID === "string" ? env.RUN402_PROJECT_ID.trim() : "";
const projectId = fromEnv || activeProjectId || null;
if (projectId)
return { projectId, skipped: null };
return {
projectId: null,
skipped: "no project is selected, so there is nothing to point a run402 remote at — run `run402 projects use <project_id>` (or set RUN402_PROJECT_ID), then re-run `run402 init`",
};
}
async function detectTarget(clientFor, apiBase) {
try {
const health = (await clientFor({ apiBase, disablePaidFetch: true, authMode: "none" }).service.health());
const kind = health && typeof health === "object" && health.mode === "core"
? "core"
: sameOrigin(apiBase, "https://api.run402.com") ? "cloud" : "core";
return { kind, health_status: typeof health?.status === "string" ? health.status : "ok" };
}
catch (err) {
return { kind: sameOrigin(apiBase, "https://api.run402.com") ? "cloud" : "core", health_error: errorMessage(err) };
}
}
async function readTokenBalance(client, token, address) {
const raw = await client.readContract({ address: token, abi: USDC_ABI, functionName: "balanceOf", args: [address] });
return Number(raw);
}
/** Configure an API target for the active profile. */
export async function initApiTarget(clientFor, apiBase, onLine = () => { }) {
const CONFIG_DIR = getConfigDir();
const detected = await detectTarget(clientFor, apiBase);
const config = configureApiBase(apiBase, {
target_kind: detected.kind,
...(detected.health_status ? { health_status: detected.health_status } : {}),
...(detected.health_error ? { health_error: detected.health_error } : {}),
});
mkdirSync(CONFIG_DIR, { recursive: true });
onLine("");
onLine(` ${"Config".padEnd(10)} ${CONFIG_DIR}`);
onLine(` ${"API base".padEnd(10)} ${config.api_base}`);
onLine(` ${"Target".padEnd(10)} ${config.target_kind}`);
if (detected.health_status)
onLine(` ${"Health".padEnd(10)} ${detected.health_status}`);
if (detected.health_error)
onLine(` ${"Health".padEnd(10)} ${detected.health_error}`);
onLine("");
return {
config_dir: CONFIG_DIR,
api_base: config.api_base,
api_base_source: "profile",
target: {
kind: config.target_kind,
...(config.health_status ? { health_status: config.health_status } : {}),
...(config.health_error ? { health_error: config.health_error } : {}),
},
payment_required: config.target_kind === "cloud",
next_actions: [{ type: "create_project", command: 'run402 projects provision --name "my-app"' }],
next_step: 'run402 projects provision --name "my-app"',
};
}
/** Set up the active profile: wallet, rail, funding, voucher, Lightning, tier, projects, vault remote, orgs, next step. */
export async function runInit(r, client, opts = {}) {
gateSecret(client, "init", opts);
const onLine = opts.onLine ?? (() => { });
const cwd = opts.cwd ?? process.cwd();
const voucherCode = opts.voucher ?? null;
const displayName = opts.name;
if (displayName !== undefined && displayName.trim() === "") {
throw new LocalError("--name requires a non-empty value.", "running init", { code: "BAD_USAGE", details: { flag: "--name" } });
}
const CONFIG_DIR = getConfigDir();
const requestedRail = opts.rail ?? "x402";
const isMpp = requestedRail === "mpp";
const isLightning = requestedRail === "lightning";
const existingWallet = readLocalWallet();
// Creating the wallet writes a private key: the same refusal as wallets.create.
if (!existingWallet)
gateSecret(client, "wallets.create");
if (existingWallet?.rail && existingWallet.rail !== requestedRail && !opts.switchRail) {
throw new LocalError(`Already on rail '${existingWallet.rail}'. Pass --switch-rail to switch to '${requestedRail}'.`, "running init", {
code: "RAIL_SWITCH_REQUIRES_CONFIRM",
details: { current_rail: existingWallet.rail, requested_rail: requestedRail },
});
}
const line = (label, value) => onLine(` ${label.padEnd(10)} ${value}`);
const summary = {
config_dir: CONFIG_DIR,
wallet: null,
rail: null,
network: null,
balances: null,
// Present only when a code was offered, so absence means "none offered".
...(voucherCode ? { voucher: null } : {}),
tier: null,
projects_saved: 0,
active_project_id: null,
next_actions: [],
next_step: null,
};
onLine("");
// 1. Config directory.
mkdirSync(CONFIG_DIR, { recursive: true });
line("Config", CONFIG_DIR);
// 2. Wallet.
let localWallet = existingWallet;
const previousRail = localWallet?.rail;
if (!localWallet) {
const { generatePrivateKey, privateKeyToAccount } = await import("viem/accounts");
const privateKey = generatePrivateKey();
const account = privateKeyToAccount(privateKey);
localWallet = { address: account.address, privateKey, created: new Date().toISOString(), funded: false, rail: requestedRail };
saveWallet(localWallet);
line("Wallet", `${short(localWallet.address)} (created)`);
}
else {
// A wallet leaving Lightning keeps its pairing on disk until revoked.
if (localWallet.rail !== requestedRail) {
localWallet = { ...localWallet, rail: requestedRail };
saveWallet(localWallet);
}
line("Wallet", short(localWallet.address));
}
const walletName = getActiveProfile();
const walletMeta = readMeta(walletName);
summary.wallet = { local_label: walletName, server_label: walletMeta?.label ?? null, address: localWallet.address };
summary.network = isLightning ? "bitcoin-mainnet" : isMpp ? "tempo-moderato" : "base-sepolia";
summary.rail = requestedRail;
line("Network", isLightning ? "Bitcoin mainnet (Lightning) + Base Sepolia fallback" : isMpp ? "Tempo Moderato (testnet)" : "Base Sepolia (testnet)");
line("Rail", requestedRail);
// 3. Balance — read on-chain, faucet if zero.
let balance = 0;
let fundingError;
let fundingPending = false;
if (isMpp) {
const { createPublicClient, http, defineChain } = await import("viem");
const tempoModerato = defineChain({
id: 42431,
name: "Tempo Moderato",
nativeCurrency: { name: "pathUSD", symbol: "pathUSD", decimals: 6 },
rpcUrls: { default: { http: [TEMPO_RPC] } },
});
const chain = createPublicClient({ chain: tempoModerato, transport: http() });
try {
balance = await readTokenBalance(chain, PATH_USD, localWallet.address);
}
catch { /* unreadable → 0 */ }
if (balance === 0) {
line("Balance", "0 pathUSD — requesting Tempo faucet...");
try {
const res = await fetch(TEMPO_RPC, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "tempo_fundAddress", params: [localWallet.address], id: 1 }),
});
const data = (await res.json());
if (data.result) {
// The faucet settles on-chain before the RPC read sees it: poll up to 30 s.
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 1000));
try {
balance = await readTokenBalance(chain, PATH_USD, localWallet.address);
if (balance > 0)
break;
}
catch { /* keep polling */ }
}
saveWallet({ ...localWallet, funded: true, lastFaucet: new Date().toISOString() });
if (balance > 0) {
line("Balance", `${(balance / 1e6).toFixed(2)} pathUSD (funded)`);
}
else {
fundingPending = true;
line("Balance", "faucet sent — not yet confirmed on-chain");
}
}
else {
fundingError = data.error ?? { message: "Faucet failed" };
line("Balance", `faucet failed: ${data.error?.message || "unknown error"}`);
}
}
catch (err) {
fundingError = err;
line("Balance", `faucet error: ${err.message}`);
}
}
else {
line("Balance", `${(balance / 1e6).toFixed(2)} pathUSD`);
}
}
else {
const { createPublicClient, http } = await import("viem");
const { baseSepolia } = await import("viem/chains");
const chain = createPublicClient({ chain: baseSepolia, transport: http() });
try {
balance = await readTokenBalance(chain, USDC_SEPOLIA, localWallet.address);
}
catch { /* unreadable → 0 */ }
if (balance === 0) {
line("Balance", "0 USDC — requesting faucet...");
try {
await r.wallets.faucet(localWallet.address);
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 1000));
try {
balance = await readTokenBalance(chain, USDC_SEPOLIA, localWallet.address);
if (balance > 0)
break;
}
catch { /* keep polling */ }
}
saveWallet({ ...localWallet, funded: true, lastFaucet: new Date().toISOString() });
if (balance > 0) {
line("Balance", `${(balance / 1e6).toFixed(2)} USDC (funded)`);
}
else {
fundingPending = true;
line("Balance", "faucet sent — not yet confirmed on-chain");
}
}
catch (err) {
fundingError = err;
line("Balance", `faucet failed: ${errorMessage(err)}`);
}
}
else {
line("Balance", `${(balance / 1e6).toFixed(2)} USDC`);
}
}
// 3b. Promo code. Runs after the wallet exists (it authenticates as this
// agent) and before the allowance read (so the redeemed amount shows).
// NOTHING here may fail init: every failure is `voucher_error`.
if (voucherCode) {
try {
const redemption = (await r.vouchers.redeem(voucherCode));
const redeemed = (redemption.amount_usd_micros / 1_000_000).toFixed(2);
line("Voucher", redemption.already_redeemed
? `$${redeemed} already added to the allowance (not added twice)`
: `$${redeemed} added to the allowance`);
summary.voucher = {
voucher_id: redemption.voucher_id,
amount_usd_micros: redemption.amount_usd_micros,
already_redeemed: redemption.already_redeemed,
next_actions: Array.isArray(redemption.next_actions) ? redemption.next_actions : [],
};
// The gateway's own `set_tier` action names the largest tier the allowance covers.
const covers = summary.voucher.next_actions.find((a) => a?.type === "set_tier" && typeof a.cli === "string");
if (covers) {
const tierName = covers.highest_affordable_tier ?? covers.cli.replace(/^run402 tier set\s+/, "");
line("Allowance", `$${redeemed} covers ${tierName} — ${covers.cli} (settles from the allowance; no wallet funds needed)`);
}
}
catch (err) {
const reason = errorMessage(err);
line("Voucher", `not applied: ${reason}`);
summary.voucher = null;
summary.voucher_error = { code: errorCode(err, "VOUCHER_REDEEM_FAILED"), message: reason };
}
}
// Balances mirror `status`: the on-chain figure plus the organization's
// allowance, read best-effort.
const billing = (await r.billing.checkBalance(localWallet.address).catch(() => null));
const hasBilling = billing && billing.exists !== false;
summary.balances = {
on_chain_usd_micros: balance,
on_chain_token: isMpp ? "pathUSD" : "USDC",
allowance_usd_micros: hasBilling ? billing.allowance_usd_micros ?? null : null,
held_usd_micros: hasBilling ? (billing.held_usd_micros ?? 0) : null,
};
if (previousRail && previousRail !== (isMpp ? "mpp" : "x402")) {
const prev = previousRail === "mpp" ? "Tempo pathUSD" : "Base Sepolia USDC";
line("Note", `Switched from ${previousRail} — ${prev} balance still available if you switch back`);
}
// 3c. The Lightning wallet: minted on Run402's Hub, its pairing kept beside
// the Base key and never returned. No Hub leaves x402 as the live fallback.
summary.lightning = null;
if (isLightning) {
try {
const result = await ensureLightningWallet(r.agent);
localWallet = result.localWallet;
const settled = result.outcome === "stored" || result.outcome === "present";
const lnBalance = settled ? await readLightningBalance(localWallet) : null;
summary.lightning = { ...(describeLightning(localWallet, result.wallet, lnBalance) ?? {}), outcome: result.outcome };
if (result.outcome === "stored") {
line("Lightning", `wallet minted on Run402's Hub (${result.wallet.budget_sats} sats budget, ${result.wallet.starter_sats} starter)`);
}
else if (result.outcome === "present") {
line("Lightning", `wallet ${localWallet.lightning.wallet_id}${lnBalance ? ` — ${lnBalance.balance_sats} sats` : ""}`);
}
else if (result.outcome === "minting") {
line("Lightning", "the platform is still minting the wallet — rerun `run402 init lightning` in a few seconds");
}
else if (result.outcome === "unavailable") {
line("Lightning", "no Hub on this gateway — paying over x402 until it is back");
}
else if (result.outcome === "pairing_lost") {
line("Lightning", "wallet is active but its pairing was handed to another machine — `run402 wallets lightning revoke`, then init again");
}
else {
line("Lightning", `wallet is ${result.outcome}`);
}
}
catch (err) {
summary.lightning = { outcome: "error", code: errorCode(err, "LIGHTNING_WALLET_FAILED"), message: err?.message ?? String(err) };
line("Lightning", `wallet setup failed: ${err?.message ?? String(err)} — paying over x402 until it is fixed`);
}
}
// 4. Tier.
const store = loadKeyStore();
let tierInfo = null;
try {
tierInfo = (await r.tier.status());
}
catch { /* read best-effort */ }
if (tierInfo && tierInfo.tier && tierInfo.active) {
const expiry = tierInfo.lease_expires_at ? tierInfo.lease_expires_at.split("T")[0] : "unknown";
line("Tier", `${tierInfo.tier} (expires ${expiry})`);
summary.tier = { name: tierInfo.tier, expires: tierInfo.lease_expires_at || null };
}
else {
line("Tier", "(none)");
summary.tier = null;
}
// 5. Projects with LOCAL keys ("saved", not "active").
summary.projects_saved = Object.keys(store.projects).length;
line("Projects", `${summary.projects_saved} saved`);
// 5b. The vault git remote: purely local git, never an allocation. Adding
// the remote inside an existing repository is the default (pure addition);
// creating a repository needs `gitRemote`. Non-fatal in every branch.
const { projectId: activeProjectId, skipped: noProjectSkip } = resolveScaffoldProject(opts.env ?? process.env);
summary.active_project_id = activeProjectId;
if (noProjectSkip) {
summary.vault = null;
summary.vault_skipped = noProjectSkip;
line("Vault", "skipped — no project selected (run402 projects use <project_id>)");
}
else {
summary.vault = null;
try {
let insideRepo = true;
try {
await hardenedGit(cwd, ["rev-parse", "--git-dir"]);
}
catch {
insideRepo = false;
}
if (!insideRepo && !opts.gitRemote) {
summary.vault_skipped = "not a git repository — re-run with --git-remote to create one and add the remote";
line("Vault", "skipped — not a git repository (--git-remote creates one)");
}
else {
const orgId = await r.orgs.owningOrgOf(activeProjectId);
if (!orgId) {
summary.vault_skipped = `could not resolve the owning org for ${activeProjectId} — the run402 remote was not added`;
line("Vault", "skipped — owning org unresolved");
}
else {
const remote = (await r.repos.scaffoldRemote({ repo_dir: cwd, org_id: orgId, project_id: activeProjectId }));
// Local git only: no vault exists for the project yet.
summary.vault = { ...remote, allocated: false };
if (remote.already_present && remote.existing_url !== remote.url) {
line("Vault", `remote '${remote.name}' already points at ${remote.existing_url} — left unchanged (${remote.reason})`);
}
else if (remote.already_present) {
line("Vault", `remote '${remote.name}' already set (${remote.url})`);
}
else {
line("Vault", `${remote.created_repository ? "initialized a repository and added" : "added"} remote '${remote.name}' -> ${remote.url} (${remote.reason})`);
}
}
}
}
catch (err) {
const reason = errorMessage(err);
summary.vault = null;
summary.vault_error = { code: errorCode(err, "VAULT_SCAFFOLD_FAILED"), message: reason };
line("Vault", `remote not added: ${reason}`);
}
}
// 5c. The org: materialized by the tier read above; an identifier, not a credential.
try {
const orgs = (await r.orgs.list());
const rows = (Array.isArray(orgs) ? orgs : (orgs?.orgs ?? []));
summary.orgs = rows.map((o) => ({ org_id: o.org_id, display_name: o.display_name ?? null, role: o.role ?? null }));
if (rows.length === 1) {
line("Org", `${rows[0].org_id}${rows[0].display_name ? ` (${rows[0].display_name})` : ""}`);
}
else if (rows.length > 1) {
line("Org", `${rows.length} organizations — run402 orgs list`);
}
}
catch {
summary.orgs = null;
}
// 6. The next step: canonical typed action(s); `next_step` mirrors the first command.
onLine("");
const tierMissing = !tierInfo || !tierInfo.tier || !tierInfo.active;
summary.funding = fundingRecovery(fundingError, fundingPending);
const fundingBlocked = fundingBlocksBootstrap(summary.funding, {
activeTier: !tierMissing,
onChainBalance: balance,
allowanceBalance: summary.balances.allowance_usd_micros,
lightningBalance: summary.lightning?.balance_sats,
});
summary.next_actions = [tierMissing ? upDeployAction() : deployAction()];
if (fundingBlocked) {
const quotedWallet = `'${walletName.replaceAll("'", "'\\''")}'`;
summary.next_actions = summary.funding.next_actions.map((action) => ({
...action,
...(["retry", "check_balance"].includes(action.type) ? {
command: `run402 --wallet ${quotedWallet} wallets ${action.type === "retry" ? "fund" : "balance"}`,
} : {}),
}));
}
summary.next_step = summary.next_actions[0]?.command ?? null;
if (fundingBlocked) {
line("Funding", summary.funding.status);
onLine(` ${summary.next_actions[0].why}`);
if (summary.next_step)
onLine(` Next: ${summary.next_step}`);
}
else if (tierMissing) {
// `up -y` sets the prototype tier itself as part of the first deploy.
const allowanceCovers = summary.voucher?.next_actions?.find((a) => a?.type === "set_tier" && typeof a.cli === "string");
onLine(" Next: run402 up -y");
onLine(" Deploy with run402 up -y — it sets the prototype tier (free on testnet) as part of the first deploy.");
if (allowanceCovers) {
onLine(` Your allowance covers ${allowanceCovers.highest_affordable_tier ?? "a larger tier"}: ${allowanceCovers.cli} — then run402 up -y.`);
}
else {
onLine(" Or set it separately: run402 tier set prototype.");
}
}
else {
onLine(" Ready to deploy. Run: run402 deploy --manifest app.json");
}
onLine("");
// 5d. Display name, best-effort.
if (displayName !== undefined) {
try {
const me = (await r.orgs.setDisplayName(displayName.trim()));
summary.display_name = me?.principal?.display_name ?? displayName.trim();
line("Name", summary.display_name);
}
catch (err) {
summary.display_name = null;
summary.display_name_error = { code: errorCode(err, "DISPLAY_NAME_FAILED"), message: err?.message ?? String(err) };
}
}
return summary;
}
//# sourceMappingURL=init.js.map