From 6eb423142ac2917982caf9039fcae7276d5e7857 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:08:48 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20new=20=E2=80=94=20the=20scaffold=20fron?= =?UTF-8?q?t=20door=20(wraps=20create-rome-app)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rome new [--chain ]: shell out to the canonical create-rome-app scaffolder, then add what it can't know — the chain, resolved from the registry and pre-wired into the app's .env — and return the lifecycle next-steps in the CLI's own commands (fund → deploy → demo → verify). Validates name/chain and refuses an existing target BEFORE any npx spend. Keyless action: CLI-only (MCP never writes to disk) but requires no signing key — mkCap gains an explicit requiresKey override, and the actions invariant is TIGHTENED (the keyless set must equal exactly {new.new}). Works-gate (live): scaffold on martius → CHAIN_ID=121214 pre-wired → npm install → gen-config resolves chain 121214 from the registry → vite build green. Driving this gate surfaced + fixed two shipped create-rome-app bugs upstream (#3 npx empty-scaffold, #4 CHAIN_ID ignored). 115 unit tests; GUIDES "Start an app" + ARCHITECTURE rows. --- docs/ARCHITECTURE.md | 4 +- docs/GUIDES.md | 26 ++++++++++- src/core/capabilities.ts | 21 ++++++++- src/core/new.ts | 95 ++++++++++++++++++++++++++++++++++++++++ test/actions.test.ts | 7 ++- test/new.test.ts | 93 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 src/core/new.ts create mode 100644 test/new.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3336cb5..4d7cf62 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ ``` ┌───────────────── capability core (src/core/capabilities.ts) ─────────────────┐ │ READS (no keys): facts · cookbook · call · doctor · tx · preset │ - │ ACTIONS (key-gated): deploy · send · fund · bridge · activate · verify │ + │ ACTIONS (CLI-only): new · deploy · send · fund · bridge · activate · verify │ └───────────────────────────┬───────────────────────────┬─────────────────────┘ rome (CLI — all commands) rome mcp (stdio MCP server) humans + agent shell-outs MCP-native agents — READS ONLY @@ -74,6 +74,7 @@ Chains resolve by id, name, or slug (`200010`, `hadrian`, `Rome Hadrian`) — by | `bridge --from --amount [--intent gas\|wrapper]` | `ROME_EVM_KEY` | bridge USDC **in** as gas or wUSDC | | `bridge --to --amount [--recipient 0x…]` | `ROME_EVM_KEY` | bridge wUSDC **out**: burn on Rome → claim handle for the destination (you claim there) | | `activate ` | `ROME_EVM_KEY` | one-time PDA funding required before the first bridge **out** (idempotent; inbound needs none) | +| `new [--chain ]` | *none* (keyless) | scaffold a dual-lane app — wraps `create-rome-app`, pre-wires the chain from the registry into `.env`, prints the lifecycle next-steps (fund → deploy → demo → verify). CLI-only: MCP never writes to disk | | `verify [--path solidity]` | `ROME_EVM_KEY` + `ROME_SOLANA_KEY` | the **both-lane works-gate**: deploy a probe, drive it from the EVM lane *and* the Solana lane, assert parity | | `verify --path solana-program` | `ROME_EVM_KEY` | the **cross-VM works-gate**: deploy a thin CPI wrapper; an EVM-lane call drives a Solana program (SPL Memo) via CPI. `--solana-rpc` adds the Solana-log deep check | | `verify --path from-home --from --amount ` | `ROME_EVM_KEY` | the **round-trip works-gate**: bridge in (wrapper) → act on Rome → bridge out to claim-ready. Waits on Circle attestation (~20 min); needs an activated account | @@ -105,6 +106,7 @@ src/ actions.ts call (read) · deploy · send (viem + submitRomeTx) bridge.ts fund · bridge in/out — the CCTP flow engines (orchestrate the SDK) activate.ts one-time PDA funding for bridge-out (SimpleActivator) + the check + new.ts scaffold front door — wraps create-rome-app + chain pre-wiring doctor.ts preflight checklist tx.ts cross-VM diagnosis (rome_solanaTxForEvmTx; no debug_trace) verify.ts the path-aware works-gate (+ probe.ts: bundled Store + CPI-Memo probes) diff --git a/docs/GUIDES.md b/docs/GUIDES.md index 9269659..6c8e729 100644 --- a/docs/GUIDES.md +++ b/docs/GUIDES.md @@ -5,6 +5,7 @@ Concrete, copy-paste recipes: real commands with real output, and how to fold `r - [Every command, with real output](#every-command-with-real-output) - [Integrate into an AI agent (MCP)](#integrate-into-an-ai-agent-mcp) - [The agent grounding loop](#the-agent-grounding-loop) +- [Start an app (`rome new`)](#start-an-app-rome-new) - [Fund a wallet from another chain (`fund` / `bridge`)](#fund-a-wallet-from-another-chain-fund--bridge) - [Bridge out of Rome (`bridge --to`) + `activate`](#bridge-out-of-rome-bridge---to--activate) - [Prove it works — `rome verify`](#prove-it-works--rome-verify) @@ -169,7 +170,7 @@ npm install -g github:rome-protocol/rome-cli#v0.8.0 rome mcp # starts the stdio server; your client lists the READ tools: facts_chain, # facts_tokens, facts_contracts, facts_gas, facts_balance, facts_programs, # cookbook_cpi_recipe, cookbook_patterns, cookbook_errors, call, doctor, tx, preset - # (actions — deploy/send/fund/bridge/activate/verify — are CLI-only, never on MCP) + # (actions — new/deploy/send/fund/bridge/activate/verify — are CLI-only, never on MCP) ``` **4. Use it in a prompt.** Now the agent can call the tools instead of hallucinating: @@ -202,6 +203,29 @@ The agent now writes against `aerarium`'s pattern with Hadrian's real RPC and th --- +## Start an app (`rome new`) + +The scaffold front door. Wraps [`create-rome-app`](https://github.com/rome-protocol/create-rome-app) (the canonical dual-lane scaffolder — a Vault contract + both lanes + a Vite UI), then adds what the scaffolder can't know: your **chain**, resolved from the registry and pre-wired into the app's `.env`. Keyless — it signs nothing (the key stays out until you fund/deploy). + +```console +$ rome new my-app --chain hadrian +{ + "app": "my-app", + "chainId": 200010, + "chainName": "Rome Hadrian", + "next": [ + "cd my-app && npm install", + "# fund the wallets in .env (gas is USDC — bridge it in; no faucet):", + "rome fund hadrian --from base-sepolia --amount 1", + "npm run deploy # deploy the Vault to Rome Hadrian", + "npm run demo # the funded dual-lane proof (MetaMask + Phantom → one Vault)", + "rome verify hadrian # the works-gate, any path" + ] +} +``` + +The `next` steps are the whole lifecycle in this CLI's own commands — scaffold → fund → deploy → prove. Templates live in `create-rome-app` (today: the dual-lane Vault, i.e. the *bring-your-idea* path); as per-path templates land there, `new` grows matching flags. + ## Fund a wallet from another chain (`fund` / `bridge`) The **from-home** path: you hold USDC on another chain and want onto Rome. `fund` bridges it in as **native gas**; `bridge --intent wrapper` brings it in as **wUSDC**. Both use Circle CCTP, orchestrated through [`@rome-protocol/sdk`](https://github.com/rome-protocol/rome-sdk-ts)'s `bridge` module — you sign only the source-chain burn; Rome's sponsor pays the settle. diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 4013d3f..435ec65 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -6,6 +6,7 @@ import { doctor } from "./doctor.js"; import { diagnoseTx } from "./tx.js"; import { verifyHandler } from "./verify.js"; import { activateHandler } from "./activate.js"; +import { newHandler } from "./new.js"; import { getPreset } from "./presets.js"; import { type Deps } from "./deps.js"; @@ -46,13 +47,16 @@ function mkCap( summary: string, args: ArgSpec[], handler: Capability["handler"], + requiresKey?: boolean, ): Capability { // id stays group.command (stable). CLI + MCP names use single-verb form for verbs. // MCP tool names use underscores only (some clients reject hyphens); derivation is // deterministic → alignment holds (see alignment.test.ts). + // requiresKey defaults by kind; a keyless action (e.g. `new` — writes to disk, + // signs nothing) overrides it while staying CLI-only. const cliPath = verb ? command : `${group} ${command}`; const mcpTool = (verb ? command : `${group}_${command}`).replace(/-/g, "_"); - return { id: `${group}.${command}`, group, command, verb, cliPath, mcpTool, summary, kind, requiresKey: kind === "action", args, handler }; + return { id: `${group}.${command}`, group, command, verb, cliPath, mcpTool, summary, kind, requiresKey: requiresKey ?? kind === "action", args, handler }; } // grouped read (CLI+MCP) / grouped action (CLI-only, key). @@ -63,6 +67,9 @@ const verbCap = (group: string, command: string, summary: string, args: ArgSpec[ mkCap("read", true, group, command, summary, args, handler); const verbAction = (group: string, command: string, summary: string, args: ArgSpec[], handler: Capability["handler"]) => mkCap("action", true, group, command, summary, args, handler); +// keyless action: CLI-only (never MCP) but needs no signing key (e.g. scaffolding). +const verbActionKeyless = (group: string, command: string, summary: string, args: ArgSpec[], handler: Capability["handler"]) => + mkCap("action", true, group, command, summary, args, handler, false); const chainArg: ArgSpec = { name: "chain", required: true, description: "chain id, name, or slug (e.g. 200010 or hadrian)" }; @@ -213,6 +220,18 @@ export const CAPABILITIES: Capability[] = [ (a) => bridgeHandler(a), ), + // ── new: scaffold front door (wraps create-rome-app; keyless, CLI-only) ── + verbActionKeyless( + "new", + "new", + "Scaffold a dual-lane Rome app (wraps create-rome-app) with the chain pre-wired from the registry, then the lifecycle next-steps: fund → deploy → demo → verify. No key needed. e.g. rome new my-app --chain hadrian", + [ + { name: "name", required: true, description: "app name (becomes the directory + package name)" }, + { name: "chain", required: false, description: "Rome chain to pre-wire (id, name, or slug; default hadrian)" }, + ], + (a) => newHandler(a), + ), + // ── activate: one-time PDA funding, required before the first bridge OUT ── verbAction( "activate", diff --git a/src/core/new.ts b/src/core/new.ts new file mode 100644 index 0000000..4936865 --- /dev/null +++ b/src/core/new.ts @@ -0,0 +1,95 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getChainFacts } from "./facts.js"; + +// `rome new [--chain ]` — the scaffold front door. WRAPS +// create-rome-app (the scaffolder stays canonical — we never rebuild it), then adds +// the Rome-unique glue: resolve the chain through the registry, pre-wire it into the +// app's .env, and hand back the lifecycle next-steps (fund → deploy → demo → verify). +// CLI-only (the MCP surface never writes to disk) but KEYLESS — it signs nothing. + +const SCAFFOLDER = "github:rome-protocol/create-rome-app"; // floats on main; pin when it tags + +export interface NewDeps { + targetExists(appName: string): boolean; + /** Run the canonical scaffolder (npx create-rome-app ). */ + scaffold(appName: string): Promise; + /** Write /.env from .env.example with CHAIN_ID pre-wired. */ + writeChainEnv(appName: string, chainId: number): Promise; +} + +export interface NewResult { + app: string; + chainId: number; + chainName: string; + next: string[]; +} + +/** .env.example → .env content with CHAIN_ID set (uncommented; appended if absent). */ +export function projectChainEnv(exampleContent: string, chainId: number): string { + const line = `CHAIN_ID=${chainId}`; + if (/^#?\s*CHAIN_ID=.*$/m.test(exampleContent)) { + return exampleContent.replace(/^#?\s*CHAIN_ID=.*$/m, line); + } + return exampleContent.replace(/\n*$/, "\n") + line + "\n"; +} + +/** Validate → scaffold (canonical) → pre-wire the chain → grounded next steps. */ +export async function runNew(appName: string, chain: string | number | undefined, deps: NewDeps): Promise { + if (!appName || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(appName)) { + throw new Error(`Invalid app name "${appName}" — use letters/digits/dashes (it becomes a directory + package name).`); + } + const c = getChainFacts(chain ?? "hadrian"); // throws "Unknown chain …" BEFORE any npx spend + if (deps.targetExists(appName)) { + throw new Error(`"${appName}" already exists here — pick another name or remove it first.`); + } + + await deps.scaffold(appName); + await deps.writeChainEnv(appName, c.chainId); + + const slug = String(chain ?? "hadrian"); + return { + app: appName, + chainId: c.chainId, + chainName: c.name, + next: [ + `cd ${appName} && npm install`, + `# fund the wallets in .env (gas is USDC — bridge it in; no faucet):`, + `rome fund ${slug} --from base-sepolia --amount 1`, + `npm run deploy # deploy the Vault to ${c.name}`, + `npm run demo # the funded dual-lane proof (MetaMask + Phantom → one Vault)`, + `rome verify ${slug} # the works-gate, any path`, + ], + }; +} + +const pExecFile = promisify(execFile); + +/** Real deps: npx the canonical scaffolder + fs env projection. */ +export function defaultNewDeps(): NewDeps { + return { + targetExists: (appName) => existsSync(join(process.cwd(), appName)), + async scaffold(appName) { + try { + await pExecFile("npx", ["-y", SCAFFOLDER, appName], { cwd: process.cwd(), timeout: 300_000 }); + } catch (e) { + const err = e as { stderr?: string; message?: string }; + throw new Error(`create-rome-app failed: ${(err.stderr || err.message || "").trim().slice(0, 400)}`); + } + }, + async writeChainEnv(appName, chainId) { + const dir = join(process.cwd(), appName); + const example = join(dir, ".env.example"); + const content = existsSync(example) ? readFileSync(example, "utf8") : ""; + writeFileSync(join(dir, ".env"), projectChainEnv(content, chainId)); + }, + }; +} + +/** `rome new [--chain ]` handler — keyless; CLI-only. */ +export async function newHandler(args: Record): Promise { + if (!args.name) throw new Error("Missing app name. Usage: rome new [--chain ]"); + return runNew(args.name, args.chain, defaultNewDeps()); +} diff --git a/test/actions.test.ts b/test/actions.test.ts index 0340d40..be8736b 100644 --- a/test/actions.test.ts +++ b/test/actions.test.ts @@ -20,10 +20,13 @@ describe("capability kinds", () => { } }); - it("there is at least one action, and actions require a key", () => { + it("there is at least one action; every action requires a key EXCEPT the explicit keyless set", () => { const actions = CAPABILITIES.filter((c) => c.kind === "action"); expect(actions.length).toBeGreaterThan(0); - for (const a of actions) expect(a.requiresKey).toBe(true); + // `new` scaffolds to disk but signs nothing — the ONLY keyless action. Anything + // else keyless is a bug: signing actions must fail fast on a missing env key. + const KEYLESS = new Set(["new.new"]); + for (const a of actions) expect(a.requiresKey, a.id).toBe(!KEYLESS.has(a.id)); }); }); diff --git a/test/new.test.ts b/test/new.test.ts new file mode 100644 index 0000000..11004cd --- /dev/null +++ b/test/new.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { runNew, projectChainEnv, type NewDeps } from "../src/core/new.js"; +import { CAPABILITIES } from "../src/core/capabilities.js"; +import { buildMcpTools } from "../src/mcp.js"; + +// `rome new [--chain ]` — scaffold a dual-lane app by WRAPPING +// create-rome-app (the scaffolder stays canonical), then pre-wire the chosen chain +// into .env and hand back grounded next steps (fund → deploy → demo → verify). +// CLI-only (MCP never writes to disk) but KEYLESS — signs nothing. + +function deps(over: Partial = {}, order: string[] = []): NewDeps { + return { + targetExists: vi.fn(() => false), + scaffold: vi.fn(async () => void order.push("scaffold")), + writeChainEnv: vi.fn(async () => void order.push("env")), + ...over, + }; +} + +describe("runNew — scaffold, pre-wire chain, grounded next steps", () => { + it("resolves the chain, scaffolds, writes the env, and returns lifecycle next-steps", async () => { + const order: string[] = []; + const d = deps({}, order); + const r = await runNew("my-app", "hadrian", d); + expect(order).toEqual(["scaffold", "env"]); + expect(d.writeChainEnv).toHaveBeenCalledWith("my-app", 200010); + expect(r.app).toBe("my-app"); + expect(r.chainId).toBe(200010); + const next = r.next.join("\n"); + expect(next).toMatch(/rome fund/); + expect(next).toMatch(/npm run deploy/); + expect(next).toMatch(/npm run demo/); + expect(next).toMatch(/rome verify/); + }); + + it("fails fast on an unknown chain BEFORE scaffolding (no npx spend)", async () => { + const d = deps(); + await expect(runNew("my-app", "not-a-chain", d)).rejects.toThrow(/[Uu]nknown chain/); + expect(d.scaffold).not.toHaveBeenCalled(); + }); + + it("rejects a bad app name (empty / path-y) before scaffolding", async () => { + const d = deps(); + await expect(runNew("", "hadrian", d)).rejects.toThrow(/app name/i); + await expect(runNew("../evil", "hadrian", d)).rejects.toThrow(/app name/i); + expect(d.scaffold).not.toHaveBeenCalled(); + }); + + it("refuses to scaffold over an existing directory", async () => { + const d = deps({ targetExists: vi.fn(() => true) }); + await expect(runNew("taken", "hadrian", d)).rejects.toThrow(/exists/); + expect(d.scaffold).not.toHaveBeenCalled(); + }); +}); + +describe("projectChainEnv — .env.example → .env with the chain pre-wired", () => { + it("uncomments/sets CHAIN_ID, preserving the rest of the file", () => { + const example = "# Chain — defaults to Rome Hadrian.\n# CHAIN_ID=200010\nPROXY_URL=\nEVM_KEY=0xYOUR_EVM_PRIVATE_KEY\n"; + const out = projectChainEnv(example, 121214); + expect(out).toContain("CHAIN_ID=121214"); + expect(out).not.toMatch(/^# CHAIN_ID=/m); + expect(out).toContain("PROXY_URL="); + expect(out).toContain("EVM_KEY=0xYOUR_EVM_PRIVATE_KEY"); + }); + + it("appends CHAIN_ID when the example has no CHAIN_ID line at all", () => { + const out = projectChainEnv("PROXY_URL=\n", 200010); + expect(out).toMatch(/^CHAIN_ID=200010$/m); + expect(out).toContain("PROXY_URL="); + }); +}); + +describe("new is CLI-only but KEYLESS", () => { + it("registered as a keyless verb action, absent from the MCP surface", () => { + const cap = CAPABILITIES.find((c) => c.id === "new.new"); + expect(cap?.kind).toBe("action"); + expect(cap?.requiresKey).toBe(false); // scaffolding signs nothing + expect(cap?.verb).toBe(true); + expect(new Set(buildMcpTools().map((t) => t.name)).has("new")).toBe(false); + }); + + it("the handler runs with NO ROME_EVM_KEY in the environment (fails only on its own args)", async () => { + const prev = process.env.ROME_EVM_KEY; + delete process.env.ROME_EVM_KEY; + try { + const cap = CAPABILITIES.find((c) => c.id === "new.new")!; + // missing app name → its own arg error, NOT a key error + await expect(Promise.resolve(cap.handler({ chain: "hadrian" }))).rejects.toThrow(/app name/i); + } finally { + if (prev !== undefined) process.env.ROME_EVM_KEY = prev; + } + }); +});