run402 1.13.0 → 1.13.2

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/lib/deploy.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
+ import { dirname, resolve } from "path";
2
3
  import { API, allowanceAuthHeaders, findProject } from "./config.mjs";
4
+ import { resolveFilePathsInManifest, resolveMigrationsFile } from "./manifest.mjs";
3
5
 
4
6
  const HELP = `run402 deploy — Deploy to an existing project on Run402
5
7
 
@@ -15,7 +17,8 @@ Options:
15
17
  Manifest format (JSON):
16
18
  {
17
19
  "project_id": "prj_...",
18
- "migrations": "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false)",
20
+ "migrations": "CREATE TABLE items (...)",
21
+ "migrations_file": "setup.sql",
19
22
  "rls": {
20
23
  "template": "public_read_write",
21
24
  "tables": [{ "table": "items" }]
@@ -25,13 +28,30 @@ Manifest format (JSON):
25
28
  "name": "my-fn",
26
29
  "code": "export default async (req) => new Response('ok')"
27
30
  }],
28
- "files": [{ "file": "index.html", "data": "<html>...</html>" }],
31
+ "files": [
32
+ { "file": "index.html", "data": "<html>...</html>" },
33
+ { "file": "style.css", "path": "./dist/style.css" }
34
+ ],
29
35
  "subdomain": "my-app"
30
36
  }
31
37
 
32
38
  project_id is required (provision first with 'run402 provision').
33
39
  All other fields are optional.
34
40
 
41
+ Migrations can be inline or read from a file:
42
+ "migrations": "CREATE TABLE ..." ← inline SQL
43
+ "migrations_file": "setup.sql" ← read from disk
44
+ Use migrations_file when your SQL contains JSONB literals or other
45
+ characters that are painful to escape inside a JSON string.
46
+ Paths are resolved relative to the manifest file's directory.
47
+ If both are present, migrations_file wins.
48
+
49
+ Files can use either inline "data" or a local "path":
50
+ { "file": "index.html", "data": "<html>...</html>" } ← inline content
51
+ { "file": "style.css", "path": "./dist/style.css" } ← read from disk
52
+ Paths are resolved relative to the manifest file's directory.
53
+ Binary files (images, fonts, etc.) are auto-detected and base64-encoded.
54
+
35
55
  RLS templates:
36
56
  user_owns_rows — users see only their rows (requires owner_column per table)
37
57
  public_read — anyone reads, authenticated users write
@@ -70,7 +90,13 @@ export async function run(args) {
70
90
  if (args[i] === "--project" && args[i + 1]) opts.project = args[++i];
71
91
  }
72
92
 
73
- const manifest = opts.manifest ? JSON.parse(readFileSync(opts.manifest, "utf-8")) : JSON.parse(await readStdin());
93
+ const raw = opts.manifest ? readFileSync(opts.manifest, "utf-8") : await readStdin();
94
+ const manifest = JSON.parse(raw);
95
+ if (opts.manifest) {
96
+ const baseDir = dirname(resolve(opts.manifest));
97
+ resolveMigrationsFile(manifest, baseDir);
98
+ resolveFilePathsInManifest(manifest, baseDir);
99
+ }
74
100
 
75
101
  // --project flag overrides manifest's project_id
76
102
  if (opts.project) manifest.project_id = opts.project;
@@ -0,0 +1,62 @@
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
+ * If the manifest has `migrations_file` instead of (or in addition to) `migrations`,
11
+ * read the SQL from that file path and set `migrations` to its contents.
12
+ * `migrations_file` is resolved relative to `baseDir`.
13
+ *
14
+ * @param {object} manifest Parsed manifest JSON (mutated in place)
15
+ * @param {string} baseDir Directory to resolve relative paths from
16
+ * @returns {object} The same manifest object
17
+ */
18
+ export function resolveMigrationsFile(manifest, baseDir) {
19
+ if (!manifest.migrations_file) return manifest;
20
+ const abs = resolve(baseDir, manifest.migrations_file);
21
+ manifest.migrations = readFileSync(abs, "utf-8");
22
+ delete manifest.migrations_file;
23
+ return manifest;
24
+ }
25
+
26
+ /**
27
+ * Resolve `path` fields in a manifest's files array.
28
+ *
29
+ * For each entry that has `path` instead of `data`, reads the file from disk
30
+ * and sets `data` + `encoding`. Paths are resolved relative to `baseDir`.
31
+ *
32
+ * Entries with `data` already set are left untouched.
33
+ *
34
+ * @param {object} manifest Parsed manifest JSON (mutated in place)
35
+ * @param {string} baseDir Directory to resolve relative paths from
36
+ * @returns {object} The same manifest object
37
+ */
38
+ export function resolveFilePathsInManifest(manifest, baseDir) {
39
+ if (!Array.isArray(manifest.files)) return manifest;
40
+
41
+ for (const entry of manifest.files) {
42
+ if (!entry.path || entry.data !== undefined) continue;
43
+
44
+ const abs = resolve(baseDir, entry.path);
45
+ const ext = extname(abs).toLowerCase();
46
+ const isText = TEXT_EXTS.has(ext);
47
+
48
+ if (isText) {
49
+ entry.data = readFileSync(abs, "utf-8");
50
+ } else {
51
+ entry.data = readFileSync(abs).toString("base64");
52
+ entry.encoding = "base64";
53
+ }
54
+
55
+ // If no explicit file (deploy target name), use the path value
56
+ if (!entry.file) entry.file = entry.path;
57
+
58
+ delete entry.path;
59
+ }
60
+
61
+ return manifest;
62
+ }
@@ -0,0 +1,128 @@
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, resolveMigrationsFile } 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
+ writeFileSync(join(tempDir, "setup.sql"), "CREATE TABLE items (id serial PRIMARY KEY, data jsonb);\nINSERT INTO items (data) VALUES ('[{\"x\":0.5}]');");
17
+ });
18
+
19
+ after(() => {
20
+ rmSync(tempDir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("resolveFilePathsInManifest", () => {
24
+ it("resolves path to inline data for text files", () => {
25
+ const manifest = {
26
+ files: [{ file: "index.html", path: "index.html" }],
27
+ };
28
+ resolveFilePathsInManifest(manifest, tempDir);
29
+ assert.equal(manifest.files[0].data, "<!DOCTYPE html><html><body>Hello</body></html>");
30
+ assert.equal(manifest.files[0].path, undefined, "path field should be removed");
31
+ assert.equal(manifest.files[0].encoding, undefined, "text files should not set encoding");
32
+ });
33
+
34
+ it("auto-detects binary files and base64-encodes them", () => {
35
+ const manifest = {
36
+ files: [{ file: "logo.png", path: "logo.png" }],
37
+ };
38
+ resolveFilePathsInManifest(manifest, tempDir);
39
+ assert.equal(manifest.files[0].encoding, "base64");
40
+ // Verify it's valid base64 that decodes to our PNG header
41
+ const buf = Buffer.from(manifest.files[0].data, "base64");
42
+ assert.equal(buf[0], 0x89);
43
+ assert.equal(buf[1], 0x50); // 'P'
44
+ });
45
+
46
+ it("leaves entries with existing data untouched", () => {
47
+ const manifest = {
48
+ files: [{ file: "index.html", data: "<h1>inline</h1>" }],
49
+ };
50
+ resolveFilePathsInManifest(manifest, tempDir);
51
+ assert.equal(manifest.files[0].data, "<h1>inline</h1>");
52
+ });
53
+
54
+ it("mixes path and data entries", () => {
55
+ const manifest = {
56
+ files: [
57
+ { file: "index.html", data: "<h1>inline</h1>" },
58
+ { file: "style.css", path: "style.css" },
59
+ ],
60
+ };
61
+ resolveFilePathsInManifest(manifest, tempDir);
62
+ assert.equal(manifest.files[0].data, "<h1>inline</h1>");
63
+ assert.equal(manifest.files[1].data, "body { margin: 0; }");
64
+ assert.equal(manifest.files[1].path, undefined);
65
+ });
66
+
67
+ it("uses path as file name when file is omitted", () => {
68
+ const manifest = {
69
+ files: [{ path: "style.css" }],
70
+ };
71
+ resolveFilePathsInManifest(manifest, tempDir);
72
+ assert.equal(manifest.files[0].file, "style.css");
73
+ assert.equal(manifest.files[0].data, "body { margin: 0; }");
74
+ });
75
+
76
+ it("handles manifest with no files array", () => {
77
+ const manifest = { migrations: "CREATE TABLE t (id int)" };
78
+ resolveFilePathsInManifest(manifest, tempDir);
79
+ assert.equal(manifest.files, undefined, "should not add a files array");
80
+ });
81
+
82
+ it("throws on missing file", () => {
83
+ const manifest = {
84
+ files: [{ file: "missing.html", path: "does-not-exist.html" }],
85
+ };
86
+ assert.throws(
87
+ () => resolveFilePathsInManifest(manifest, tempDir),
88
+ /ENOENT/,
89
+ );
90
+ });
91
+ });
92
+
93
+ describe("resolveMigrationsFile", () => {
94
+ it("reads SQL from migrations_file and sets migrations", () => {
95
+ const manifest = { migrations_file: "setup.sql" };
96
+ resolveMigrationsFile(manifest, tempDir);
97
+ assert.ok(manifest.migrations.includes("CREATE TABLE items"));
98
+ assert.ok(manifest.migrations.includes('[{"x":0.5}]'), "should preserve JSON literals without escaping issues");
99
+ assert.equal(manifest.migrations_file, undefined, "migrations_file should be removed");
100
+ });
101
+
102
+ it("overwrites inline migrations when migrations_file is present", () => {
103
+ const manifest = { migrations: "SELECT 1", migrations_file: "setup.sql" };
104
+ resolveMigrationsFile(manifest, tempDir);
105
+ assert.ok(manifest.migrations.includes("CREATE TABLE items"));
106
+ assert.equal(manifest.migrations_file, undefined);
107
+ });
108
+
109
+ it("leaves manifest untouched when no migrations_file", () => {
110
+ const manifest = { migrations: "SELECT 1" };
111
+ resolveMigrationsFile(manifest, tempDir);
112
+ assert.equal(manifest.migrations, "SELECT 1");
113
+ });
114
+
115
+ it("handles manifest with neither migrations nor migrations_file", () => {
116
+ const manifest = { files: [] };
117
+ resolveMigrationsFile(manifest, tempDir);
118
+ assert.equal(manifest.migrations, undefined);
119
+ });
120
+
121
+ it("throws on missing migrations file", () => {
122
+ const manifest = { migrations_file: "does-not-exist.sql" };
123
+ assert.throws(
124
+ () => resolveMigrationsFile(manifest, tempDir),
125
+ /ENOENT/,
126
+ );
127
+ });
128
+ });
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
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": {