@selvajs/cli 4.9.0 → 4.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.9.0",
3
+ "version": "4.9.1",
4
4
  "description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
package/src/caddyfile.js CHANGED
@@ -45,8 +45,11 @@ export function renderCaddyfile({ domain, acmeEmail, port = 3000 }) {
45
45
  @api path /api/*
46
46
  header @api Cache-Control "no-cache, no-store, must-revalidate"
47
47
 
48
+ # Must clear the app's own caps (COMPUTE_REQUEST_MAX_BYTES / BODY_SIZE_LIMIT,
49
+ # both 256 MB) — Caddy rejects first, so a lower value here silently caps
50
+ # every upload no matter what .env says.
48
51
  request_body {
49
- max_size 100mb
52
+ max_size 256mb
50
53
  }
51
54
 
52
55
  log {
@@ -165,6 +165,73 @@ export function checkEnvDocumentation(commentLines, fix) {
165
165
  );
166
166
  }
167
167
 
168
+ /**
169
+ * Parse a `BODY_SIZE_LIMIT` the way adapter-node does, so this check agrees with
170
+ * the thing that actually rejects the request.
171
+ *
172
+ * The quirk is load-bearing: adapter-node reads only the LAST character as the
173
+ * suffix, so `60mb` parses as the digits `60m` and happens to mean 60 MB, while
174
+ * `60xy` silently becomes NaN. Returns bytes, or `null` when the value is one
175
+ * adapter-node can't use.
176
+ */
177
+ export function parseBodySizeLimit(raw) {
178
+ if (raw == null || raw === '') return null;
179
+ const value = String(raw).trim();
180
+ if (value === '') return null;
181
+
182
+ // Mirrors adapter-node's parse_as_bytes (files/utils.js): only K/M/G count as
183
+ // units, and the rest goes through `Number`, which is strict — "256mb" leaves
184
+ // a trailing "m" and yields NaN, so adapter-node throws on boot.
185
+ const units = { K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 };
186
+ const multiplier = units[value.at(-1).toUpperCase()] ?? 1;
187
+ const bytes = Number(multiplier !== 1 ? value.slice(0, -1) : value) * multiplier;
188
+ if (!Number.isFinite(bytes) || bytes <= 0) return null;
189
+ return bytes;
190
+ }
191
+
192
+ // The app's own default for COMPUTE_REQUEST_MAX_BYTES (packages/server
193
+ // compute/limits.ts). Duplicated rather than imported: the CLI installs the
194
+ // runtime and cannot import from it.
195
+ const COMPUTE_REQUEST_DEFAULT_BYTES = 256 * 1024 * 1024;
196
+
197
+ /**
198
+ * BODY_SIZE_LIMIT has to clear the compute request cap, or adapter-node's global
199
+ * backstop rejects the upload first — with a non-JSON body the app never sees
200
+ * and cannot log. Unset is the dangerous case, not a neutral one: adapter-node
201
+ * falls back to 512 KB, so every upload 413s on a deployment that looks fine.
202
+ */
203
+ export function checkBodySizeLimit(env) {
204
+ const raw = env.BODY_SIZE_LIMIT;
205
+ const requestCap = Number(env.COMPUTE_REQUEST_MAX_BYTES) || COMPUTE_REQUEST_DEFAULT_BYTES;
206
+ const asMb = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
207
+
208
+ if (raw == null || raw === '') {
209
+ return red(
210
+ `BODY_SIZE_LIMIT unset — adapter-node falls back to 512 KB, so uploads over ` +
211
+ `that fail with an opaque 413 no app log records. Set it to at least ` +
212
+ `${asMb(requestCap)} (the compute request cap).`
213
+ );
214
+ }
215
+
216
+ const bytes = parseBodySizeLimit(raw);
217
+ if (bytes === null) {
218
+ return red(
219
+ `BODY_SIZE_LIMIT="${raw}" — adapter-node cannot parse this and throws on boot. ` +
220
+ `Use a byte count or a single K/M/G suffix, e.g. "256M".`
221
+ );
222
+ }
223
+
224
+ if (bytes < requestCap) {
225
+ return yellow(
226
+ `BODY_SIZE_LIMIT=${raw} (${asMb(bytes)}) is below COMPUTE_REQUEST_MAX_BYTES ` +
227
+ `(${asMb(requestCap)}) — the global cap rejects first, making the compute cap ` +
228
+ `dead config. Raise it to at least ${asMb(requestCap)}.`
229
+ );
230
+ }
231
+
232
+ return green(`BODY_SIZE_LIMIT=${raw}`);
233
+ }
234
+
168
235
  // A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
169
236
  // encodes a value in the new name, so migrate leaves it alone rather than
170
237
  // guessing — it's reported here and nowhere else.
@@ -27,6 +27,7 @@ import { green, yellow, red, fixable } from '../checks/result.js';
27
27
  import { checkBootPersistence } from '../checks/boot.js';
28
28
  import { checkRuntimeEnvironment } from '../checks/runtime.js';
29
29
  import {
30
+ checkBodySizeLimit,
30
31
  checkDeprecatedEnv,
31
32
  checkEnvDocumentation,
32
33
  checkHeaderNames,
@@ -82,6 +83,7 @@ export async function runDoctor(argv = []) {
82
83
  checks.push(...checkRuntimeEnvironment(dir));
83
84
  checks.push(...checkBootPersistence(dir));
84
85
  checks.push(checkOrigin(env));
86
+ checks.push(checkBodySizeLimit(env));
85
87
  checks.push(...checkDeprecatedEnv(env));
86
88
  checks.push(checkEnvDocs(dir));
87
89
 
@@ -174,7 +176,7 @@ function checkDataPath(dir, dataPath) {
174
176
  }
175
177
  }
176
178
 
177
- async function checkSupabase(env) {
179
+ export async function checkSupabase(env) {
178
180
  if (!env.SUPABASE_URL) return red('SUPABASE_URL unset');
179
181
  if (!env.SUPABASE_ANON_KEY) return red('SUPABASE_ANON_KEY unset');
180
182
  if (!env.SUPABASE_SERVICE_ROLE_KEY) return red('SUPABASE_SERVICE_ROLE_KEY unset');
@@ -189,11 +191,21 @@ async function checkSupabase(env) {
189
191
  try {
190
192
  const controller = new AbortController();
191
193
  const timer = setTimeout(() => controller.abort(), 4000);
194
+ // GoTrue rejects an unauthenticated request to its own health endpoint, so
195
+ // without the key a perfectly healthy project answers 401 and this reads as
196
+ // a broken deployment. Sending the anon key makes 200 mean what it says.
192
197
  const res = await fetch(env.SUPABASE_URL + '/auth/v1/health', {
198
+ headers: { apikey: env.SUPABASE_ANON_KEY },
193
199
  signal: controller.signal
194
200
  });
195
201
  clearTimeout(timer);
196
202
  if (res.ok) return green(`SUPABASE_URL reachable (${res.status})`);
203
+ if (res.status === 401) {
204
+ return red(
205
+ `SUPABASE_URL rejected SUPABASE_ANON_KEY (401) — the key does not belong ` +
206
+ `to this project, or it has been rotated.`
207
+ );
208
+ }
197
209
  return yellow(`SUPABASE_URL responded ${res.status} — check project status`);
198
210
  } catch (err) {
199
211
  return yellow(`SUPABASE_URL unreachable (${err.message ?? err}) — skipping`);
package/src/env.js CHANGED
@@ -180,6 +180,31 @@ export function readEnvFile(path) {
180
180
  return parseEnv(readFileSync(path, 'utf8'));
181
181
  }
182
182
 
183
+ /**
184
+ * Template vars whose shipped value is a real default a deployment should run
185
+ * with, rather than a placeholder or a prompt answer.
186
+ *
187
+ * Deliberately an allowlist, not "every live line in the template". The template
188
+ * also ships `SELVA_HMAC_KEY=replace-this-…` (seeding it would give every
189
+ * deployment a publicly known key) and a monorepo-relative `DATA_PATH` that is
190
+ * wrong anywhere but this repo.
191
+ *
192
+ * `BODY_SIZE_LIMIT` has to be here: unset, adapter-node falls back to 512 KB and
193
+ * every upload 413s with an opaque non-JSON body, which reads as a broken
194
+ * install rather than a missing setting.
195
+ */
196
+ const SEEDED_TEMPLATE_DEFAULTS = ['BODY_SIZE_LIMIT'];
197
+
198
+ /** The subset of the template's live `KEY=value` lines that are safe to seed. */
199
+ function seededTemplateDefaults(template) {
200
+ const parsed = parseEnv(template);
201
+ const out = {};
202
+ for (const key of SEEDED_TEMPLATE_DEFAULTS) {
203
+ if (parsed[key] !== undefined) out[key] = parsed[key];
204
+ }
205
+ return out;
206
+ }
207
+
183
208
  /**
184
209
  * Render a values-only `.env` — the scaffold's default shape.
185
210
  *
@@ -192,6 +217,7 @@ export function readEnvFile(path) {
192
217
  export function renderEnvValues(template, values, { header = ENV_HEADER } = {}) {
193
218
  const ordered = [];
194
219
  const seen = new Set();
220
+ const defaults = seededTemplateDefaults(template);
195
221
 
196
222
  // The template names a var three ways — live (`KEY=v`), commented-out
197
223
  // (`# KEY=v`), and as a bare mention in prose (`# KEY single | multi`).
@@ -202,7 +228,17 @@ export function renderEnvValues(template, values, { header = ENV_HEADER } = {})
202
228
  const key = stripped.split(/[\s=]/)[0]?.trim();
203
229
  if (!key || !/^[A-Z][A-Z0-9_]{2,}$/.test(key)) continue;
204
230
  if (seen.has(key)) continue;
205
- if (!Object.prototype.hasOwnProperty.call(values, key)) continue;
231
+ // A live `KEY=value` in the template is a shipped default, not just an
232
+ // ordering hint: the prompts cover a fraction of the vars, so without this
233
+ // a scaffolded deployment silently runs on the code's fallback instead.
234
+ // BODY_SIZE_LIMIT is the case that bites — adapter-node's fallback is
235
+ // 512 KB, so every upload 413s until an operator sets it by hand.
236
+ if (
237
+ !Object.prototype.hasOwnProperty.call(values, key) &&
238
+ !Object.prototype.hasOwnProperty.call(defaults, key)
239
+ ) {
240
+ continue;
241
+ }
206
242
  seen.add(key);
207
243
  ordered.push(key);
208
244
  }
@@ -213,7 +249,9 @@ export function renderEnvValues(template, values, { header = ENV_HEADER } = {})
213
249
 
214
250
  const lines = [];
215
251
  for (const key of ordered) {
216
- const value = values[key];
252
+ // Operator values win over the template's defaults, including when the
253
+ // operator has deliberately blanked one out.
254
+ const value = Object.prototype.hasOwnProperty.call(values, key) ? values[key] : defaults[key];
217
255
  if (value === undefined || value === null || value === '') continue;
218
256
  lines.push(`${key}=${quoteIfNeeded(value)}`);
219
257
  }