Skip to content

Commit d4d6155

Browse files
MajorTalclaude
andcommitted
Add incremental deploy (inherit) and public storage URLs
- deploy_site and bundle_deploy accept optional inherit: true to copy unchanged files from the previous deployment (server-side S3 CopyObject) - upload_file shows public URL when present in API response - CLI sites deploy gains --inherit flag; deploy manifest supports inherit field - Unit tests for all three changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f4cfeb2 commit d4d6155

14 files changed

Lines changed: 377 additions & 9 deletions

File tree

cli/lib/deploy.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ Manifest format (JSON):
3232
{ "file": "index.html", "data": "<html>...</html>" },
3333
{ "file": "style.css", "path": "./dist/style.css" }
3434
],
35-
"subdomain": "my-app"
35+
"subdomain": "my-app",
36+
"inherit": true
3637
}
3738
3839
project_id is required (provision first with 'run402 provision').
3940
All other fields are optional.
41+
inherit: copy unchanged site files from previous deployment (only upload changed files).
4042
4143
Migrations can be inline or read from a file:
4244
"migrations": "CREATE TABLE ..." ← inline SQL

cli/lib/sites.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Options (deploy):
1818
--manifest <file> Path to manifest JSON file (or read from stdin)
1919
--project <id> Project ID (defaults to active project)
2020
--target <target> Deployment target (e.g. 'production')
21+
--inherit Copy unchanged files from the previous deployment (only upload changed files)
2122
--help, -h Show this help message
2223
2324
Manifest format (JSON):
@@ -51,19 +52,21 @@ async function readStdin() {
5152
}
5253

5354
async function deploy(args) {
54-
const opts = { manifest: null, project: undefined, target: undefined };
55+
const opts = { manifest: null, project: undefined, target: undefined, inherit: false };
5556
for (let i = 0; i < args.length; i++) {
5657
if (args[i] === "--help" || args[i] === "-h") { console.log(HELP); process.exit(0); }
5758
if (args[i] === "--manifest" && args[i + 1]) opts.manifest = args[++i];
5859
if (args[i] === "--project" && args[i + 1]) opts.project = args[++i];
5960
if (args[i] === "--target" && args[i + 1]) opts.target = args[++i];
61+
if (args[i] === "--inherit") opts.inherit = true;
6062
}
6163
const projectId = resolveProjectId(opts.project);
6264
const raw = opts.manifest ? readFileSync(opts.manifest, "utf-8") : await readStdin();
6365
const manifest = JSON.parse(raw);
6466
if (opts.manifest) resolveFilePathsInManifest(manifest, dirname(resolve(opts.manifest)));
6567
const body = { files: manifest.files, project: projectId };
6668
if (opts.target) body.target = opts.target;
69+
if (opts.inherit) body.inherit = true;
6770

6871
const authHeaders = allowanceAuthHeaders("/deployments/v1");
6972
const res = await fetch(`${API}/deployments/v1`, {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-03-31
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
## Context
2+
3+
The backend already implements incremental deploys. When `inherit: true` is sent in the deploy request body, the server looks up the most recent deployment for that project and copies all files not present in the new upload via S3 CopyObject. This is server-side and instant - no extra bandwidth or time. The public repo tools just need to pass this flag through.
4+
5+
The upload response also now includes a `url` field (public URL) that the `upload_file` tool should display.
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
- Expose `inherit` as an optional boolean on `deploy_site` and `bundle_deploy` MCP tools
11+
- Add `--inherit` CLI flag for `sites deploy`
12+
- Show `url` in `upload_file` response when the API returns it
13+
- Keep the parameter optional - default behavior (no inherit) is unchanged
14+
15+
**Non-Goals:**
16+
- Diffing files client-side to determine what changed (the server handles this)
17+
- Any new API endpoints or tools
18+
- Public storage URL as a separate tool (it's just a field in the upload response)
19+
20+
## Decisions
21+
22+
### 1. Optional boolean parameter, defaults to undefined (not sent)
23+
24+
When `inherit` is omitted, the request body doesn't include it, preserving backward compatibility. When `true`, it's included. No need to send `false` explicitly.
25+
26+
### 2. No changes to deploy CLI module
27+
28+
`cli/lib/deploy.mjs` already passes the entire manifest JSON through to `/deploy/v1`. If the manifest includes `"inherit": true`, it's sent automatically. Only the help text needs updating to document this.
29+
30+
### 3. Show `url` field in upload_file only when present
31+
32+
The `url` field is new in the API response. Display it in the MCP tool output when present, skip it when absent (backward compatible with older server versions).
33+
34+
## Risks / Trade-offs
35+
36+
- **[Older server versions]** If someone runs this MCP version against an older gateway that doesn't support `inherit`, the server ignores unknown fields - no breakage. → No mitigation needed.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Why
2+
3+
The Run402 gateway now supports incremental deploys via `inherit: true` on both `POST /deployments/v1` and `POST /deploy/v1`. When set, only changed files need uploading - unchanged files are copied server-side from the previous deployment via S3 CopyObject (instant). This is already live on the backend but the MCP tools, CLI, and OpenClaw don't expose the parameter yet, so agents always re-upload every file on every deploy.
4+
5+
Additionally, the upload response now includes a `url` field with a public URL (`/storage/v1/public/:project_id/:bucket/*`), but the `upload_file` MCP tool doesn't display it.
6+
7+
## What Changes
8+
9+
- Add optional `inherit` boolean to `deploy_site` MCP tool schema and pass it to the API
10+
- Add optional `inherit` boolean to `bundle_deploy` MCP tool schema and pass it to the API
11+
- Add `--inherit` flag to CLI `sites deploy` command
12+
- CLI `deploy` already passes the full manifest through, so `inherit: true` in the manifest works automatically - just needs documentation
13+
- Show `url` field in `upload_file` MCP tool response when present
14+
- Update CLI `sites deploy` help text to document the `--inherit` flag
15+
16+
## Capabilities
17+
18+
### New Capabilities
19+
20+
_None - this modifies existing capabilities._
21+
22+
### Modified Capabilities
23+
24+
- `incremental-deploy`: Add `inherit` parameter to deploy_site and bundle_deploy tools, and display public URL in upload_file response
25+
26+
## Impact
27+
28+
- **MCP server** (`src/tools/`): Modified `deploy-site.ts`, `bundle-deploy.ts`, `upload-file.ts`
29+
- **CLI** (`cli/lib/`): Modified `sites.mjs` (add `--inherit` flag), `deploy.mjs` (help text only)
30+
- **OpenClaw**: No changes - shims re-export from CLI
31+
- **Sync test**: No changes - no new tools, just new parameters on existing tools
32+
- **Dependencies**: None
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Deploy a static site
4+
5+
The `deploy_site` tool SHALL accept an optional `inherit` boolean parameter. When `true`, the server copies unchanged files from the previous deployment. Only changed/new files need to be included in the `files` array.
6+
7+
#### Scenario: Deploy with inherit enabled
8+
- **WHEN** the user calls `deploy_site` with `inherit: true` and a partial file list
9+
- **THEN** the tool sends `inherit: true` in the POST body to `/deployments/v1`
10+
- **AND** the server copies missing files from the previous deployment
11+
12+
#### Scenario: Deploy without inherit (default)
13+
- **WHEN** the user calls `deploy_site` without the `inherit` parameter
14+
- **THEN** the tool does NOT include `inherit` in the POST body
15+
- **AND** behavior is unchanged from before
16+
17+
### Requirement: Bundle deploy with inherit
18+
19+
The `bundle_deploy` tool SHALL accept an optional `inherit` boolean parameter, passed through to the deploy API.
20+
21+
#### Scenario: Bundle deploy with inherit enabled
22+
- **WHEN** the user calls `bundle_deploy` with `inherit: true` and a partial file list
23+
- **THEN** the tool sends `inherit: true` in the POST body to `/deploy/v1`
24+
25+
#### Scenario: Bundle deploy without inherit (default)
26+
- **WHEN** the user calls `bundle_deploy` without the `inherit` parameter
27+
- **THEN** the tool does NOT include `inherit` in the POST body
28+
29+
### Requirement: Upload file shows public URL
30+
31+
The `upload_file` tool SHALL display the `url` field from the API response when present.
32+
33+
#### Scenario: Upload response includes public URL
34+
- **WHEN** the upload API returns a response with a `url` field
35+
- **THEN** the tool displays the public URL in the output
36+
37+
#### Scenario: Upload response without URL (backward compat)
38+
- **WHEN** the upload API returns a response without a `url` field
39+
- **THEN** the tool displays the response without the URL (no error)
40+
41+
### Requirement: CLI sites deploy with --inherit flag
42+
43+
The CLI `sites deploy` command SHALL accept an optional `--inherit` flag that sends `inherit: true` in the request body.
44+
45+
#### Scenario: CLI deploy with --inherit
46+
- **WHEN** the user runs `run402 sites deploy --manifest site.json --inherit`
47+
- **THEN** the CLI includes `inherit: true` in the POST body
48+
49+
### Requirement: CLI deploy manifest supports inherit
50+
51+
The CLI `deploy` command SHALL pass through `inherit` from the manifest JSON to the API.
52+
53+
#### Scenario: Manifest includes inherit
54+
- **WHEN** the manifest JSON contains `"inherit": true`
55+
- **THEN** the CLI passes it through in the POST body (already works, documentation only)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
## 1. MCP Tools
2+
3+
- [x] 1.1 Add optional `inherit` boolean to `deploySiteSchema` in `src/tools/deploy-site.ts` and pass it in the request body
4+
- [x] 1.2 Add optional `inherit` boolean to `bundleDeploySchema` in `src/tools/bundle-deploy.ts` and pass it in the request body
5+
- [x] 1.3 Show `url` field in `upload_file` response in `src/tools/upload-file.ts` when present
6+
7+
## 2. Unit Tests
8+
9+
- [x] 2.1 Add test to `src/tools/upload-file.test.ts` for public URL in response
10+
- [x] 2.2 Add test to `deploy-site` for passing `inherit: true` in request body (new test file or extend existing)
11+
- [x] 2.3 Add test to `bundle-deploy` for passing `inherit: true` in request body (new test file or extend existing)
12+
13+
## 3. CLI
14+
15+
- [x] 3.1 Add `--inherit` flag to `sites deploy` in `cli/lib/sites.mjs` and pass in request body
16+
- [x] 3.2 Update help text in `cli/lib/deploy.mjs` to document `inherit` field in manifest
17+
18+
## 4. Validation
19+
20+
- [x] 4.1 Run `npm test` and verify all tests pass

src/tools/bundle-deploy.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, beforeEach, afterEach, mock } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { tmpdir } from "node:os";
6+
7+
mock.module("../allowance-auth.js", {
8+
namedExports: {
9+
requireAllowanceAuth: () => ({ headers: { "SIGN-IN-WITH-X": "dGVzdA==" } }),
10+
},
11+
});
12+
13+
mock.module("../paid-fetch.js", {
14+
namedExports: {
15+
paidApiRequest: async (path: string, opts: any) => {
16+
const { apiRequest } = await import("../client.js");
17+
return apiRequest(path, opts);
18+
},
19+
},
20+
});
21+
22+
const { handleBundleDeploy } = await import("./bundle-deploy.js");
23+
24+
const originalFetch = globalThis.fetch;
25+
let tempDir: string;
26+
27+
beforeEach(() => {
28+
tempDir = mkdtempSync(join(tmpdir(), "run402-bundle-deploy-test-"));
29+
process.env.RUN402_CONFIG_DIR = tempDir;
30+
process.env.RUN402_API_BASE = "https://test-api.run402.com";
31+
32+
const store = {
33+
projects: {
34+
"proj-001": { anon_key: "ak-123", service_key: "sk-456" },
35+
},
36+
};
37+
writeFileSync(join(tempDir, "projects.json"), JSON.stringify(store));
38+
});
39+
40+
afterEach(() => {
41+
globalThis.fetch = originalFetch;
42+
rmSync(tempDir, { recursive: true, force: true });
43+
delete process.env.RUN402_CONFIG_DIR;
44+
delete process.env.RUN402_API_BASE;
45+
});
46+
47+
describe("bundle_deploy tool", () => {
48+
it("sends inherit in body when true", async () => {
49+
let capturedBody: string | undefined;
50+
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
51+
capturedBody = init?.body as string;
52+
return new Response(
53+
JSON.stringify({ project_id: "proj-001", site_url: "https://dpl-001.sites.run402.com" }),
54+
{ status: 201, headers: { "Content-Type": "application/json" } },
55+
);
56+
}) as typeof fetch;
57+
58+
await handleBundleDeploy({
59+
project_id: "proj-001",
60+
files: [{ file: "style.css", data: "body{}" }],
61+
inherit: true,
62+
});
63+
64+
const parsed = JSON.parse(capturedBody!);
65+
assert.equal(parsed.inherit, true);
66+
});
67+
68+
it("does not send inherit when omitted", async () => {
69+
let capturedBody: string | undefined;
70+
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
71+
capturedBody = init?.body as string;
72+
return new Response(
73+
JSON.stringify({ project_id: "proj-001", site_url: "https://dpl-002.sites.run402.com" }),
74+
{ status: 201, headers: { "Content-Type": "application/json" } },
75+
);
76+
}) as typeof fetch;
77+
78+
await handleBundleDeploy({
79+
project_id: "proj-001",
80+
files: [{ file: "index.html", data: "<html></html>" }],
81+
});
82+
83+
const parsed = JSON.parse(capturedBody!);
84+
assert.equal(parsed.inherit, undefined);
85+
});
86+
});

src/tools/bundle-deploy.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export const bundleDeploySchema = {
5959
.string()
6060
.optional()
6161
.describe("Custom subdomain to claim (e.g. 'myapp' → myapp.run402.com)"),
62+
inherit: z
63+
.boolean()
64+
.optional()
65+
.describe("If true, copy unchanged site files from the previous deployment. Only include changed/new files."),
6266
};
6367

6468
export async function handleBundleDeploy(args: {
@@ -69,6 +73,7 @@ export async function handleBundleDeploy(args: {
6973
functions?: Array<{ name: string; code: string; config?: { timeout?: number; memory?: number }; schedule?: string }>;
7074
files?: Array<{ file: string; data: string; encoding?: string }>;
7175
subdomain?: string;
76+
inherit?: boolean;
7277
}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
7378
const projectId = args.project_id;
7479
if (!projectId) return projectNotFound("(none — project_id is required)");
@@ -89,6 +94,7 @@ export async function handleBundleDeploy(args: {
8994
functions: args.functions,
9095
files: args.files,
9196
subdomain: args.subdomain,
97+
...(args.inherit ? { inherit: true } : {}),
9298
},
9399
});
94100

src/tools/deploy-site.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, it, beforeEach, afterEach, mock } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { tmpdir } from "node:os";
6+
7+
mock.module("../allowance-auth.js", {
8+
namedExports: {
9+
requireAllowanceAuth: () => ({ headers: { "SIGN-IN-WITH-X": "dGVzdA==" } }),
10+
},
11+
});
12+
13+
const { handleDeploySite } = await import("./deploy-site.js");
14+
15+
const originalFetch = globalThis.fetch;
16+
let tempDir: string;
17+
18+
beforeEach(() => {
19+
tempDir = mkdtempSync(join(tmpdir(), "run402-deploy-site-test-"));
20+
process.env.RUN402_CONFIG_DIR = tempDir;
21+
process.env.RUN402_API_BASE = "https://test-api.run402.com";
22+
});
23+
24+
afterEach(() => {
25+
globalThis.fetch = originalFetch;
26+
rmSync(tempDir, { recursive: true, force: true });
27+
delete process.env.RUN402_CONFIG_DIR;
28+
delete process.env.RUN402_API_BASE;
29+
});
30+
31+
describe("deploy_site tool", () => {
32+
it("sends inherit in body when true", async () => {
33+
let capturedBody: string | undefined;
34+
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
35+
capturedBody = init?.body as string;
36+
return new Response(
37+
JSON.stringify({ deployment_id: "dpl_001", url: "https://dpl-001.sites.run402.com" }),
38+
{ status: 201, headers: { "Content-Type": "application/json" } },
39+
);
40+
}) as typeof fetch;
41+
42+
await handleDeploySite({
43+
project: "proj-001",
44+
files: [{ file: "style.css", data: "body{}" }],
45+
inherit: true,
46+
});
47+
48+
const parsed = JSON.parse(capturedBody!);
49+
assert.equal(parsed.inherit, true);
50+
});
51+
52+
it("does not send inherit when omitted", async () => {
53+
let capturedBody: string | undefined;
54+
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
55+
capturedBody = init?.body as string;
56+
return new Response(
57+
JSON.stringify({ deployment_id: "dpl_002", url: "https://dpl-002.sites.run402.com" }),
58+
{ status: 201, headers: { "Content-Type": "application/json" } },
59+
);
60+
}) as typeof fetch;
61+
62+
await handleDeploySite({
63+
project: "proj-001",
64+
files: [{ file: "index.html", data: "<html></html>" }],
65+
});
66+
67+
const parsed = JSON.parse(capturedBody!);
68+
assert.equal(parsed.inherit, undefined);
69+
});
70+
});

0 commit comments

Comments
 (0)