run402 1.54.2 → 1.54.4

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/argparse.mjs CHANGED
@@ -1,4 +1,6 @@
1
+ import { existsSync, statSync } from "node:fs";
1
2
  import { fail } from "./sdk-errors.mjs";
3
+ import { resolveProjectId } from "./config.mjs";
2
4
 
3
5
  export function normalizeArgv(argv = []) {
4
6
  const out = [];
@@ -99,6 +101,89 @@ export function failBadProjectId(value) {
99
101
  });
100
102
  }
101
103
 
104
+ /**
105
+ * Validate a webhook URL: parse it locally and reject non-https:// schemes.
106
+ *
107
+ * Scope (GH-192): scheme-only validation. Reject `javascript:`, `file:`,
108
+ * `http:`, `data:`, `ftp:`, etc. before the request leaves the CLI process.
109
+ * Server-side SSRF defenses (private-IP filtering, DNS rebinding, IMDS
110
+ * blocking) live on the gateway, not here — this helper is the cheap
111
+ * client-side guard against the obvious classes.
112
+ *
113
+ * No-op when `url` is null/undefined/empty so callers can pass optional
114
+ * flag values directly. Required-vs-optional handling stays at the call
115
+ * site (e.g. `webhooks register` does its own missing-flag check first).
116
+ *
117
+ * On failure: `fail()` writes the canonical error envelope and exits 1.
118
+ *
119
+ * @param {string|null|undefined} url - The webhook URL to validate.
120
+ * @param {string} fieldName - The CLI flag name for the error envelope (e.g. "--url", "--webhook").
121
+ */
122
+ export function validateWebhookUrl(url, fieldName = "--url") {
123
+ if (!url) return;
124
+ let parsed;
125
+ try {
126
+ parsed = new URL(url);
127
+ } catch {
128
+ fail({
129
+ code: "BAD_WEBHOOK_URL",
130
+ message: `${fieldName} is not a valid URL: ${JSON.stringify(url)}`,
131
+ field: fieldName,
132
+ hint: "Webhook URL must be a fully-qualified https:// URL.",
133
+ details: { flag: fieldName, value: url },
134
+ });
135
+ }
136
+ if (parsed.protocol !== "https:") {
137
+ fail({
138
+ code: "BAD_WEBHOOK_URL",
139
+ message: `${fieldName} must use https://, got ${parsed.protocol}`,
140
+ field: fieldName,
141
+ hint: "Webhook URLs must be https:// for transport security.",
142
+ details: { flag: fieldName, value: url, scheme: parsed.protocol },
143
+ });
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Validate that a CLI flag pointing at a filesystem path resolves to an
149
+ * existing regular file. Replaces the GH-195 inline pattern that was
150
+ * duplicated across `functions deploy`, `secrets set`, `projects sql`,
151
+ * and `projects apply-expose` (GH-233).
152
+ *
153
+ * Without this guard, `readFileSync` against a missing path leaks a raw
154
+ * `node:fs` ENOENT/EISDIR stack to stderr (with the V8 source pointer),
155
+ * which violates the CLI's structured-error contract.
156
+ *
157
+ * No-op: this helper is meant to be called only when the flag is set.
158
+ * Callers handle the optional/required dichotomy themselves.
159
+ *
160
+ * On failure: `fail()` writes a `FILE_NOT_FOUND` or `NOT_A_FILE` envelope
161
+ * to stderr and exits 1.
162
+ *
163
+ * @param {string} path - The filesystem path captured from the flag.
164
+ * @param {string} fieldName - The flag name for the envelope (default "--file").
165
+ */
166
+ export function validateRegularFile(path, fieldName = "--file") {
167
+ if (!existsSync(path)) {
168
+ fail({
169
+ code: "FILE_NOT_FOUND",
170
+ message: `File not found: ${path}`,
171
+ field: fieldName,
172
+ path,
173
+ hint: `Check that ${fieldName} points to an existing file.`,
174
+ });
175
+ }
176
+ const stat = statSync(path);
177
+ if (!stat.isFile()) {
178
+ fail({
179
+ code: "NOT_A_FILE",
180
+ message: `${fieldName} points to a ${stat.isDirectory() ? "directory" : "non-regular file"}: ${path}`,
181
+ field: fieldName,
182
+ path,
183
+ });
184
+ }
185
+ }
186
+
102
187
  export function positionalArgs(args = [], flagsWithValues = []) {
103
188
  const valueFlags = new Set(flagsWithValues);
104
189
  const out = [];
@@ -114,6 +199,49 @@ export function positionalArgs(args = [], flagsWithValues = []) {
114
199
  return out;
115
200
  }
116
201
 
202
+ // Resolve a positional project_id argument with active-project fallback (GH-102, GH-187).
203
+ // If the first positional starts with "prj_", treat it as the project id and
204
+ // strip it from the rest. Otherwise, fall through to the active project from
205
+ // the keystore. Callers can tighten the legacy shorthand when a bare non-prj
206
+ // positional is more likely a mistyped project id than an argument for the
207
+ // active project.
208
+ //
209
+ // Options:
210
+ // rejectBareFirst: when true, error if the first positional
211
+ // is non-empty and doesn't start with "prj_".
212
+ // rejectBareFirstWhenFlagPresent: when one of these flags is present in
213
+ // args AND the first positional doesn't
214
+ // start with "prj_", error out.
215
+ // maxBarePositionals + valueFlags: when set, count the bare (non-flag)
216
+ // positionals using `positionalArgs(args,
217
+ // valueFlags)` and error if the count
218
+ // exceeds maxBarePositionals.
219
+ export function resolvePositionalProject(args, opts = {}) {
220
+ const first = Array.isArray(args) ? args[0] : undefined;
221
+ if (typeof first === "string" && first.startsWith("prj_")) {
222
+ return { projectId: first, rest: args.slice(1) };
223
+ }
224
+ if (
225
+ typeof first === "string" &&
226
+ first.length > 0 &&
227
+ !first.startsWith("-") &&
228
+ Array.isArray(opts.rejectBareFirstWhenFlagPresent) &&
229
+ opts.rejectBareFirstWhenFlagPresent.some((flag) => args.includes(flag))
230
+ ) {
231
+ failBadProjectId(first);
232
+ }
233
+ if (typeof first === "string" && first.length > 0 && !first.startsWith("-") && opts.rejectBareFirst) {
234
+ failBadProjectId(first);
235
+ }
236
+ if (typeof first === "string" && first.length > 0 && !first.startsWith("-") && opts.maxBarePositionals !== undefined) {
237
+ const bare = positionalArgs(args, opts.valueFlags ?? []);
238
+ if (bare.length > opts.maxBarePositionals) {
239
+ failBadProjectId(first);
240
+ }
241
+ }
242
+ return { projectId: resolveProjectId(null), rest: Array.isArray(args) ? args : [] };
243
+ }
244
+
117
245
  function closestFlag(flag, candidates) {
118
246
  let best = null;
119
247
  let bestDistance = Number.POSITIVE_INFINITY;
package/lib/auth.mjs CHANGED
@@ -187,10 +187,23 @@ async function settings(args) {
187
187
  message: "Missing --allow-password-set <true|false>",
188
188
  });
189
189
  }
190
+ // Reject anything that isn't literally "true" or "false". Without this guard,
191
+ // the previous `=== "true"` coercion silently turned every other input
192
+ // (including "1", "yes", "TRUE", "bogus") into `false` and printed
193
+ // `{"status":"ok"}`, giving the user the OPPOSITE of what they likely
194
+ // intended for this security-adjacent flag. See GH-204.
195
+ if (allowPasswordSet !== "true" && allowPasswordSet !== "false") {
196
+ fail({
197
+ code: "BAD_FLAG",
198
+ message: "--allow-password-set must be 'true' or 'false'",
199
+ hint: "Use the literal strings 'true' or 'false'.",
200
+ });
201
+ }
190
202
 
203
+ const allow = allowPasswordSet === "true";
191
204
  try {
192
- await getSdk().auth.settings(projectId, { allow_password_set: allowPasswordSet === "true" });
193
- console.log(JSON.stringify({ status: "ok", allow_password_set: allowPasswordSet === "true" }));
205
+ await getSdk().auth.settings(projectId, { allow_password_set: allow });
206
+ console.log(JSON.stringify({ status: "ok", allow_password_set: allow }));
194
207
  } catch (err) {
195
208
  reportSdkError(err);
196
209
  }
package/lib/billing.mjs CHANGED
@@ -84,6 +84,41 @@ Options:
84
84
  Examples:
85
85
  run402 billing history user@example.com
86
86
  run402 billing history 0x1234... --limit 100
87
+ `,
88
+ balance: `run402 billing balance — Show balance for an email or wallet
89
+
90
+ Usage:
91
+ run402 billing balance <identifier>
92
+
93
+ Arguments:
94
+ <identifier> Email address or wallet (0x...)
95
+
96
+ Examples:
97
+ run402 billing balance user@example.com
98
+ run402 billing balance 0x1234abcd...
99
+ `,
100
+ "create-email": `run402 billing create-email — Create an email billing account
101
+
102
+ Usage:
103
+ run402 billing create-email <email>
104
+
105
+ Arguments:
106
+ <email> Email address to register as a billing account
107
+
108
+ Examples:
109
+ run402 billing create-email user@example.com
110
+ `,
111
+ "link-wallet": `run402 billing link-wallet — Link a wallet to an email billing account
112
+
113
+ Usage:
114
+ run402 billing link-wallet <account_id> <wallet>
115
+
116
+ Arguments:
117
+ <account_id> Billing account ID (e.g. acct_abc123)
118
+ <wallet> Wallet address (0x...) to link
119
+
120
+ Examples:
121
+ run402 billing link-wallet acct_abc123 0x1234abcd...
87
122
  `,
88
123
  };
89
124
 
package/lib/config.mjs CHANGED
@@ -14,8 +14,27 @@ export const ALLOWANCE_FILE = getAllowancePath();
14
14
  export const PROJECTS_FILE = getKeystorePath();
15
15
  export const API = getApiBase();
16
16
 
17
+ /**
18
+ * Wraps core's `readAllowance()` and converts the malformed-shape throw
19
+ * (GH-194) into the canonical CLI failure envelope. Without this guard, every
20
+ * CLI subcommand that touches the allowance leaks a Node stack trace and
21
+ * source paths the moment a user has a malformed `allowance.json`.
22
+ *
23
+ * The unparseable-JSON case still returns `null` (matching the historical
24
+ * "no_allowance" UX); only valid-JSON-but-wrong-shape becomes a structured
25
+ * error with `code: BAD_ALLOWANCE_FILE`.
26
+ */
17
27
  export function readAllowance() {
18
- return coreReadAllowance();
28
+ try {
29
+ return coreReadAllowance();
30
+ } catch (err) {
31
+ fail({
32
+ code: "BAD_ALLOWANCE_FILE",
33
+ message: err?.message ?? "allowance.json is malformed",
34
+ hint: "Back up ~/.config/run402/allowance.json and run 'run402 init' to recreate it.",
35
+ details: { path: ALLOWANCE_FILE },
36
+ });
37
+ }
19
38
  }
20
39
 
21
40
  export function saveAllowance(data) {
package/lib/contracts.mjs CHANGED
@@ -83,6 +83,47 @@ Usage:
83
83
 
84
84
  Usage:
85
85
  run402 contracts delete <project_id> <wallet_id> --confirm
86
+ `,
87
+ "get-wallet": `run402 contracts get-wallet — Get wallet metadata + live balance
88
+
89
+ Usage:
90
+ run402 contracts get-wallet <project_id> <wallet_id>
91
+
92
+ Arguments:
93
+ <project_id> Project ID that owns the wallet
94
+ <wallet_id> Wallet ID (e.g. cwlt_abc123)
95
+
96
+ Examples:
97
+ run402 contracts get-wallet prj_abc123 cwlt_abc123
98
+ `,
99
+ "list-wallets": `run402 contracts list-wallets — List all KMS wallets for a project
100
+
101
+ Usage:
102
+ run402 contracts list-wallets <project_id>
103
+
104
+ Arguments:
105
+ <project_id> Project ID to list wallets for
106
+
107
+ Notes:
108
+ - Includes deleted wallets
109
+
110
+ Examples:
111
+ run402 contracts list-wallets prj_abc123
112
+ `,
113
+ status: `run402 contracts status — Get a contract call's status and receipt
114
+
115
+ Usage:
116
+ run402 contracts status <project_id> <call_id>
117
+
118
+ Arguments:
119
+ <project_id> Project ID that submitted the call
120
+ <call_id> Contract call ID returned from 'contracts call'
121
+
122
+ Notes:
123
+ - Returns status, gas used, gas cost (USD-micros), and receipt
124
+
125
+ Examples:
126
+ run402 contracts status prj_abc123 ccall_abc123
86
127
  `,
87
128
  };
88
129
 
package/lib/deploy-v2.mjs CHANGED
@@ -166,6 +166,43 @@ async function applyCmd(args) {
166
166
  });
167
167
  }
168
168
 
169
+ // GH-232: Reject empty specs client-side. Without this guard,
170
+ // `run402 deploy apply --spec '{}'` (and `--manifest <empty>`) would silently
171
+ // send an empty ReleaseSpec to /deploy/v2/plans with no signal that nothing
172
+ // was deployed. This mirrors the GH-185 guard already in place for the
173
+ // legacy `run402 deploy --manifest` path.
174
+ //
175
+ // `deploy apply` is v2-only — only meaningful keys are the v2 ReleaseSpec
176
+ // shape (database, site, functions, secrets, subdomains, domains).
177
+ // For object-typed sections the "container is non-empty" check isn't enough
178
+ // — `site:{replace:{}}` has one key but ships nothing. We recurse one level
179
+ // so any object whose own values are all empty containers is still empty.
180
+ const meaningful = ["database", "site", "functions", "secrets", "subdomains", "domains"];
181
+ function hasContent(v) {
182
+ if (v == null) return false;
183
+ if (Array.isArray(v)) return v.length > 0;
184
+ if (typeof v === "object") {
185
+ const keys = Object.keys(v);
186
+ if (keys.length === 0) return false;
187
+ return keys.some((k) => hasContent(v[k]));
188
+ }
189
+ if (typeof v === "string") return v.length > 0;
190
+ return true;
191
+ }
192
+ const hasMeaningfulContent = spec && typeof spec === "object" && !Array.isArray(spec) && meaningful.some((key) => hasContent(spec[key]));
193
+ if (!hasMeaningfulContent) {
194
+ fail({
195
+ code: "MANIFEST_EMPTY",
196
+ message: `Manifest contains no deployable sections. Expected at least one of: ${meaningful.join(", ")}`,
197
+ hint: "Did you mean to write a 'site.replace' or 'database.migrations' block? See https://run402.com/schemas/manifest.v1.json",
198
+ details: {
199
+ field: opts.manifest ? "manifest" : opts.spec ? "spec" : "stdin",
200
+ ...(opts.manifest ? { path: resolve(opts.manifest) } : {}),
201
+ meaningful_keys: meaningful,
202
+ },
203
+ });
204
+ }
205
+
169
206
  if (opts.manifest) resolveFileDataPaths(spec, dirname(resolve(opts.manifest)));
170
207
 
171
208
  if (opts.project && spec.project_id && spec.project_id !== opts.project) {
package/lib/deploy.mjs CHANGED
@@ -16,77 +16,100 @@ Options:
16
16
  --project <id> Project ID to deploy to (default: active project)
17
17
  --help, -h Show this help message
18
18
 
19
- Manifest format (JSON):
19
+ Subcommands (recommended for new manifests):
20
+ run402 deploy apply --manifest <file> unified deploy primitive (v1.34+)
21
+ run402 deploy resume <operation_id> resume a stuck operation
22
+ run402 deploy list [--project <id>] list recent deploy operations
23
+ run402 deploy events <operation_id> fetch event stream for an operation
24
+
25
+ Manifest format (JSON, v2 ReleaseSpec — recommended):
20
26
  {
21
27
  "project_id": "prj_...",
22
- "migrations": "CREATE TABLE items (...)",
23
- "migrations_file": "setup.sql",
24
- "secrets": [{ "key": "OPENAI_API_KEY", "value": "sk-..." }],
25
- "functions": [{
26
- "name": "my-fn",
27
- "code": "export default async (req) => new Response('ok')"
28
- }],
29
- "files": [
30
- {
31
- "file": "manifest.json",
32
- "data": "{\\"version\\":\\"1\\",\\"tables\\":[{\\"name\\":\\"items\\",\\"expose\\":true,\\"policy\\":\\"public_read_write_UNRESTRICTED\\",\\"i_understand_this_is_unrestricted\\":true}]}"
33
- },
34
- { "file": "index.html", "data": "<html>...</html>" },
35
- { "file": "style.css", "path": "./dist/style.css" }
36
- ],
37
- "subdomain": "my-app"
28
+ "database": {
29
+ "migrations": [
30
+ { "id": "001_init", "sql": "CREATE TABLE IF NOT EXISTS items (...)" }
31
+ ],
32
+ "expose": {
33
+ "version": "1",
34
+ "tables": [
35
+ { "name": "items", "expose": true, "policy": "public_read_authenticated_write" }
36
+ ]
37
+ }
38
+ },
39
+ "secrets": { "set": { "OPENAI_API_KEY": { "value": "sk-..." } } },
40
+ "functions": {
41
+ "replace": {
42
+ "api": {
43
+ "runtime": "node22",
44
+ "source": { "data": "export default async (req) => new Response('ok')" }
45
+ }
46
+ }
47
+ },
48
+ "site": {
49
+ "replace": {
50
+ "index.html": { "data": "<!doctype html><html>...</html>" },
51
+ "assets/logo.png": { "data": "iVBORw0KGgo...", "encoding": "base64" }
52
+ }
53
+ },
54
+ "subdomains": { "set": ["my-app"] }
38
55
  }
39
56
 
40
57
  project_id is required (provision first with 'run402 provision').
41
- All other fields are optional.
42
-
43
- Migrations can be inline or read from a file:
44
- "migrations": "CREATE TABLE ..." ← inline SQL
45
- "migrations_file": "setup.sql" ← read from disk
46
- Use migrations_file when your SQL contains JSONB literals or other
47
- characters that are painful to escape inside a JSON string.
48
- Paths are resolved relative to the manifest file's directory.
49
- If both are present, migrations_file wins.
50
-
51
- Files can use either inline "data" or a local "path":
52
- { "file": "index.html", "data": "<html>...</html>" } ← inline content
53
- { "file": "style.css", "path": "./dist/style.css" } ← read from disk
54
- Paths are resolved relative to the manifest file's directory.
55
- Binary files (images, fonts, etc.) are auto-detected and base64-encoded.
56
-
57
- Authorization (manifest.json file pattern):
58
- Tables are dark by default — anon/authenticated can't read them until a
59
- manifest declares them with expose:true. Ship a "manifest.json" entry in
60
- files[] (preferred auth-as-SDLC) and the platform reads, validates,
61
- applies, and strips it before the site deploys. Schema:
62
- https://run402.com/schemas/manifest.v1.json
63
-
64
- Per-table policies:
65
- user_owns_rows users see only their own rows. Requires
66
- "owner_column"; with
67
- "force_owner_on_insert": true the gateway
68
- sets it from auth.uid() automatically.
69
- uuid columns get index-friendly policies.
70
- public_read_authenticated_write anyone reads; any authenticated user can
71
- INSERT/UPDATE/DELETE any row (not just
72
- their own). For collaborative content
73
- like shared boards or announcements.
74
- public_read_write_UNRESTRICTED ⚠ fully open — anon_key can read AND
75
- write any row. Only for intentionally
76
- public tables (guestbooks, waitlists,
77
- feedback forms). REQUIRES
58
+ All other fields are optional. Top-level absence = "leave untouched".
59
+
60
+ Source of truth for the v2 ReleaseSpec shape:
61
+ https://run402.com/llms-cli.txt (search for "Unified Deploy")
62
+
63
+ Replace vs patch semantics per resource:
64
+ "site": { "replace": {...} } whole-site (omitted files removed)
65
+ "site": { "patch": { "put": {...}, "delete": [...] } } surgical updates
66
+ Same for "functions" and "secrets". Migrations are always additive (each
67
+ is keyed by id; re-shipping the same id+sql is a registry noop, same id
68
+ with different sql is a hard MIGRATION_CHECKSUM_MISMATCH error).
69
+
70
+ File entries accept inline "data", a local "path", or a "sql_path"
71
+ (migrations only) — paths are resolved relative to the manifest file's
72
+ directory. Binary files (images, fonts, PDFs) take "encoding": "base64";
73
+ text defaults to UTF-8.
74
+
75
+ Authorization (database.expose):
76
+ Tables are dark by default anon/authenticated can't read them until
77
+ you declare them via "database.expose". Per-table policies:
78
+ user_owns_rows users see only their own rows.
79
+ Requires "owner_column"; with
80
+ "force_owner_on_insert": true the
81
+ gateway sets it from auth.uid()
82
+ automatically.
83
+ public_read_authenticated_write anyone reads; any authenticated
84
+ user can INSERT/UPDATE/DELETE any
85
+ row (not just their own).
86
+ public_read_write_UNRESTRICTED ⚠ fully open anon_key reads AND
87
+ writes. REQUIRES
78
88
  "i_understand_this_is_unrestricted":
79
89
  true on the table entry.
80
- custom escape hatch. Provide "custom_sql" with
81
- CREATE POLICY statements.
90
+ custom escape hatch. Provide "custom_sql"
91
+ with CREATE POLICY statements.
92
+ Schema for the expose section: https://run402.com/schemas/manifest.v1.json
82
93
 
83
- ⚠️ Without a manifest, tables are unreachable via anon_key. If your app
84
- reads or writes data from the browser, you need a manifest.json entry.
94
+ ⚠️ Without an "expose" entry, tables are unreachable via anon_key.
95
+
96
+ Legacy v1 bundle format (still accepted via compatibility shim):
97
+ Existing manifests with top-level "migrations" (string), "secrets" (array),
98
+ "functions" (array), "files" (array), "subdomain" (string), and the
99
+ "files[].file/data/path" + inline "manifest.json" entry continue to work —
100
+ the SDK translates them into a v2 ReleaseSpec under the hood. Prefer the
101
+ v2 shape above for new manifests; the legacy form is preserved for the
102
+ deprecation window so existing scripts don't break.
103
+
104
+ "migrations_file": "setup.sql" (legacy convenience) reads SQL from disk
105
+ relative to the manifest file. Useful when JSONB literals make inline
106
+ strings painful. Still supported on the legacy code path.
85
107
 
86
108
  Examples:
87
109
  run402 deploy --manifest app.json
88
110
  run402 deploy --manifest app.json --project prj_123_1
89
111
  cat app.json | run402 deploy
112
+ run402 deploy apply --manifest app.json # unified primitive (recommended)
90
113
 
91
114
  Prerequisites:
92
115
  - run402 init Set up allowance and funding
@@ -157,6 +180,50 @@ async function loadManifest(opts) {
157
180
  });
158
181
  }
159
182
 
183
+ // GH-185: Reject empty manifests client-side. Without this guard,
184
+ // `echo '{}' | run402 deploy` silently succeeds against the gateway with
185
+ // no signal that nothing was deployed. The MCP `deploy` tool was hardened
186
+ // for the same class of bug in #133; this is the CLI-side analog.
187
+ //
188
+ // "Meaningful" = at least one of these keys exists with non-empty content.
189
+ // We accept both shapes because this CLI path receives v1 manifests
190
+ // (translated by the bundleDeploy shim) and may also receive v2 manifests.
191
+ // v1: migrations, migrations_file, secrets, functions, files, subdomain
192
+ // v2: database, site, functions, secrets, subdomains, domains
193
+ // For object-typed v2 sections (site, database, functions, secrets,
194
+ // subdomains, domains) the "container is non-empty" check isn't enough —
195
+ // `site:{replace:{}}` has one key but ships nothing. We recurse one level
196
+ // so any object whose own values are all empty containers is still empty.
197
+ const meaningfulV1 = ["migrations", "migrations_file", "secrets", "functions", "files", "subdomain"];
198
+ const meaningfulV2 = ["database", "site", "functions", "secrets", "subdomains", "domains"];
199
+ const meaningful = [...new Set([...meaningfulV1, ...meaningfulV2])];
200
+
201
+ function hasContent(v) {
202
+ if (v == null) return false;
203
+ if (Array.isArray(v)) return v.length > 0;
204
+ if (typeof v === "object") {
205
+ const keys = Object.keys(v);
206
+ if (keys.length === 0) return false;
207
+ return keys.some((k) => hasContent(v[k]));
208
+ }
209
+ if (typeof v === "string") return v.length > 0;
210
+ return true;
211
+ }
212
+
213
+ const hasMeaningfulContent = manifest && typeof manifest === "object" && !Array.isArray(manifest) && meaningful.some((key) => hasContent(manifest[key]));
214
+ if (!hasMeaningfulContent) {
215
+ fail({
216
+ code: "MANIFEST_EMPTY",
217
+ message: `Manifest contains no deployable sections. Expected at least one of: ${meaningful.join(", ")}`,
218
+ hint: "Did you mean to write a 'site.replace' or 'database.migrations' block? See https://run402.com/schemas/manifest.v1.json",
219
+ details: {
220
+ field: opts.manifest ? "manifest" : "stdin",
221
+ ...(opts.manifest ? { path: resolve(opts.manifest) } : {}),
222
+ meaningful_keys: meaningful,
223
+ },
224
+ });
225
+ }
226
+
160
227
  if (opts.manifest) {
161
228
  try {
162
229
  resolveMigrationsFile(manifest, baseDir);
package/lib/domains.mjs CHANGED
@@ -9,7 +9,7 @@ Usage:
9
9
 
10
10
  Subcommands:
11
11
  add <domain> <subdomain_name> [--project <id>] Register a custom domain
12
- list [<id>] List custom domains for a project
12
+ list [<id>] | list --project <id> List custom domains for a project
13
13
  status <domain> [--project <id>] Check domain DNS/SSL status
14
14
  delete <domain> --confirm [--project <id>] Release a custom domain. Requires --confirm.
15
15
 
@@ -26,6 +26,74 @@ Notes:
26
26
  - The domain must CNAME to domains.run402.com (or ALIAS for apex domains)
27
27
  `;
28
28
 
29
+ const SUB_HELP = {
30
+ add: `run402 domains add — Register a custom domain for a project
31
+
32
+ Usage:
33
+ run402 domains add <domain> <subdomain_name> [--project <id>]
34
+
35
+ Arguments:
36
+ <domain> Custom domain (e.g. example.com)
37
+ <subdomain_name> Existing subdomain to map the custom domain to
38
+
39
+ Options:
40
+ --project <id> Project ID (defaults to the active project)
41
+
42
+ Notes:
43
+ - After adding, configure DNS as shown in the response
44
+ - Poll 'run402 domains status <domain>' until active
45
+ - The domain must CNAME to domains.run402.com (or ALIAS for apex domains)
46
+
47
+ Examples:
48
+ run402 domains add example.com myapp
49
+ run402 domains add example.com myapp --project prj_abc123
50
+ `,
51
+ list: `run402 domains list — List custom domains for a project
52
+
53
+ Usage:
54
+ run402 domains list [<id>]
55
+
56
+ Arguments:
57
+ <id> Project ID (defaults to the active project)
58
+
59
+ Examples:
60
+ run402 domains list
61
+ run402 domains list prj_abc123
62
+ `,
63
+ status: `run402 domains status — Check DNS/SSL status of a custom domain
64
+
65
+ Usage:
66
+ run402 domains status <domain> [--project <id>]
67
+
68
+ Arguments:
69
+ <domain> Custom domain to check
70
+
71
+ Options:
72
+ --project <id> Project ID (defaults to the active project)
73
+
74
+ Examples:
75
+ run402 domains status example.com
76
+ run402 domains status example.com --project prj_abc123
77
+ `,
78
+ delete: `run402 domains delete — Release a custom domain
79
+
80
+ Usage:
81
+ run402 domains delete <domain> --confirm [--project <id>]
82
+
83
+ Arguments:
84
+ <domain> Custom domain to release
85
+
86
+ Options:
87
+ --confirm Required: releasing detaches the domain from this
88
+ project and clears its DNS/SSL configuration
89
+ (irreversible)
90
+ --project <id> Project ID (defaults to the active project)
91
+
92
+ Examples:
93
+ run402 domains delete example.com --confirm
94
+ `,
95
+ };
96
+
29
97
  function parseProjectFlag(args) {
30
98
  let project = null;
31
99
  const rest = [];
@@ -56,8 +124,14 @@ async function add(args) {
56
124
  }
57
125
  }
58
126
 
59
- async function list(projectIdArg) {
60
- const projectId = resolveProjectId(projectIdArg);
127
+ async function list(args) {
128
+ const argList = Array.isArray(args) ? args : [];
129
+ const { project, rest } = parseProjectFlag(argList);
130
+ // Either --project <id> or a positional id is accepted; --project wins
131
+ // when both are supplied. Falls back to the active project when neither
132
+ // is given. Keeps backward-compat with the legacy `domains list <id>`
133
+ // form (GH-209).
134
+ const projectId = resolveProjectId(project || rest[0]);
61
135
  try {
62
136
  const data = await getSdk().domains.list(projectId);
63
137
  console.log(JSON.stringify(data, null, 2));
@@ -113,10 +187,10 @@ async function deleteDomain(args) {
113
187
 
114
188
  export async function run(sub, args) {
115
189
  if (!sub || sub === '--help' || sub === '-h') { console.log(HELP); process.exit(0); }
116
- if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) { console.log(HELP); process.exit(0); }
190
+ if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) { console.log(SUB_HELP[sub] || HELP); process.exit(0); }
117
191
  switch (sub) {
118
192
  case "add": await add(args); break;
119
- case "list": await list(args[0]); break;
193
+ case "list": await list(args); break;
120
194
  case "status": await status(args); break;
121
195
  case "delete": await deleteDomain(args); break;
122
196
  default: