Skip to content

Commit 5dac13a

Browse files
MajorTalclaude
andcommitted
Add admin pin command and manifest path field support
- Add `pin_project` admin command (MCP, CLI, OpenClaw) gated by server-side allowance address whitelist; CLI help hidden behind RUN402_ADMIN env var - Add `path` field alternative to `data` in deploy/sites manifests so files can be read from disk instead of inlined as JSON strings - Update formatApiError to detect admin_required 403 responses Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 72100a9 commit 5dac13a

10 files changed

Lines changed: 259 additions & 8 deletions

File tree

cli-e2e.test.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,28 @@ describe("CLI e2e happy path", () => {
516516
assert.ok(captured().includes("prj_test123"), "should return project info");
517517
});
518518

519+
it("deploy with path fields in manifest", async () => {
520+
const { run } = await import("./cli/lib/deploy.mjs");
521+
const { writeFileSync: wf, mkdirSync } = await import("node:fs");
522+
// Create a dist/ subdirectory with files
523+
const distDir = join(tempDir, "dist");
524+
mkdirSync(distDir, { recursive: true });
525+
wf(join(distDir, "index.html"), "<h1>Built</h1>");
526+
wf(join(distDir, "logo.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
527+
// Manifest uses path fields resolved relative to manifest location
528+
const manifestPath = join(tempDir, "path-manifest.json");
529+
wf(manifestPath, JSON.stringify({
530+
files: [
531+
{ file: "index.html", path: "dist/index.html" },
532+
{ file: "logo.png", path: "dist/logo.png" },
533+
],
534+
}));
535+
captureStart();
536+
await run(["--manifest", manifestPath, "--project", "prj_test123"]);
537+
captureStop();
538+
assert.ok(captured().includes("prj_test123"), "should deploy with resolved paths");
539+
});
540+
519541
// ── Functions ───────────────────────────────────────────────────────────
520542

521543
it("functions deploy", async () => {

cli/lib/deploy.mjs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { readFileSync } from "fs";
2+
import { dirname, resolve } from "path";
23
import { API, allowanceAuthHeaders, findProject } from "./config.mjs";
4+
import { resolveFilePathsInManifest } from "./manifest.mjs";
35

46
const HELP = `run402 deploy — Deploy to an existing project on Run402
57
@@ -25,13 +27,22 @@ Manifest format (JSON):
2527
"name": "my-fn",
2628
"code": "export default async (req) => new Response('ok')"
2729
}],
28-
"files": [{ "file": "index.html", "data": "<html>...</html>" }],
30+
"files": [
31+
{ "file": "index.html", "data": "<html>...</html>" },
32+
{ "file": "style.css", "path": "./dist/style.css" }
33+
],
2934
"subdomain": "my-app"
3035
}
3136
3237
project_id is required (provision first with 'run402 provision').
3338
All other fields are optional.
3439
40+
Files can use either inline "data" or a local "path":
41+
{ "file": "index.html", "data": "<html>...</html>" } ← inline content
42+
{ "file": "style.css", "path": "./dist/style.css" } ← read from disk
43+
Paths are resolved relative to the manifest file's directory.
44+
Binary files (images, fonts, etc.) are auto-detected and base64-encoded.
45+
3546
RLS templates:
3647
user_owns_rows — users see only their rows (requires owner_column per table)
3748
public_read — anyone reads, authenticated users write
@@ -70,7 +81,9 @@ export async function run(args) {
7081
if (args[i] === "--project" && args[i + 1]) opts.project = args[++i];
7182
}
7283

73-
const manifest = opts.manifest ? JSON.parse(readFileSync(opts.manifest, "utf-8")) : JSON.parse(await readStdin());
84+
const raw = opts.manifest ? readFileSync(opts.manifest, "utf-8") : await readStdin();
85+
const manifest = JSON.parse(raw);
86+
if (opts.manifest) resolveFilePathsInManifest(manifest, dirname(resolve(opts.manifest)));
7487

7588
// --project flag overrides manifest's project_id
7689
if (opts.project) manifest.project_id = opts.project;

cli/lib/manifest.mjs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { readFileSync } from "fs";
2+
import { resolve, extname } from "path";
3+
4+
const TEXT_EXTS = new Set([
5+
".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
6+
".json", ".svg", ".xml", ".txt", ".md", ".yaml", ".yml", ".toml", ".csv",
7+
]);
8+
9+
/**
10+
* Resolve `path` fields in a manifest's files array.
11+
*
12+
* For each entry that has `path` instead of `data`, reads the file from disk
13+
* and sets `data` + `encoding`. Paths are resolved relative to `baseDir`.
14+
*
15+
* Entries with `data` already set are left untouched.
16+
*
17+
* @param {object} manifest Parsed manifest JSON (mutated in place)
18+
* @param {string} baseDir Directory to resolve relative paths from
19+
* @returns {object} The same manifest object
20+
*/
21+
export function resolveFilePathsInManifest(manifest, baseDir) {
22+
if (!Array.isArray(manifest.files)) return manifest;
23+
24+
for (const entry of manifest.files) {
25+
if (!entry.path || entry.data !== undefined) continue;
26+
27+
const abs = resolve(baseDir, entry.path);
28+
const ext = extname(abs).toLowerCase();
29+
const isText = TEXT_EXTS.has(ext);
30+
31+
if (isText) {
32+
entry.data = readFileSync(abs, "utf-8");
33+
} else {
34+
entry.data = readFileSync(abs).toString("base64");
35+
entry.encoding = "base64";
36+
}
37+
38+
// If no explicit file (deploy target name), use the path value
39+
if (!entry.file) entry.file = entry.path;
40+
41+
delete entry.path;
42+
}
43+
44+
return manifest;
45+
}

cli/lib/manifest.test.mjs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, it, before, after } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { tmpdir } from "node:os";
6+
import { resolveFilePathsInManifest } from "./manifest.mjs";
7+
8+
let tempDir;
9+
10+
before(() => {
11+
tempDir = mkdtempSync(join(tmpdir(), "manifest-test-"));
12+
// Create test files
13+
writeFileSync(join(tempDir, "index.html"), "<!DOCTYPE html><html><body>Hello</body></html>");
14+
writeFileSync(join(tempDir, "style.css"), "body { margin: 0; }");
15+
writeFileSync(join(tempDir, "logo.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); // PNG header
16+
});
17+
18+
after(() => {
19+
rmSync(tempDir, { recursive: true, force: true });
20+
});
21+
22+
describe("resolveFilePathsInManifest", () => {
23+
it("resolves path to inline data for text files", () => {
24+
const manifest = {
25+
files: [{ file: "index.html", path: "index.html" }],
26+
};
27+
resolveFilePathsInManifest(manifest, tempDir);
28+
assert.equal(manifest.files[0].data, "<!DOCTYPE html><html><body>Hello</body></html>");
29+
assert.equal(manifest.files[0].path, undefined, "path field should be removed");
30+
assert.equal(manifest.files[0].encoding, undefined, "text files should not set encoding");
31+
});
32+
33+
it("auto-detects binary files and base64-encodes them", () => {
34+
const manifest = {
35+
files: [{ file: "logo.png", path: "logo.png" }],
36+
};
37+
resolveFilePathsInManifest(manifest, tempDir);
38+
assert.equal(manifest.files[0].encoding, "base64");
39+
// Verify it's valid base64 that decodes to our PNG header
40+
const buf = Buffer.from(manifest.files[0].data, "base64");
41+
assert.equal(buf[0], 0x89);
42+
assert.equal(buf[1], 0x50); // 'P'
43+
});
44+
45+
it("leaves entries with existing data untouched", () => {
46+
const manifest = {
47+
files: [{ file: "index.html", data: "<h1>inline</h1>" }],
48+
};
49+
resolveFilePathsInManifest(manifest, tempDir);
50+
assert.equal(manifest.files[0].data, "<h1>inline</h1>");
51+
});
52+
53+
it("mixes path and data entries", () => {
54+
const manifest = {
55+
files: [
56+
{ file: "index.html", data: "<h1>inline</h1>" },
57+
{ file: "style.css", path: "style.css" },
58+
],
59+
};
60+
resolveFilePathsInManifest(manifest, tempDir);
61+
assert.equal(manifest.files[0].data, "<h1>inline</h1>");
62+
assert.equal(manifest.files[1].data, "body { margin: 0; }");
63+
assert.equal(manifest.files[1].path, undefined);
64+
});
65+
66+
it("uses path as file name when file is omitted", () => {
67+
const manifest = {
68+
files: [{ path: "style.css" }],
69+
};
70+
resolveFilePathsInManifest(manifest, tempDir);
71+
assert.equal(manifest.files[0].file, "style.css");
72+
assert.equal(manifest.files[0].data, "body { margin: 0; }");
73+
});
74+
75+
it("handles manifest with no files array", () => {
76+
const manifest = { migrations: "CREATE TABLE t (id int)" };
77+
resolveFilePathsInManifest(manifest, tempDir);
78+
assert.equal(manifest.files, undefined, "should not add a files array");
79+
});
80+
81+
it("throws on missing file", () => {
82+
const manifest = {
83+
files: [{ file: "missing.html", path: "does-not-exist.html" }],
84+
};
85+
assert.throws(
86+
() => resolveFilePathsInManifest(manifest, tempDir),
87+
/ENOENT/,
88+
);
89+
});
90+
});

cli/lib/projects.mjs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ Subcommands:
1616
usage <id> Show compute/storage usage for a project
1717
schema <id> Inspect the database schema
1818
rls <id> <template> <tables_json> Apply Row-Level Security policies
19-
delete <id> Delete a project and remove it from local state
19+
delete <id> Delete a project and remove it from local state${process.env.RUN402_ADMIN ? `
20+
pin <id> [admin] Pin a project (prevents expiry/GC)` : ""}
2021
2122
Examples:
2223
run402 projects quote
@@ -142,6 +143,25 @@ async function use(projectId) {
142143
console.log(JSON.stringify({ status: "ok", active_project_id: projectId }));
143144
}
144145

146+
async function pin(projectId) {
147+
if (!projectId) { console.error(JSON.stringify({ status: "error", message: "Usage: run402 projects pin <project_id>" })); process.exit(1); }
148+
const authHeaders = allowanceAuthHeaders(`/projects/v1/admin/${projectId}/pin`);
149+
const res = await fetch(`${API}/projects/v1/admin/${projectId}/pin`, {
150+
method: "POST",
151+
headers: { ...authHeaders },
152+
});
153+
const data = await res.json();
154+
if (!res.ok) {
155+
if (res.status === 403 && data.admin_required) {
156+
console.error(JSON.stringify({ status: "error", message: "This command requires admin access." }));
157+
} else {
158+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
159+
}
160+
process.exit(1);
161+
}
162+
console.log(JSON.stringify(data, null, 2));
163+
}
164+
145165
async function deleteProject(projectId) {
146166
const p = findProject(projectId);
147167
const res = await fetch(`${API}/projects/v1/${projectId}`, { method: "DELETE", headers: { "Authorization": `Bearer ${p.service_key}` } });
@@ -171,6 +191,7 @@ export async function run(sub, args) {
171191
case "schema": await schema(args[0]); break;
172192
case "rls": await rls(args[0], args[1], args[2]); break;
173193
case "delete": await deleteProject(args[0]); break;
194+
case "pin": await pin(args[0]); break;
174195
default:
175196
console.error(`Unknown subcommand: ${sub}\n`);
176197
console.log(HELP);

cli/lib/sites.mjs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { readFileSync } from "fs";
2+
import { dirname, resolve } from "path";
23
import { API, allowanceAuthHeaders, resolveProjectId, updateProject } from "./config.mjs";
4+
import { resolveFilePathsInManifest } from "./manifest.mjs";
35

46
const HELP = `run402 sites — Deploy and manage static sites
57
@@ -22,10 +24,16 @@ Manifest format (JSON):
2224
{
2325
"files": [
2426
{ "file": "index.html", "data": "<html>...</html>" },
25-
{ "file": "style.css", "data": "body { margin: 0; }" }
27+
{ "file": "style.css", "path": "./dist/style.css" }
2628
]
2729
}
2830
31+
Files can use either inline "data" or a local "path":
32+
{ "file": "index.html", "data": "<html>...</html>" } ← inline content
33+
{ "file": "style.css", "path": "./dist/style.css" } ← read from disk
34+
Paths are resolved relative to the manifest file's directory.
35+
Binary files (images, fonts, etc.) are auto-detected and base64-encoded.
36+
2937
Examples:
3038
run402 sites deploy --manifest site.json
3139
run402 sites status dpl_abc123
@@ -51,7 +59,9 @@ async function deploy(args) {
5159
if (args[i] === "--target" && args[i + 1]) opts.target = args[++i];
5260
}
5361
const projectId = resolveProjectId(opts.project);
54-
const manifest = opts.manifest ? JSON.parse(readFileSync(opts.manifest, "utf-8")) : JSON.parse(await readStdin());
62+
const raw = opts.manifest ? readFileSync(opts.manifest, "utf-8") : await readStdin();
63+
const manifest = JSON.parse(raw);
64+
if (opts.manifest) resolveFilePathsInManifest(manifest, dirname(resolve(opts.manifest)));
5565
const body = { files: manifest.files, project: projectId };
5666
if (opts.target) body.target = opts.target;
5767

src/errors.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,13 @@ export function formatApiError(
6363
);
6464
break;
6565
case 403:
66-
lines.push(
67-
`\nNext step: The project lease may have expired. Use \`get_usage\` to check status, or \`set_tier\` to renew the lease.`,
68-
);
66+
if (body && body.admin_required) {
67+
lines.push(`\nThis command requires admin access.`);
68+
} else {
69+
lines.push(
70+
`\nNext step: The project lease may have expired. Use \`get_usage\` to check status, or \`set_tier\` to renew the lease.`,
71+
);
72+
}
6973
break;
7074
case 404:
7175
lines.push(

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { deleteSecretSchema, handleDeleteSecret } from "./tools/delete-secret.js
4343
// New tools — subdomains & projects
4444
import { listSubdomainsSchema, handleListSubdomains } from "./tools/list-subdomains.js";
4545
import { archiveProjectSchema, handleArchiveProject } from "./tools/archive-project.js";
46+
import { pinProjectSchema, handlePinProject } from "./tools/pin-project.js";
4647

4748
// New tools — billing
4849
import { checkBalanceSchema, handleCheckBalance } from "./tools/check-balance.js";
@@ -302,6 +303,15 @@ server.tool(
302303
async (args) => handleArchiveProject(args),
303304
);
304305

306+
// ─── Admin tools ─────────────────────────────────────────────────────────────
307+
308+
server.tool(
309+
"pin_project",
310+
"Pin a project so it is not garbage-collected or expired. Requires admin access.",
311+
pinProjectSchema,
312+
async (args) => handlePinProject(args),
313+
);
314+
305315
// ─── Billing & allowance tools ───────────────────────────────────────────────
306316

307317
server.tool(

src/tools/pin-project.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { z } from "zod";
2+
import { apiRequest } from "../client.js";
3+
import { formatApiError } from "../errors.js";
4+
import { requireAllowanceAuth } from "../allowance-auth.js";
5+
6+
export const pinProjectSchema = {
7+
project_id: z.string().describe("The project ID to pin"),
8+
};
9+
10+
export async function handlePinProject(
11+
args: { project_id: string },
12+
): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
13+
const auth = requireAllowanceAuth(`/projects/v1/admin/${args.project_id}/pin`);
14+
if ("error" in auth) return auth.error;
15+
16+
const res = await apiRequest(`/projects/v1/admin/${args.project_id}/pin`, {
17+
method: "POST",
18+
headers: { ...auth.headers },
19+
});
20+
21+
if (!res.ok) return formatApiError(res, "pinning project");
22+
23+
const body = res.body as { status: string; project_id: string; message?: string };
24+
25+
return {
26+
content: [
27+
{
28+
type: "text",
29+
text: `Project \`${args.project_id}\` pinned successfully.${body.message ? ` ${body.message}` : ""}`,
30+
},
31+
],
32+
};
33+
}

sync.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,9 @@ const SURFACE: Capability[] = [
213213
{ id: "delete_version", endpoint: "DELETE /projects/v1/admin/:id/versions/:version_id", mcp: "delete_version", cli: "apps:delete", openclaw: "apps:delete" },
214214
{ id: "get_app", endpoint: "GET /apps/v1/:version_id", mcp: "get_app", cli: "apps:inspect", openclaw: "apps:inspect" },
215215

216+
// ── Admin ──────────────────────────────────────────────────────────────
217+
{ id: "pin_project", endpoint: "POST /projects/v1/admin/:id/pin", mcp: "pin_project", cli: "projects:pin", openclaw: "projects:pin" },
218+
216219
// ── Tier management ────────────────────────────────────────────────────
217220
{ id: "tier_status", endpoint: "GET /tiers/v1/status", mcp: "tier_status", cli: "tier:status", openclaw: "tier:status" },
218221

0 commit comments

Comments
 (0)