@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/CHANGELOG.md +224 -0
- package/README.md +53 -0
- package/index.js +1269 -0
- package/lib/brand.js +124 -0
- package/lib/bundle.js +399 -0
- package/lib/check.js +281 -0
- package/lib/confirm.js +46 -0
- package/lib/draft.js +488 -0
- package/lib/envfile.js +158 -0
- package/lib/exec-args.js +40 -0
- package/lib/prebuilt.js +86 -0
- package/lib/resolver.js +63 -0
- package/lib/who.js +14 -0
- package/package.json +48 -0
- package/vendor/README.md +6 -0
- package/vendor/detector.js +423 -0
- package/vendor/inputs.json +21 -0
- package/vendor/resolve.js +2156 -0
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 };
|