Skip to content

Commit 05cd28e

Browse files
MajorTalclaude
andcommitted
bundle_deploy requires project_id; add run402 status command
- bundle_deploy now deploys to an existing project (project_id required, name removed) matching the updated POST /deploy/v1 server contract - CLI deploy adds --project flag; falls back to active project - MCP tool validates project exists in local keystore before calling API - Response no longer returns keys (project already provisioned) - New `run402 status` command: read-only account snapshot showing allowance, balance, tier, projects, and active project (JSON output) - Add --help to both `run402 init` and `run402 status` - Update e2e tests and mock fetch for new deploy contract Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fb7c863 commit 05cd28e

9 files changed

Lines changed: 152 additions & 44 deletions

File tree

cli-e2e.test.mjs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ function mockFetch(input, init) {
123123
if (path === "/tiers/v1/status" && method === "GET") {
124124
return Promise.resolve(json({ tier: "prototype", status: "active", lease_expires_at: "2026-03-22T00:00:00.000Z" }));
125125
}
126+
if (path.match(/^\/wallets\/v1\/[^/]+\/projects$/) && method === "GET") {
127+
return Promise.resolve(json({ wallet: "0xtest", projects: [{ id: "prj_test123", name: "test", tier: "prototype", status: "active", lease_expires_at: "2026-03-22T00:00:00.000Z" }] }));
128+
}
126129
// x402 discovery GET before paid POST
127130
if (path.startsWith("/tiers/v1/") && path !== "/tiers/v1/status" && method === "GET") {
128131
return Promise.resolve(json({ price: "$0.10", network: "base-sepolia" }));
@@ -217,7 +220,7 @@ function mockFetch(input, init) {
217220
// Bundle deploy
218221
if (path === "/deploy/v1" && method === "POST") {
219222
return Promise.resolve(json({
220-
...TEST_PROJECT,
223+
project_id: TEST_PROJECT.project_id,
221224
site_url: "https://test.sites.run402.com",
222225
subdomain_url: "https://test-app.run402.com",
223226
}));
@@ -505,11 +508,10 @@ describe("CLI e2e happy path", () => {
505508
const manifestPath = join(tempDir, "manifest.json");
506509
const { writeFileSync: wf } = await import("node:fs");
507510
wf(manifestPath, JSON.stringify({
508-
name: "test-app",
509511
files: [{ file: "index.html", data: "<h1>Hello</h1>" }],
510512
}));
511513
captureStart();
512-
await run(["--manifest", manifestPath]);
514+
await run(["--manifest", manifestPath, "--project", "prj_test123"]);
513515
captureStop();
514516
assert.ok(captured().includes("prj_test123"), "should return project info");
515517
});
@@ -784,4 +786,16 @@ describe("CLI e2e happy path", () => {
784786
assert.ok(out.includes("Balance") || out.includes("USDC"), "should show balance");
785787
assert.ok(out.includes("Tier") || out.includes("prototype"), "should show tier");
786788
});
789+
790+
it("status", async () => {
791+
const { run } = await import("./cli/lib/status.mjs");
792+
captureStart();
793+
await run();
794+
captureStop();
795+
const out = captured();
796+
const data = JSON.parse(out);
797+
assert.ok(data.allowance, "should include allowance");
798+
assert.ok(data.allowance.address, "should include allowance address");
799+
assert.ok(Array.isArray(data.projects), "should include projects array");
800+
});
787801
});

cli/cli.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Usage:
2020
2121
Commands:
2222
init Set up allowance, funding, and check tier status
23+
status Show full account state (allowance, balance, tier, projects)
2324
allowance Manage your agent allowance (create, fund, balance, status)
2425
tier Manage tier subscription (status, set)
2526
projects Manage projects (provision, list, query, inspect, delete)
@@ -65,7 +66,12 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
6566
switch (cmd) {
6667
case "init": {
6768
const { run } = await import("./lib/init.mjs");
68-
await run();
69+
await run([sub, ...rest].filter(Boolean));
70+
break;
71+
}
72+
case "status": {
73+
const { run } = await import("./lib/status.mjs");
74+
await run([sub, ...rest].filter(Boolean));
6975
break;
7076
}
7177
case "allowance": {

cli/lib/deploy.mjs

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import { readFileSync } from "fs";
2-
import { API, allowanceAuthHeaders, saveProject, setActiveProjectId } from "./config.mjs";
2+
import { API, allowanceAuthHeaders, findProject } from "./config.mjs";
33

4-
const HELP = `run402 deploy — Deploy a full-stack app or static site on Run402
4+
const HELP = `run402 deploy — Deploy to an existing project on Run402
55
66
Usage:
77
run402 deploy [options]
88
cat manifest.json | run402 deploy [options]
99
1010
Options:
1111
--manifest <file> Path to manifest JSON file (default: read from stdin)
12+
--project <id> Project ID to deploy to (default: active project)
1213
--help, -h Show this help message
1314
1415
Manifest format (JSON):
1516
{
16-
"name": "my-app",
17+
"project_id": "prj_...",
1718
"migrations": "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false)",
1819
"rls": {
1920
"template": "public_read_write",
@@ -28,7 +29,8 @@ Manifest format (JSON):
2829
"subdomain": "my-app"
2930
}
3031
31-
All fields except "name" are optional.
32+
project_id is required (provision first with 'run402 provision').
33+
All other fields are optional.
3234
3335
RLS templates:
3436
user_owns_rows — users see only their rows (requires owner_column per table)
@@ -40,16 +42,18 @@ Manifest format (JSON):
4042
4143
Examples:
4244
run402 deploy --manifest app.json
45+
run402 deploy --manifest app.json --project prj_123_1
4346
cat app.json | run402 deploy
4447
4548
Prerequisites:
4649
- run402 init Set up allowance and funding
4750
- run402 tier set prototype Subscribe to a tier
51+
- run402 provision Provision a project first
4852
4953
Notes:
5054
- Requires an active tier subscription (run402 tier set <tier>)
51-
- Project credentials (project_id, keys, URL) are saved locally after deploy
52-
- Use 'run402 projects list' to see all deployed projects
55+
- Provision a project first with 'run402 provision', then deploy to it
56+
- Use 'run402 projects list' to see all provisioned projects
5357
`;
5458

5559
async function readStdin() {
@@ -59,25 +63,30 @@ async function readStdin() {
5963
}
6064

6165
export async function run(args) {
62-
const opts = { manifest: null };
66+
const opts = { manifest: null, project: null };
6367
for (let i = 0; i < args.length; i++) {
6468
if (args[i] === "--help" || args[i] === "-h") { console.log(HELP); process.exit(0); }
6569
if (args[i] === "--manifest" && args[i + 1]) opts.manifest = args[++i];
70+
if (args[i] === "--project" && args[i + 1]) opts.project = args[++i];
6671
}
6772

6873
const manifest = opts.manifest ? JSON.parse(readFileSync(opts.manifest, "utf-8")) : JSON.parse(await readStdin());
6974

75+
// --project flag overrides manifest's project_id
76+
if (opts.project) manifest.project_id = opts.project;
77+
78+
// If no project_id in manifest, use active project
79+
if (!manifest.project_id) {
80+
const { id } = findProject(null);
81+
manifest.project_id = id;
82+
}
83+
84+
// Remove legacy 'name' field if present
85+
delete manifest.name;
86+
7087
const authHeaders = allowanceAuthHeaders("/deploy/v1");
7188
const res = await fetch(`${API}/deploy/v1`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, body: JSON.stringify(manifest) });
7289
const result = await res.json();
7390
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...result })); process.exit(1); }
74-
if (result.project_id) {
75-
saveProject(result.project_id, {
76-
anon_key: result.anon_key, service_key: result.service_key,
77-
site_url: result.site_url || result.subdomain_url,
78-
deployed_at: new Date().toISOString(),
79-
});
80-
setActiveProjectId(result.project_id);
81-
}
8291
console.log(JSON.stringify(result, null, 2));
8392
}

cli/lib/init.mjs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,27 @@ import { mkdirSync } from "fs";
55
const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
66
const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
77

8+
const HELP = `run402 init — Set up allowance, funding, and check tier status
9+
10+
Usage:
11+
run402 init
12+
13+
Steps (idempotent — safe to re-run):
14+
1. Creates config directory (~/.config/run402)
15+
2. Creates agent allowance if none exists
16+
3. Checks on-chain USDC balance; requests faucet if zero
17+
4. Shows current tier subscription status
18+
5. Lists local project count
19+
6. Suggests next step (tier set or deploy)
20+
21+
Run this once to get started, or again to check your setup.
22+
`;
23+
824
function short(addr) { return addr.slice(0, 6) + "..." + addr.slice(-4); }
925
function line(label, value) { console.log(` ${label.padEnd(10)} ${value}`); }
1026

11-
export async function run() {
27+
export async function run(args = []) {
28+
if (args.includes("--help") || args.includes("-h")) { console.log(HELP); process.exit(0); }
1229
console.log();
1330

1431
// 1. Config directory

cli/lib/status.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { readAllowance, loadKeyStore, getActiveProjectId, API } from "./config.mjs";
2+
import { getAllowanceAuthHeaders } from "../core-dist/allowance-auth.js";
3+
4+
const HELP = `run402 status — Show full account state in one shot
5+
6+
Usage:
7+
run402 status
8+
9+
Displays:
10+
- Allowance address and funding status
11+
- Billing balance (available + held)
12+
- Tier subscription (name, status, expiry)
13+
- Projects (from server, with fallback to local keystore)
14+
- Active project ID
15+
16+
Output is JSON. Requires an existing allowance (run 'run402 init' first).
17+
`;
18+
19+
export async function run(args = []) {
20+
if (args.includes("--help") || args.includes("-h")) { console.log(HELP); process.exit(0); }
21+
const allowance = readAllowance();
22+
if (!allowance) {
23+
console.log(JSON.stringify({ status: "no_allowance", message: "No agent allowance found. Run: run402 init" }));
24+
return;
25+
}
26+
27+
const wallet = allowance.address.toLowerCase();
28+
const authHeaders = getAllowanceAuthHeaders("/tiers/v1/status");
29+
30+
// Parallel API calls: tier + billing balance + server-side projects
31+
const [tierRes, balanceRes, projectsRes] = await Promise.all([
32+
authHeaders
33+
? fetch(`${API}/tiers/v1/status`, { headers: { ...authHeaders } }).catch(() => null)
34+
: null,
35+
fetch(`${API}/billing/v1/accounts/${wallet}`).catch(() => null),
36+
fetch(`${API}/wallets/v1/${wallet}/projects`).catch(() => null),
37+
]);
38+
39+
const tier = tierRes?.ok ? await tierRes.json() : null;
40+
const billing = balanceRes?.ok ? await balanceRes.json() : null;
41+
const remote = projectsRes?.ok ? await projectsRes.json() : null;
42+
43+
// Local keystore
44+
const store = loadKeyStore();
45+
const activeId = getActiveProjectId();
46+
47+
const result = {
48+
allowance: {
49+
address: allowance.address,
50+
funded: allowance.funded || false,
51+
},
52+
tier: tier && tier.tier
53+
? { name: tier.tier, status: tier.status, expires: tier.lease_expires_at }
54+
: null,
55+
balance: billing && billing.exists
56+
? { available_usd_micros: billing.available_usd_micros, held_usd_micros: billing.held_usd_micros }
57+
: null,
58+
projects: remote?.projects || Object.keys(store.projects).map(id => ({ id })),
59+
active_project: activeId || null,
60+
};
61+
62+
console.log(JSON.stringify(result, null, 2));
63+
}

openclaw/scripts/status.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { run } from "../../cli/lib/status.mjs";

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ server.tool(
239239

240240
server.tool(
241241
"bundle_deploy",
242-
"One-call full-stack app deployment. Provisions a database and optionally runs migrations, applies RLS, sets secrets, deploys functions, deploys a static site, and claims a subdomain. Free with active tier.",
242+
"Deploy to an existing project. Runs migrations, applies RLS, sets secrets, deploys functions, deploys a static site, and claims a subdomain. Requires a provisioned project_id. Free with active tier.",
243243
bundleDeploySchema,
244244
async (args) => handleBundleDeploy(args),
245245
);

src/tools/bundle-deploy.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { z } from "zod";
22
import { apiRequest } from "../client.js";
3-
import { saveProject, setActiveProjectId } from "../keystore.js";
4-
import { formatApiError } from "../errors.js";
3+
import { formatApiError, projectNotFound } from "../errors.js";
54
import { requireAllowanceAuth } from "../allowance-auth.js";
5+
import { getProject } from "../keystore.js";
66

77
export const bundleDeploySchema = {
8-
name: z.string().describe("App name (used as project name and default subdomain)"),
8+
project_id: z.string().describe("Project ID to deploy to (from provision). Uses active project if omitted.").optional(),
99
migrations: z
1010
.string()
1111
.optional()
12-
.describe("SQL migrations to run after provisioning (CREATE TABLE statements, etc.)"),
12+
.describe("SQL migrations to run (CREATE TABLE statements, etc.)"),
1313
rls: z
1414
.object({
1515
template: z.enum(["user_owns_rows", "public_read", "public_read_write"]),
@@ -58,22 +58,27 @@ export const bundleDeploySchema = {
5858
};
5959

6060
export async function handleBundleDeploy(args: {
61-
name: string;
61+
project_id?: string;
6262
migrations?: string;
6363
rls?: { template: string; tables: Array<{ table: string; owner_column?: string }> };
6464
secrets?: Array<{ key: string; value: string }>;
6565
functions?: Array<{ name: string; code: string; config?: { timeout?: number; memory?: number } }>;
6666
files?: Array<{ file: string; data: string; encoding?: string }>;
6767
subdomain?: string;
6868
}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
69+
const projectId = args.project_id;
70+
if (!projectId) return projectNotFound("(none — project_id is required)");
71+
const project = getProject(projectId);
72+
if (!project) return projectNotFound(projectId);
73+
6974
const auth = requireAllowanceAuth("/deploy/v1");
7075
if ("error" in auth) return auth.error;
7176

7277
const res = await apiRequest("/deploy/v1", {
7378
method: "POST",
7479
headers: { ...auth.headers },
7580
body: {
76-
name: args.name,
81+
project_id: projectId,
7782
migrations: args.migrations,
7883
rls: args.rls,
7984
secrets: args.secrets,
@@ -87,28 +92,18 @@ export async function handleBundleDeploy(args: {
8792

8893
const body = res.body as {
8994
project_id: string;
90-
anon_key: string;
91-
service_key: string;
92-
schema_slot: string;
9395
site_url?: string;
94-
subdomain_url?: string;
96+
deployment_id?: string;
9597
functions?: Array<{ name: string; url: string }>;
98+
subdomain_url?: string;
9699
};
97100

98-
// Save credentials to local key store and set as active project
99-
saveProject(body.project_id, {
100-
anon_key: body.anon_key,
101-
service_key: body.service_key,
102-
});
103-
setActiveProjectId(body.project_id);
104-
105101
const lines = [
106-
`## Bundle Deployed: ${args.name}`,
102+
`## Bundle Deployed`,
107103
``,
108104
`| Field | Value |`,
109105
`|-------|-------|`,
110106
`| project_id | \`${body.project_id}\` |`,
111-
`| schema | ${body.schema_slot} |`,
112107
];
113108

114109
if (body.site_url) {
@@ -117,6 +112,9 @@ export async function handleBundleDeploy(args: {
117112
if (body.subdomain_url) {
118113
lines.push(`| subdomain | ${body.subdomain_url} |`);
119114
}
115+
if (body.deployment_id) {
116+
lines.push(`| deployment_id | \`${body.deployment_id}\` |`);
117+
}
120118

121119
if (body.functions && body.functions.length > 0) {
122120
lines.push(``);
@@ -126,8 +124,5 @@ export async function handleBundleDeploy(args: {
126124
}
127125
}
128126

129-
lines.push(``);
130-
lines.push(`Keys saved to local key store.`);
131-
132127
return { content: [{ type: "text", text: lines.join("\n") }] };
133128
}

sync.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function parseCliCommands(): string[] {
6666
}
6767
if (existsSync(join(__dirname, "cli/lib/deploy.mjs"))) cmds.push("deploy");
6868
if (existsSync(join(__dirname, "cli/lib/init.mjs"))) cmds.push("init");
69+
if (existsSync(join(__dirname, "cli/lib/status.mjs"))) cmds.push("status");
6970
return cmds.sort();
7071
}
7172

@@ -79,6 +80,7 @@ function parseOpenClawCommands(): string[] {
7980
}
8081
if (existsSync(join(__dirname, "openclaw/scripts/deploy.mjs"))) cmds.push("deploy");
8182
if (existsSync(join(__dirname, "openclaw/scripts/init.mjs"))) cmds.push("init");
83+
if (existsSync(join(__dirname, "openclaw/scripts/status.mjs"))) cmds.push("status");
8284
return cmds.sort();
8385
}
8486

@@ -133,8 +135,9 @@ interface Capability {
133135
}
134136

135137
const SURFACE: Capability[] = [
136-
// ── Init (local-only) ──────────────────────────────────────────────────
138+
// ── Init / status (local-only) ──────────────────────────────────────────
137139
{ id: "init", endpoint: "(local)", mcp: null, cli: "init", openclaw: "init" },
140+
{ id: "status", endpoint: "(local)", mcp: null, cli: "status", openclaw: "status" },
138141

139142
// ── Project lifecycle ────────────────────────────────────────────────────
140143
{ id: "get_quote", endpoint: "POST /projects/v1/quote", mcp: "get_quote", cli: "projects:quote", openclaw: "projects:quote" },

0 commit comments

Comments
 (0)