@thebaycloud/cli 1.0.0

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/draft.js ADDED
@@ -0,0 +1,488 @@
1
+ "use strict";
2
+ /**
3
+ * Writing the first draft of `supersonic.json` from the repository alone.
4
+ *
5
+ * The measurement this exists for: on 1 Aug the detector read the root of a
6
+ * `frontend/` + `backend/` repo as "Static site, 80% confidence" — its highest
7
+ * confidence answer, and completely wrong. Nothing here makes that detector
8
+ * better. What changes is WHERE its answer lands. Today it selects a lane on a
9
+ * server, silently, 200 seconds into a deploy nobody is watching; here it lands in
10
+ * a file, in front of the agent that wrote the app, before anything is
11
+ * provisioned. Same code, radically better position — a wrong answer you can read
12
+ * is not the same defect as a wrong answer you cannot.
13
+ *
14
+ * So the rules are: infer only what the files actually say, never invent a
15
+ * declaration to fill a field, and print the questions this cannot answer instead
16
+ * of guessing them. A draft that quietly guesses `spaFallback` is worse than one
17
+ * that asks, because the guess is indistinguishable from a decision.
18
+ *
19
+ * Everything that turns a detected stack into a service — the `$PORT` rewrite, the
20
+ * `app.main:app` module path, uv-without-requirements.txt — comes from
21
+ * infer-services.ts through vendor/resolve.js. This file adds only what that
22
+ * module has no reason to know: the declared runtime version, a framework token,
23
+ * and the env var names a grep can see.
24
+ */
25
+ const fs = require("node:fs");
26
+ const path = require("node:path");
27
+
28
+ /* -------------------------------------------------------------------------- */
29
+ /* Locally determinable facts */
30
+ /* -------------------------------------------------------------------------- */
31
+
32
+ function readText(file) {
33
+ try { return fs.readFileSync(file, "utf8"); } catch { return null; }
34
+ }
35
+
36
+ function readJson(file) {
37
+ const text = readText(file);
38
+ if (text === null) return null;
39
+ try { return JSON.parse(text); } catch { return null; }
40
+ }
41
+
42
+ /**
43
+ * The runtime the repository ASKS FOR, or null when it asks for nothing.
44
+ *
45
+ * Null is the important return. `runtime` is a hard gate — a service declaring
46
+ * python3.15 is refused before the build, because the runner has 3.14 and pip
47
+ * fails forty lines into a log that blames the app. Defaulting the field to
48
+ * whatever the platform happens to ship today would turn that gate into a
49
+ * tautology, and would also pin the app to a version its author never chose: the
50
+ * next time the runner moves, a draft written months ago silently holds it back.
51
+ * Absent means "whatever the runner has", which is the truthful answer for a repo
52
+ * that never said.
53
+ *
54
+ * Both directories are consulted because a monorepo puts `.nvmrc` at the root and
55
+ * its app in `frontend/`, and the root file is still the version the author means.
56
+ */
57
+ function declaredRuntime(serviceDir, repoDir, language) {
58
+ const at = (name) => readText(path.join(serviceDir, name)) ?? (serviceDir === repoDir ? null : readText(path.join(repoDir, name)));
59
+
60
+ if (language === "node") {
61
+ const pkg = readJson(path.join(serviceDir, "package.json")) ?? {};
62
+ const engines = pkg.engines && typeof pkg.engines.node === "string" ? pkg.engines.node : null;
63
+ // `>=20`, `^20.11.0`, `20.x`, `20` all mean major 20. A range with no number
64
+ // in it at all (`*`, `latest`) is not a declaration.
65
+ const major = (engines ?? at(".nvmrc") ?? "").match(/(\d+)/);
66
+ return major ? `node${major[1]}` : null;
67
+ }
68
+ if (language === "python") {
69
+ const py = at("pyproject.toml") ?? "";
70
+ const requires = py.match(/^\s*requires-python\s*=\s*["']([^"']+)["']/m);
71
+ const version = (requires ? requires[1] : at(".python-version") ?? "").match(/(\d+)\.(\d+)/);
72
+ return version ? `python${version[1]}.${version[2]}` : null;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * The detector's display name for a framework, as the token the platform routes on.
79
+ *
80
+ * Mapped rather than re-detected: the detector already reads the dependency names,
81
+ * and a second scan here would be a second answer to "is this Django". Anything
82
+ * generic — "Node", "Python", "Static site" — maps to null, because `framework` is
83
+ * not a label. It selects the proxy-awareness injection in Phase 7b
84
+ * (ALLOWED_HOSTS, basePath, ROOT_PATH), and a token nothing has a mapping for is a
85
+ * field that reads as configured and does nothing.
86
+ */
87
+ const FRAMEWORK_TOKENS = {
88
+ "Next.js": "next",
89
+ "Nuxt": "nuxt",
90
+ "Remix": "remix",
91
+ "SvelteKit": "sveltekit",
92
+ "Astro": "astro",
93
+ "NestJS": "nest",
94
+ "Vite (SPA)": "vite",
95
+ "Create React App": "cra",
96
+ "Express": "express",
97
+ "Fastify": "fastify",
98
+ "Koa": "koa",
99
+ "Django": "django",
100
+ "FastAPI": "fastapi",
101
+ "Flask": "flask",
102
+ "Rails": "rails",
103
+ "Laravel": "laravel",
104
+ };
105
+
106
+ function frameworkToken(detected) {
107
+ return FRAMEWORK_TOKENS[detected] ?? null;
108
+ }
109
+
110
+ /* -------------------------------------------------------------------------- */
111
+ /* Env var names */
112
+ /* -------------------------------------------------------------------------- */
113
+
114
+ /** Directories that hold somebody else's code, or this app's output. */
115
+ const SKIP_DIRS = new Set([
116
+ "node_modules", ".git", ".venv", "venv", "__pycache__", "vendor",
117
+ "dist", "build", "target", ".next", ".nuxt", ".output", "coverage",
118
+ ".terraform", "site-packages", ".pytest_cache", ".mypy_cache",
119
+ ]);
120
+
121
+ const SOURCE_EXT = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".py"]);
122
+
123
+ /**
124
+ * Names that appear in a `process.env` grep and are never a secret to be asked for.
125
+ *
126
+ * NODE_ENV in particular: it is read by almost every Node file that reads anything,
127
+ * and putting it at the top of "which of these are secrets?" is how a list stops
128
+ * being read at all.
129
+ */
130
+ const NOT_A_SECRET = new Set([
131
+ "NODE_ENV", "NODE_OPTIONS", "NODE_PATH", "PATH", "HOME", "PWD", "USER", "SHELL",
132
+ "TZ", "LANG", "CI", "DEBUG", "LOG_LEVEL", "HOSTNAME", "HOST", "TMPDIR",
133
+ "PYTHONPATH", "PYTHONUNBUFFERED", "VIRTUAL_ENV",
134
+ ]);
135
+
136
+ /**
137
+ * Prefixes whose values are baked into the bundle at BUILD time.
138
+ *
139
+ * Kept apart from secrets because they are set at a different moment and the
140
+ * mistake is silent: set after the build — which is what a plain env var does —
141
+ * the build ran without them and the value never reached the shipped JavaScript.
142
+ * The page loads, calls `undefined/api`, and nothing in any log mentions the
143
+ * variable.
144
+ */
145
+ const BUILD_TIME = /^(NEXT_PUBLIC_|VITE_|REACT_APP_|PUBLIC_|GATSBY_|NUXT_PUBLIC_|EXPO_PUBLIC_|STORYBOOK_)/;
146
+
147
+ const PATTERNS = [
148
+ /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
149
+ /process\.env\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\]/g,
150
+ /import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
151
+ /(?:os\.environ(?:\.get)?|os\.getenv|getenv)\s*[[(]\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
152
+ ];
153
+
154
+ /**
155
+ * Every env var name the source reads, split into build-time and runtime.
156
+ *
157
+ * A grep, and honest about being one: it finds names, never values, never whether
158
+ * a name is required. That is the whole of what static analysis can say here —
159
+ * which of these are secrets is a judgement the author makes and the file records.
160
+ *
161
+ * Bounded because `init` promises about two seconds and a repository can contain
162
+ * anything. Hitting a bound truncates the QUESTION, never the config, so the worst
163
+ * case is an unlisted candidate rather than a wrong draft.
164
+ */
165
+ function envNames(serviceDir, { maxFiles = 800, maxBytes = 512 * 1024, platformOwned = () => false } = {}) {
166
+ const found = new Set();
167
+ let budget = maxFiles;
168
+
169
+ const walk = (dir, depth) => {
170
+ if (budget <= 0 || depth > 6) return;
171
+ let entries;
172
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
173
+ for (const e of entries) {
174
+ if (budget <= 0) return;
175
+ if (e.isDirectory()) {
176
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue;
177
+ walk(path.join(dir, e.name), depth + 1);
178
+ continue;
179
+ }
180
+ if (!e.isFile() || !SOURCE_EXT.has(path.extname(e.name))) continue;
181
+ budget--;
182
+ let text;
183
+ try {
184
+ if (fs.statSync(path.join(dir, e.name)).size > maxBytes) continue;
185
+ text = fs.readFileSync(path.join(dir, e.name), "utf8");
186
+ } catch { continue; }
187
+ for (const re of PATTERNS) {
188
+ re.lastIndex = 0;
189
+ for (let m = re.exec(text); m; m = re.exec(text)) found.add(m[1]);
190
+ }
191
+ }
192
+ };
193
+ walk(serviceDir, 0);
194
+
195
+ const buildEnv = [];
196
+ const secrets = [];
197
+ for (const name of [...found].sort()) {
198
+ // PORT, DATABASE_URL and the sixteen other spellings of it are written by the
199
+ // platform. Offering one as a secret would produce a config that
200
+ // parseAppConfig refuses outright — a draft that cannot be parsed is worse
201
+ // than no draft.
202
+ if (platformOwned(name) || NOT_A_SECRET.has(name)) continue;
203
+ (BUILD_TIME.test(name) ? buildEnv : secrets).push(name);
204
+ }
205
+ return { buildEnv, secrets };
206
+ }
207
+
208
+ /* -------------------------------------------------------------------------- */
209
+ /* The draft */
210
+ /* -------------------------------------------------------------------------- */
211
+
212
+ /**
213
+ * The repository's services, drawn entirely from the shared inference path.
214
+ *
215
+ * Three shapes, one of which the control plane's inferAppConfig already handles
216
+ * and two of which it deliberately declines:
217
+ *
218
+ * root Dockerfile — inferAppConfig returns null so that an inference can never
219
+ * outrank an instruction. `init` is not inferring here; it is writing down the
220
+ * instruction, which is the one thing that makes a nested build context
221
+ * ("context") expressible at all.
222
+ * two or more apps — inferAppConfig, unchanged.
223
+ * one app — inferAppConfig declines below two parts because splitting a
224
+ * single-app repo is the thing it exists not to do. init still has to write a
225
+ * file, so it asks deployableParts where the one app is and runs the same
226
+ * serviceFor mapping on it. Note the case this fixes: a repo whose only app is
227
+ * `backend/` used to leave the CLI with nothing but the root to detect, which
228
+ * is the "Static site, 80%" answer again.
229
+ */
230
+ async function draftServices(repoDir, r, detect) {
231
+ const facts = r.readRepoFacts(repoDir);
232
+
233
+ if (facts.dockerfiles.includes("Dockerfile")) {
234
+ const stack = await detect(repoDir);
235
+ // No `language`: deriveLane checks `language === "static"` before it checks
236
+ // the Dockerfile, so calling a Vite repo with a hand-written Dockerfile
237
+ // "static" would route it away from the file its author committed.
238
+ return {
239
+ config: { version: 1, services: [{ name: "app", dir: ".", dockerfile: "Dockerfile", context: ".", path: "/" }] },
240
+ stacks: new Map([[".", stack]]),
241
+ };
242
+ }
243
+
244
+ const parts = r.deployableParts(repoDir, facts);
245
+ const stacks = new Map();
246
+ const abs = (rel) => (rel === "." ? repoDir : path.join(repoDir, rel));
247
+
248
+ if (parts.length >= 2) {
249
+ const config = await r.inferAppConfig(repoDir, detect);
250
+ // inferAppConfig swallows a failed detect and returns null rather than break a
251
+ // deploy that works today. Here there is no deploy to protect, so fall through
252
+ // to the single-service shape instead of writing nothing.
253
+ if (config) {
254
+ for (const s of config.services) stacks.set(s.dir ?? ".", await detect(abs(s.dir ?? ".")));
255
+ return { config, stacks };
256
+ }
257
+ }
258
+
259
+ const rel = parts[0] ?? ".";
260
+ const stack = await detect(abs(rel));
261
+ stacks.set(rel, stack);
262
+ return {
263
+ config: { version: 1, services: [{ ...r.serviceFor(rel, stack, abs(rel)), path: "/" }] },
264
+ stacks,
265
+ };
266
+ }
267
+
268
+ /** `needsDB` is the v1 spelling; a draft written today should use the one that replaced it. */
269
+ function usesFor(service) {
270
+ return service.needsDB || (service.uses ?? []).includes("database") ? ["database"] : [];
271
+ }
272
+
273
+ /**
274
+ * Build the draft config and the list of questions it cannot answer.
275
+ *
276
+ * Placeholders are deliberate and deliberately inert. `release: ""`,
277
+ * `spaFallback: false`, `secrets: []` and `buildEnv: {}` are all read by
278
+ * declaredFields as NOT declared, so none of them can trip assert-consumed — a
279
+ * static service carrying an empty `release` is not a static service that declared
280
+ * a migration. What they buy is that the field name is in the file, next to the
281
+ * service it belongs to, where the agent correcting the draft will see it. A
282
+ * question printed to a terminal is gone the moment the terminal scrolls.
283
+ */
284
+ async function buildDraft(repoDir, { resolver: r, detect }) {
285
+ const { config, stacks } = await draftServices(repoDir, r, detect);
286
+ const candidates = new Map();
287
+ // Decided over ALL services before any one of them is scanned, because whether
288
+ // DATABASE_URL is a secret the author must supply or a value the platform writes
289
+ // depends on it — and a draft that offers DATABASE_URL as a secret for an app
290
+ // about to be handed a provisioned Postgres is asking the author for something
291
+ // they must not answer. Same ordering the parser needs, for the same reason.
292
+ const draftDatabase = config.services.some((s) => usesFor(s).length > 0)
293
+ ? { provider: "managed", engine: "postgres" }
294
+ : undefined;
295
+ const ownedInDraft = (name) => r.platformOwned(name, draftDatabase);
296
+ let wantsDatabase = false;
297
+
298
+ const services = config.services.map((s) => {
299
+ const dir = s.dir ?? ".";
300
+ const absDir = dir === "." ? repoDir : path.join(repoDir, dir);
301
+ const stack = stacks.get(dir);
302
+ const isStatic = s.language === "static";
303
+ const env = envNames(absDir, { platformOwned: ownedInDraft });
304
+ candidates.set(s.name, env);
305
+ wantsDatabase = wantsDatabase || usesFor(s).length > 0;
306
+
307
+ const out = { ...s };
308
+ delete out.needsDB;
309
+
310
+ // Every field below is gated on the lane that will read it, because a draft
311
+ // that trips assert-consumed is a draft whose first act is to fail — and the
312
+ // failure names a field the AUTHOR never wrote. The static lane builds a
313
+ // directory and hands it to a CDN: it has no runtime to pin, no proxy to make
314
+ // a framework aware of, and no credentials to receive. Those still get
315
+ // ANSWERED, one level up, in `resources`.
316
+ if (!isStatic) {
317
+ const runtime = declaredRuntime(absDir, repoDir, s.language);
318
+ if (runtime) out.runtime = runtime;
319
+
320
+ const framework = stack ? frameworkToken(stack.framework) : null;
321
+ if (framework) out.framework = framework;
322
+
323
+ const uses = usesFor(s);
324
+ if (uses.length) out.uses = uses;
325
+
326
+ out.release = "";
327
+ } else {
328
+ delete out.needsDB;
329
+ out.spaFallback = false;
330
+ }
331
+
332
+ if (env.buildEnv.length) out.buildEnv = {};
333
+ out.secrets = [];
334
+ return out;
335
+ });
336
+
337
+ // Stated at app level even when a service also declares `uses`, because they are
338
+ // different claims: `resources` is what gets provisioned, once, and `uses` is
339
+ // which service receives the credentials. Provisioning driven by the primary
340
+ // service alone is how a static frontend on "/" with a Django API on "/api"
341
+ // provisioned nothing at all.
342
+ const resources = config.resources ?? (wantsDatabase ? { database: { engine: "postgres" } } : null);
343
+
344
+ return {
345
+ config: { version: 1, ...(resources ? { resources } : {}), services },
346
+ candidates,
347
+ stacks,
348
+ };
349
+ }
350
+
351
+ /* -------------------------------------------------------------------------- */
352
+ /* Output */
353
+ /* -------------------------------------------------------------------------- */
354
+
355
+ const COUNT_WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"];
356
+
357
+ /** What this service does, in one line: the commands, not the configuration. */
358
+ function summaryOf(s) {
359
+ if (s.dockerfile) return `${s.dockerfile}${s.context && s.context !== "." ? ` (context ${s.context})` : ""}`;
360
+ if (s.language === "static") {
361
+ const steps = [s.install, s.build].filter(Boolean).join(" && ");
362
+ const out = s.outputDir && s.outputDir !== "." ? ` → ${s.outputDir}` : " → this folder";
363
+ return steps ? `${steps}${out}` : `publish${out}`;
364
+ }
365
+ return s.start || "(no start command)";
366
+ }
367
+
368
+ function kindOf(s) {
369
+ if (s.dockerfile) return "docker";
370
+ return s.language ?? "other";
371
+ }
372
+
373
+ /**
374
+ * The questions static analysis cannot answer, and which are live for THIS repo.
375
+ *
376
+ * Conditional on purpose. A block that asks about `spaFallback` on a repo with no
377
+ * static service, or about migrations on one with no database, is a block that
378
+ * gets skipped — and the one question that mattered gets skipped with it.
379
+ */
380
+ function unknownsFor(repoDir, config, candidates) {
381
+ const out = [];
382
+ const servers = config.services.filter((s) => s.language !== "static");
383
+
384
+ // Only when it is genuinely a coin flip. With a frontend and an API, whatever
385
+ // answers a browser owns `/` and that is not a guess; with two servers the
386
+ // detector has no signal at all and picked the first one declared.
387
+ if (config.services.length >= 2 && servers.length === config.services.length) {
388
+ const [first, ...rest] = config.services;
389
+ out.push([
390
+ `is \`${first.name}\` the one on / ?`,
391
+ `(path — ${rest.map((s) => `${s.name} is on ${s.path}`).join(", ")})`,
392
+ ]);
393
+ }
394
+
395
+ for (const s of servers) {
396
+ // The detector hands back `gunicorn ${MODULE}.wsgi` for every Django project
397
+ // and prints "Set ${MODULE} to your Django project package." as a note nobody
398
+ // reads. Unset, the shell expands it to nothing and the container dies on
399
+ // `gunicorn .wsgi` — a placeholder that looks like a command right up until it
400
+ // runs. Not an error here: MODULE is a name the author may yet set. It is a
401
+ // question, which is what this block is for.
402
+ const placeholder = String(s.start ?? "").match(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/);
403
+ if (placeholder) {
404
+ out.push([
405
+ `what is \${${placeholder[1]}} in \`${s.name}\`'s start command?`,
406
+ "(start — the shell expands it to nothing until something sets it)",
407
+ ]);
408
+ }
409
+
410
+ // Asked where a migration is plausible: a service that reaches the database.
411
+ // "Alembic is installed" and "alembic upgrade head SHOULD run before traffic"
412
+ // are different claims and only the author holds the second one.
413
+ if ((s.uses ?? []).includes("database")) {
414
+ out.push([`does \`${s.name}\` need a migration before traffic?`, "(release: …)"]);
415
+ }
416
+ }
417
+
418
+ for (const s of config.services) {
419
+ if (s.language !== "static") continue;
420
+ out.push(["should unknown paths serve index.html?", "(spaFallback)"]);
421
+ // A committed output directory is either the deliverable or last month's
422
+ // build, and the two are the same bytes on disk. Publishing a stale one is a
423
+ // silent wrong SUCCESS, which is worse than a failure.
424
+ const built = s.outputDir && s.outputDir !== "." ? path.join(repoDir, s.dir === "." ? "" : s.dir, s.outputDir) : null;
425
+ if (s.build && built && fs.existsSync(built)) {
426
+ out.push([`is the committed \`${path.relative(repoDir, built)}\` the deliverable, or stale?`, "(it is in the repo already)"]);
427
+ }
428
+ }
429
+
430
+ const build = [...new Set([...candidates.values()].flatMap((c) => c.buildEnv))].sort();
431
+ if (build.length) {
432
+ out.push([
433
+ `what ${build.length === 1 ? "is" : "are"} ${listOf(build, 4)} at BUILD time?`,
434
+ "(buildEnv — a value set after the build never reaches the bundle)",
435
+ ]);
436
+ }
437
+
438
+ const secrets = [...new Set([...candidates.values()].flatMap((c) => c.secrets))].sort();
439
+ if (!secrets.length) out.push(["which secrets does it read?", "(secrets: [])"]);
440
+ else if (secrets.length === 1) out.push([`is ${secrets[0]} a secret?`, "(secrets: [])"]);
441
+ else out.push([`which of ${listOf(secrets, 6)} are secrets?`, "(secrets: [])"]);
442
+
443
+ return out;
444
+ }
445
+
446
+ function pad(s, n) { return s + " ".repeat(Math.max(0, n - s.length)); }
447
+
448
+ /** Names, truncated. A question listing forty variables is a question nobody answers. */
449
+ function listOf(names, limit) {
450
+ return names.slice(0, limit).join(", ") + (names.length > limit ? `, +${names.length - limit} more` : "");
451
+ }
452
+
453
+ /** The whole of what `init` says, as lines. Separated from printing so it can be tested. */
454
+ function renderDraft(configFilename, config, unknowns) {
455
+ const lines = [];
456
+ const n = config.services.length;
457
+ lines.push(`Wrote ${configFilename} — ${n} service${n === 1 ? "" : "s"} detected.`);
458
+ lines.push("");
459
+
460
+ const pathW = Math.max(...config.services.map((s) => (s.path ?? "/").length));
461
+ const nameW = Math.max(...config.services.map((s) => String(s.name ?? "app").length));
462
+ const kindW = Math.max(...config.services.map((s) => kindOf(s).length));
463
+ for (const s of config.services) {
464
+ lines.push(` ${pad(s.path ?? "/", pathW)} ${pad(String(s.name ?? "app"), nameW)} ${pad(kindOf(s), kindW)} ${summaryOf(s)}`);
465
+ }
466
+
467
+ if (unknowns.length) {
468
+ const word = COUNT_WORDS[unknowns.length] ?? String(unknowns.length);
469
+ const askW = Math.max(...unknowns.map(([ask]) => ask.length));
470
+ lines.push("");
471
+ lines.push(`${word} thing${unknowns.length === 1 ? "" : "s"} I could not determine — check ${unknowns.length === 1 ? "it" : "them"}:`);
472
+ for (const [ask, where] of unknowns) lines.push(` · ${pad(ask, askW)} ${where}`);
473
+ }
474
+
475
+ lines.push("");
476
+ // Said last, where it is read. The detector that wrote this is the one that read
477
+ // a frontend/+backend/ root as "Static site, 80% confidence" — its own highest
478
+ // confidence answer. Presenting its output as a finding rather than a draft is
479
+ // how that answer became a deploy.
480
+ lines.push("This is a draft, from a detector that has been confidently wrong before.");
481
+ lines.push(`Read it, fix what is wrong, then: supersonic check`);
482
+ return lines;
483
+ }
484
+
485
+ module.exports = {
486
+ buildDraft, renderDraft, unknownsFor,
487
+ declaredRuntime, frameworkToken, envNames, summaryOf,
488
+ };
package/lib/envfile.js ADDED
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ /**
3
+ * Carrying the project's local `.env` up to the deployed app.
4
+ *
5
+ * `.env` is deliberately kept out of the upload (see packageFolder) — a secret in the
6
+ * tarball ends up in the build bucket and in the image layers, where it cannot be
7
+ * rotated and, until every app has its own runtime identity, can be read by other
8
+ * apps. So the values travel as env vars on the deploy request instead and are set on
9
+ * the service, which is the one place they can be changed or removed later.
10
+ *
11
+ * The rules live here as pure functions: what a `.env` line means, and which of those
12
+ * values have any business being in production.
13
+ */
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
16
+
17
+ // Read in this order; later files win, matching how dotenv-style tooling layers them.
18
+ //
19
+ // `.env.production` belongs here and its absence was the worst of both worlds: the
20
+ // tarball's exclude list did not cover it either, so a project keeping its real
21
+ // values there had them SHIPPED into the build bundle and never applied to the
22
+ // app. Deploying is production, so the production files are the ones whose values
23
+ // should win — and every one of them is excluded from the bundle (see
24
+ // packageFolder), because a value baked into an image cannot be rotated.
25
+ const ENV_FILES = [".env", ".env.production", ".env.local", ".env.production.local"];
26
+
27
+ // Written by the deploy itself and not by anything the app declares: the bucket it
28
+ // provisions and the project it runs in. Unconditional, because nothing in a config
29
+ // can change who supplies them.
30
+ const ALWAYS_SKIPPED = new Set(["STORAGE_BUCKET", "GOOGLE_CLOUD_PROJECT"]);
31
+
32
+ /**
33
+ * The set this file used to own outright, kept as the fallback.
34
+ *
35
+ * It was the second reader of a rule the control plane already had, and the two
36
+ * drifted the way two copies always do — this one listed 6 names against the 17
37
+ * `databaseEnv()` writes. Worse than the drift is that the rule stopped being true:
38
+ * whether DATABASE_URL belongs to the platform depends on whether the platform
39
+ * provisions the database, and this file cannot know that on its own. So callers
40
+ * that can resolve `bay.json` pass the real predicate in, and this remains
41
+ * only for the ones that cannot — where over-refusing is the safe direction, since
42
+ * a skipped variable is reported to the user and a wrongly-sent one silently
43
+ * points a live app at a laptop.
44
+ */
45
+ const PLATFORM_OWNED = new Set([
46
+ "DATABASE_URL",
47
+ "STORAGE_BUCKET",
48
+ "GOOGLE_CLOUD_PROJECT",
49
+ "SUPERSONIC_CODE_BUCKET",
50
+ "SUPERSONIC_CODE_OBJECT",
51
+ "PORT",
52
+ ]);
53
+
54
+ // A value aimed at the developer's own machine is worse than no value: the app starts,
55
+ // then hangs or fails on the first request to something that isn't there.
56
+ const LOCAL_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"];
57
+
58
+ /**
59
+ * Parse one `.env` file's text into a plain object.
60
+ *
61
+ * Handles what people actually write: `export` prefixes, comments, quoted values, and
62
+ * values that span lines (service-account keys and PEM blocks arrive that way).
63
+ * Anything it can't make sense of is skipped rather than guessed at.
64
+ */
65
+ function parseEnv(text) {
66
+ const out = {};
67
+ const lines = String(text).split(/\r?\n/);
68
+ for (let i = 0; i < lines.length; i++) {
69
+ let line = lines[i].trim();
70
+ if (!line || line.startsWith("#")) continue;
71
+ if (line.startsWith("export ")) line = line.slice(7).trim();
72
+ const eq = line.indexOf("=");
73
+ if (eq < 1) continue;
74
+ const key = line.slice(0, eq).trim();
75
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
76
+
77
+ let rest = line.slice(eq + 1).trim();
78
+ const quote = rest[0] === '"' || rest[0] === "'" ? rest[0] : "";
79
+ if (!quote) {
80
+ // Unquoted: an inline comment ends the value, as in `KEY=value # note`.
81
+ const hash = rest.indexOf(" #");
82
+ out[key] = (hash > -1 ? rest.slice(0, hash) : rest).trim();
83
+ continue;
84
+ }
85
+ // Quoted, possibly across several lines — consume until the closing quote.
86
+ rest = rest.slice(1);
87
+ let value = "";
88
+ let closed = false;
89
+ for (;;) {
90
+ const end = rest.indexOf(quote);
91
+ if (end > -1) { value += rest.slice(0, end); closed = true; break; }
92
+ value += rest + "\n";
93
+ if (++i >= lines.length) break;
94
+ rest = lines[i];
95
+ }
96
+ if (!closed) continue; // unterminated quote — the file is malformed, don't guess
97
+ out[key] = quote === '"' ? value.replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\") : value;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** Read the project's env files, layered. Missing or unreadable files are simply absent. */
103
+ function readEnvFiles(cwd, files = ENV_FILES) {
104
+ const merged = {};
105
+ for (const name of files) {
106
+ let text;
107
+ try { text = fs.readFileSync(path.join(cwd, name), "utf8"); } catch { continue; }
108
+ Object.assign(merged, parseEnv(text));
109
+ }
110
+ return merged;
111
+ }
112
+
113
+ /**
114
+ * Decide which of the local values to send.
115
+ *
116
+ * `existingKeys` are the vars the app already has. They are left alone on purpose: the
117
+ * value in production is the one the user set deliberately, and the one in `.env` is
118
+ * very often a test key. Overwriting a live Stripe key with `sk_test_…` on an unrelated
119
+ * redeploy breaks payments silently, which is the worst way for this to fail.
120
+ *
121
+ * `platformOwned` is the control plane's own predicate, injected rather than
122
+ * reimplemented — the same reason `draft.js` takes it. It has to be injected because
123
+ * the answer is no longer a property of the NAME: an app that declares
124
+ * `"provider": "external"` owns DATABASE_URL, and this file dropping it as "set by
125
+ * Bay" would strip the one value that deploy cannot run without, before the
126
+ * server ever sees it. The failure would then arrive as a crash loop about a
127
+ * variable the user had, in fact, set.
128
+ */
129
+ function selectEnv(vars, { existingKeys = [], platformOwned = (k) => PLATFORM_OWNED.has(k) } = {}) {
130
+ const have = new Set(existingKeys);
131
+ const send = {};
132
+ const skipped = [];
133
+ for (const [key, value] of Object.entries(vars)) {
134
+ if (!value) { skipped.push({ key, reason: "empty" }); continue; }
135
+ if (ALWAYS_SKIPPED.has(key) || platformOwned(key)) { skipped.push({ key, reason: "set by Bay" }); continue; }
136
+ if (LOCAL_HOSTS.some((h) => value.includes(h))) { skipped.push({ key, reason: "points at your machine" }); continue; }
137
+ if (have.has(key)) { skipped.push({ key, reason: "already set on the app" }); continue; }
138
+ send[key] = value;
139
+ }
140
+ return { send, skipped };
141
+ }
142
+
143
+ /**
144
+ * Serialize for the deploy request's header.
145
+ *
146
+ * Cloud Run caps request headers, and a `.env` big enough to hit that cap is rare
147
+ * enough that failing the whole deploy over it would be absurd — over the limit we
148
+ * hand back null and the caller falls back to setting the vars after the build.
149
+ */
150
+ const MAX_HEADER_BYTES = 8 * 1024;
151
+
152
+ function encodeEnvHeader(send) {
153
+ if (!send || !Object.keys(send).length) return null;
154
+ const encoded = Buffer.from(JSON.stringify(send), "utf8").toString("base64");
155
+ return Buffer.byteLength(encoded) > MAX_HEADER_BYTES ? null : encoded;
156
+ }
157
+
158
+ module.exports = { parseEnv, readEnvFiles, selectEnv, encodeEnvHeader, ENV_FILES, PLATFORM_OWNED, ALWAYS_SKIPPED, MAX_HEADER_BYTES };