run402 1.12.3 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.mjs CHANGED
@@ -20,6 +20,7 @@ Usage:
20
20
 
21
21
  Commands:
22
22
  init Set up allowance, funding, and check tier status
23
+ status Show full account state (allowance, balance, tier, projects)
23
24
  allowance Manage your agent allowance (create, fund, balance, status)
24
25
  tier Manage tier subscription (status, set)
25
26
  projects Manage projects (provision, list, query, inspect, delete)
@@ -65,7 +66,12 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
65
66
  switch (cmd) {
66
67
  case "init": {
67
68
  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));
69
75
  break;
70
76
  }
71
77
  case "allowance": {
package/lib/deploy.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  import { readFileSync } from "fs";
2
- import { API, allowanceAuthHeaders, saveProject, setActiveProjectId } from "./config.mjs";
2
+ import { dirname, resolve } from "path";
3
+ import { API, allowanceAuthHeaders, findProject } from "./config.mjs";
4
+ import { resolveFilePathsInManifest } from "./manifest.mjs";
3
5
 
4
- const HELP = `run402 deploy — Deploy a full-stack app or static site on Run402
6
+ const HELP = `run402 deploy — Deploy to an existing project on Run402
5
7
 
6
8
  Usage:
7
9
  run402 deploy [options]
@@ -9,11 +11,12 @@ Usage:
9
11
 
10
12
  Options:
11
13
  --manifest <file> Path to manifest JSON file (default: read from stdin)
14
+ --project <id> Project ID to deploy to (default: active project)
12
15
  --help, -h Show this help message
13
16
 
14
17
  Manifest format (JSON):
15
18
  {
16
- "name": "my-app",
19
+ "project_id": "prj_...",
17
20
  "migrations": "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false)",
18
21
  "rls": {
19
22
  "template": "public_read_write",
@@ -24,11 +27,21 @@ Manifest format (JSON):
24
27
  "name": "my-fn",
25
28
  "code": "export default async (req) => new Response('ok')"
26
29
  }],
27
- "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
+ ],
28
34
  "subdomain": "my-app"
29
35
  }
30
36
 
31
- All fields except "name" are optional.
37
+ project_id is required (provision first with 'run402 provision').
38
+ All other fields are optional.
39
+
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.
32
45
 
33
46
  RLS templates:
34
47
  user_owns_rows — users see only their rows (requires owner_column per table)
@@ -40,16 +53,18 @@ Manifest format (JSON):
40
53
 
41
54
  Examples:
42
55
  run402 deploy --manifest app.json
56
+ run402 deploy --manifest app.json --project prj_123_1
43
57
  cat app.json | run402 deploy
44
58
 
45
59
  Prerequisites:
46
60
  - run402 init Set up allowance and funding
47
61
  - run402 tier set prototype Subscribe to a tier
62
+ - run402 provision Provision a project first
48
63
 
49
64
  Notes:
50
65
  - 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
66
+ - Provision a project first with 'run402 provision', then deploy to it
67
+ - Use 'run402 projects list' to see all provisioned projects
53
68
  `;
54
69
 
55
70
  async function readStdin() {
@@ -59,25 +74,32 @@ async function readStdin() {
59
74
  }
60
75
 
61
76
  export async function run(args) {
62
- const opts = { manifest: null };
77
+ const opts = { manifest: null, project: null };
63
78
  for (let i = 0; i < args.length; i++) {
64
79
  if (args[i] === "--help" || args[i] === "-h") { console.log(HELP); process.exit(0); }
65
80
  if (args[i] === "--manifest" && args[i + 1]) opts.manifest = args[++i];
81
+ if (args[i] === "--project" && args[i + 1]) opts.project = args[++i];
82
+ }
83
+
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)));
87
+
88
+ // --project flag overrides manifest's project_id
89
+ if (opts.project) manifest.project_id = opts.project;
90
+
91
+ // If no project_id in manifest, use active project
92
+ if (!manifest.project_id) {
93
+ const { id } = findProject(null);
94
+ manifest.project_id = id;
66
95
  }
67
96
 
68
- const manifest = opts.manifest ? JSON.parse(readFileSync(opts.manifest, "utf-8")) : JSON.parse(await readStdin());
97
+ // Remove legacy 'name' field if present
98
+ delete manifest.name;
69
99
 
70
100
  const authHeaders = allowanceAuthHeaders("/deploy/v1");
71
101
  const res = await fetch(`${API}/deploy/v1`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, body: JSON.stringify(manifest) });
72
102
  const result = await res.json();
73
103
  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
- }
82
104
  console.log(JSON.stringify(result, null, 2));
83
105
  }
package/lib/init.mjs CHANGED
@@ -5,10 +5,27 @@ import { mkdirSync } from "fs";
5
5
  const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
6
6
  const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
7
7
 
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
+
8
24
  function short(addr) { return addr.slice(0, 6) + "..." + addr.slice(-4); }
9
25
  function line(label, value) { console.log(` ${label.padEnd(10)} ${value}`); }
10
26
 
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); }
12
29
  console.log();
13
30
 
14
31
  // 1. Config directory
@@ -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
+ }
@@ -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
+ });
package/lib/projects.mjs CHANGED
@@ -16,7 +16,8 @@ Subcommands:
16
16
  usage <id> Show compute/storage usage for a project
17
17
  schema <id> Inspect the database schema
18
18
  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)` : ""}
20
21
 
21
22
  Examples:
22
23
  run402 projects quote
@@ -142,6 +143,25 @@ async function use(projectId) {
142
143
  console.log(JSON.stringify({ status: "ok", active_project_id: projectId }));
143
144
  }
144
145
 
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
+
145
165
  async function deleteProject(projectId) {
146
166
  const p = findProject(projectId);
147
167
  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) {
171
191
  case "schema": await schema(args[0]); break;
172
192
  case "rls": await rls(args[0], args[1], args[2]); break;
173
193
  case "delete": await deleteProject(args[0]); break;
194
+ case "pin": await pin(args[0]); break;
174
195
  default:
175
196
  console.error(`Unknown subcommand: ${sub}\n`);
176
197
  console.log(HELP);
package/lib/sites.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
+ import { dirname, resolve } from "path";
2
3
  import { API, allowanceAuthHeaders, resolveProjectId, updateProject } from "./config.mjs";
4
+ import { resolveFilePathsInManifest } from "./manifest.mjs";
3
5
 
4
6
  const HELP = `run402 sites — Deploy and manage static sites
5
7
 
@@ -22,10 +24,16 @@ Manifest format (JSON):
22
24
  {
23
25
  "files": [
24
26
  { "file": "index.html", "data": "<html>...</html>" },
25
- { "file": "style.css", "data": "body { margin: 0; }" }
27
+ { "file": "style.css", "path": "./dist/style.css" }
26
28
  ]
27
29
  }
28
30
 
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
+
29
37
  Examples:
30
38
  run402 sites deploy --manifest site.json
31
39
  run402 sites status dpl_abc123
@@ -51,7 +59,9 @@ async function deploy(args) {
51
59
  if (args[i] === "--target" && args[i + 1]) opts.target = args[++i];
52
60
  }
53
61
  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)));
55
65
  const body = { files: manifest.files, project: projectId };
56
66
  if (opts.target) body.target = opts.target;
57
67
 
package/lib/status.mjs ADDED
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "1.12.3",
3
+ "version": "1.13.1",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 micropayments.",
5
5
  "type": "module",
6
6
  "bin": {