@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/check.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The local dry run: resolve, validate, and say what each phase would run.
|
|
4
|
+
*
|
|
5
|
+
* It fixes zero deploys directly, and it is the highest-leverage thing in this
|
|
6
|
+
* package, because the author of a bay.json is always an agent and its loop
|
|
7
|
+
* today is eleven minutes long — upload, provision, build, fail, read a log,
|
|
8
|
+
* guess. Eleven attempts is two hours of wall clock and a Cloud Build bill for
|
|
9
|
+
* every one. The same eleven attempts against this is twenty seconds, on the
|
|
10
|
+
* user's machine, on the user's tokens.
|
|
11
|
+
*
|
|
12
|
+
* That only holds if this answers the question the server will answer, so nothing
|
|
13
|
+
* here decides anything. It calls resolve() and validate() out of
|
|
14
|
+
* vendor/resolve.js — the control plane's own, compiled — and formats the result.
|
|
15
|
+
* A check that agreed with the server most of the time would be worse than no
|
|
16
|
+
* check at all: it would move the surprise from the first deploy to the tenth.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const path = require("node:path");
|
|
20
|
+
|
|
21
|
+
/** Read a manifest for the runtime gate; absent is not an error, it is silence. */
|
|
22
|
+
function readOr(file, fallback) {
|
|
23
|
+
try { return fs.readFileSync(file, "utf8"); } catch { return fallback; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function jsonOr(file, fallback) {
|
|
27
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return fallback; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve and validate a directory, collecting every problem rather than the first.
|
|
32
|
+
*
|
|
33
|
+
* `validate` already batches its own — a config with four mistakes should cost one
|
|
34
|
+
* round trip, not four — so the only thing added here is the runtime gate, which
|
|
35
|
+
* lives in plan-deps and is checked by the pipeline at deploy-pipeline.ts:1026.
|
|
36
|
+
* Left out, `check` would pass a repo the deploy refuses, which is the one outcome
|
|
37
|
+
* that makes a dry run worth less than nothing.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* One thrown message back into the several problems it reports.
|
|
41
|
+
*
|
|
42
|
+
* `validate` batches deliberately — a config with four mistakes should cost one
|
|
43
|
+
* round trip — and joins them with newlines. But assert-consumed's message is
|
|
44
|
+
* itself three lines, so splitting on every newline turns one problem into three
|
|
45
|
+
* and makes the count a lie. The ✕ is the boundary; a continuation line never
|
|
46
|
+
* carries one.
|
|
47
|
+
*/
|
|
48
|
+
function splitProblems(e) {
|
|
49
|
+
return String(e && e.message ? e.message : e).split(/\n(?=✕)/);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function checkApp(dir, { resolver: r, detect }) {
|
|
53
|
+
const problems = [];
|
|
54
|
+
const warnings = [];
|
|
55
|
+
let app = null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
app = await r.resolve(dir, detect);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return { app: null, problems: splitProblems(e), warnings };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
r.validate(app, dir);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
problems.push(...splitProblems(e));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const s of app.services) {
|
|
70
|
+
const abs = path.join(dir, s.dir);
|
|
71
|
+
// What this service RUNS, through the control plane's own resolver. Attached
|
|
72
|
+
// to the resolved service so `renderService` has nothing to decide.
|
|
73
|
+
//
|
|
74
|
+
// Without this, `check` printed "start — nothing to run: this lane needs one"
|
|
75
|
+
// for a Telegram bot with a perfectly good worker: the declared-but-not-
|
|
76
|
+
// reflected defect, inside the tool built to make that defect visible. And the
|
|
77
|
+
// scale line printed the WEB envelope for an app that has no web process.
|
|
78
|
+
try {
|
|
79
|
+
// Read from the file rather than off the resolved service: ResolvedService
|
|
80
|
+
// carries process NAMES only, deliberately — the kinds and per-kind fields
|
|
81
|
+
// belong to lib/processes.ts, which knows what each primitive accepts.
|
|
82
|
+
const cfg = r.readAppConfig(dir);
|
|
83
|
+
const declared = ((cfg && cfg.services.find((x) => (x.name || "app") === s.name)) || {}).processes;
|
|
84
|
+
s.runs = r.resolveProcesses(r.mergeProcfile(declared, r.readProcfile(abs)));
|
|
85
|
+
s.serviceless = r.isServiceless(s.runs);
|
|
86
|
+
// Resolved here so renderService stays a pure formatter with no resolver in
|
|
87
|
+
// scope — the split that lets it be tested against a literal.
|
|
88
|
+
s.notes = s.runs.flatMap((p) => r.unemittable(p));
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// A malformed process set is a problem to report, not a reason to print
|
|
91
|
+
// nothing: the whole point of a local dry run is to name it in two seconds
|
|
92
|
+
// rather than nine minutes into a build.
|
|
93
|
+
s.runs = [];
|
|
94
|
+
s.serviceless = false;
|
|
95
|
+
s.notes = [];
|
|
96
|
+
problems.push(`✕ service "${s.name}": ${e && e.message ? e.message : String(e)}`);
|
|
97
|
+
}
|
|
98
|
+
// `runtimeMismatch` used to run here and exit 1 on "this app needs Python
|
|
99
|
+
// 3.14 and the runner has 3.12". That sentence stopped being true: the deploy
|
|
100
|
+
// writes a Dockerfile whose `FROM` is the version the repository asked for, so
|
|
101
|
+
// the platform no longer has one Python and one Node to be mismatched against.
|
|
102
|
+
// Keeping the gate would have `check` refusing, locally, deploys the server
|
|
103
|
+
// builds fine — which is the one-rule-two-readers failure this tool exists to
|
|
104
|
+
// catch, committed by the tool itself.
|
|
105
|
+
//
|
|
106
|
+
// What replaces it is the question that IS still worth asking locally: which
|
|
107
|
+
// version will this be built on, and which file said so. Read through the same
|
|
108
|
+
// function the deploy reads it through, so the two cannot disagree.
|
|
109
|
+
try {
|
|
110
|
+
const spec = r.detect(abs, {});
|
|
111
|
+
const primary = spec.toolchains[0];
|
|
112
|
+
if (primary) {
|
|
113
|
+
s.built = {
|
|
114
|
+
language: primary.language,
|
|
115
|
+
version: primary.version,
|
|
116
|
+
versionFrom: primary.versionFrom,
|
|
117
|
+
extra: spec.toolchains.slice(1).map((t) => `${t.language} in ${t.dir}`),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (spec.confidence === "guessed" && !s.start && !s.outputDir) {
|
|
121
|
+
warnings.push(`service "${s.name}": nothing in the repo says how to start it — pass --run "<command>" or add a Procfile`);
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// A malformed version file is exactly what a local dry run is for: the
|
|
125
|
+
// message names the file, which `invalid reference format` in a build log
|
|
126
|
+
// forty lines deep does not.
|
|
127
|
+
problems.push(`✕ service "${s.name}": ${e && e.message ? e.message : String(e)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { app, problems, warnings };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Secrets named in the config that nothing local can supply.
|
|
136
|
+
*
|
|
137
|
+
* A warning, not an error, and the distinction is real: a value already set on the
|
|
138
|
+
* deployed app is invisible from here, so failing on it would refuse a correct
|
|
139
|
+
* config. `envNeeded` had the opposite bug — the platform knew the name was
|
|
140
|
+
* required, logged it to nobody, and deployed anyway, so the first anyone heard was
|
|
141
|
+
* a crash inside the customer's app.
|
|
142
|
+
*/
|
|
143
|
+
function secretWarnings(r, app, available) {
|
|
144
|
+
const missing = r.missingSecrets(app, available);
|
|
145
|
+
if (!missing.length) return [];
|
|
146
|
+
return [`${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} declared under \`secrets\` and set nowhere here — bay env <app> set, or a .env`];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const PHASE_WIDTH = 10;
|
|
150
|
+
|
|
151
|
+
function phase(name, value) {
|
|
152
|
+
return ` ${name.padEnd(PHASE_WIDTH)}${value}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Per service, exactly what each phase would run.
|
|
157
|
+
*
|
|
158
|
+
* The commands are printed as resolved — subshell-wrapped, `$PORT` already
|
|
159
|
+
* substituted — because the wrapping is where a real bug lived: each command was
|
|
160
|
+
* prefixed independently and the lanes disagreed about how they ran them, so a
|
|
161
|
+
* bare `cd frontend && …` installed correctly and then failed with `cd: frontend:
|
|
162
|
+
* No such file or directory`. Printing the pretty version would hide the only
|
|
163
|
+
* detail worth checking.
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* One process, as the line a person needs: what runs it, and how much of it.
|
|
167
|
+
*
|
|
168
|
+
* Per kind rather than one format, because the kinds genuinely differ — an
|
|
169
|
+
* instance count is meaningless for a job and a schedule is meaningless for a
|
|
170
|
+
* worker. The same reason lib/processes.ts does not share one `Scale`.
|
|
171
|
+
*/
|
|
172
|
+
function describeProcess(p) {
|
|
173
|
+
const size = `${p.memory || (p.scale && p.scale.memory)} · ${p.cpu || (p.scale && p.scale.cpu)} cpu`;
|
|
174
|
+
if (p.kind === "web") return `${p.command} · ${p.visibility}`;
|
|
175
|
+
if (p.kind === "worker") return `${p.command} · ${size} · ${p.instances} instance${p.instances === 1 ? "" : "s"}`;
|
|
176
|
+
if (p.kind === "cron") return `${p.command} · ${p.schedule}${p.timezone ? ` ${p.timezone}` : " UTC"} · ${size}`;
|
|
177
|
+
return `${p.command} · runs once before traffic · ${size}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderService(s) {
|
|
181
|
+
const lines = [` ${s.name} · ${s.lane} lane · ${s.path}`];
|
|
182
|
+
if (s.runtime) lines.push(phase("runtime", s.runtime));
|
|
183
|
+
// The version the image will actually be built on, and which file chose it.
|
|
184
|
+
// "platform default" is the case worth seeing, because it is the only one the
|
|
185
|
+
// author did not pick — and the only one that moves when we move it.
|
|
186
|
+
if (s.built) {
|
|
187
|
+
lines.push(phase("base", `${s.built.language}:${s.built.version} · ${s.built.versionFrom}`));
|
|
188
|
+
if (s.built.extra.length) lines.push(phase("also", s.built.extra.join(", ")));
|
|
189
|
+
}
|
|
190
|
+
if (s.dockerfile) lines.push(phase("image", `${s.dockerfile} (context ${s.context})`));
|
|
191
|
+
if (s.install) lines.push(phase("install", s.install));
|
|
192
|
+
if (s.build) lines.push(phase("build", s.build));
|
|
193
|
+
|
|
194
|
+
if (s.lane === "static") {
|
|
195
|
+
lines.push(phase("publish", `${s.outputDir}${s.spaFallback ? " · unknown paths → index.html" : ""}`));
|
|
196
|
+
} else {
|
|
197
|
+
// Printed even when empty: the release phase is the difference between a
|
|
198
|
+
// migration running once before traffic and running on every cold start and
|
|
199
|
+
// every scale-out instance, concurrently. A blank line here is a fact about
|
|
200
|
+
// this deploy, not a missing one.
|
|
201
|
+
lines.push(phase("release", s.release || "— (nothing runs before traffic)"));
|
|
202
|
+
// The web half of the app, printed only when there IS one. A worker-only app
|
|
203
|
+
// has no start command, no health path and no request timeout, and printing
|
|
204
|
+
// the defaults for all three would describe a service that is not deployed.
|
|
205
|
+
if (!s.serviceless) {
|
|
206
|
+
lines.push(phase("start", s.lane === "container"
|
|
207
|
+
? "the Dockerfile's own CMD"
|
|
208
|
+
: s.start || (s.runs.length ? "— (the web process below)" : "— (nothing to run: this lane needs one)")));
|
|
209
|
+
// The web process's own health check is what the deploy probes, so it is
|
|
210
|
+
// what this line has to show. Printing the service-level default beside a
|
|
211
|
+
// `processes.web.health` that overrides it is the check disagreeing with
|
|
212
|
+
// the deploy about the one thing it exists to predict.
|
|
213
|
+
const web = (s.runs || []).find((p) => p.kind === "web");
|
|
214
|
+
const health = (web && web.health) || s.health;
|
|
215
|
+
lines.push(phase("health", `GET ${health.path} → ${health.expect}`));
|
|
216
|
+
lines.push(phase("scale", `${s.scale.memory} · ${s.scale.cpu} cpu · max ${s.scale.maxInstances} · ${s.scale.timeout}s`));
|
|
217
|
+
}
|
|
218
|
+
// `release` is skipped: it is the phase printed above, not a separate thing.
|
|
219
|
+
// Printing both put "release — nothing runs before traffic" directly over
|
|
220
|
+
// "release python manage.py migrate" — two opposite claims about one deploy.
|
|
221
|
+
for (const p of s.runs || []) if (p.kind !== "release") lines.push(phase(p.kind, describeProcess(p)));
|
|
222
|
+
if (s.serviceless) {
|
|
223
|
+
// Said out loud, because "no URL" is the single most surprising thing about
|
|
224
|
+
// a worker-only deploy and the one most likely to read as a failure.
|
|
225
|
+
lines.push(phase("", "no web process — this app gets no URL"));
|
|
226
|
+
}
|
|
227
|
+
for (const n of s.notes || []) lines.push(phase("", `! ${n}`));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (s.uses.length) lines.push(phase("uses", s.uses.join(", ")));
|
|
231
|
+
if (Object.keys(s.env).length) lines.push(phase("env", Object.keys(s.env).join(", ")));
|
|
232
|
+
if (Object.keys(s.buildEnv).length) lines.push(phase("buildEnv", Object.keys(s.buildEnv).join(", ")));
|
|
233
|
+
if (s.secrets.length) lines.push(phase("secrets", s.secrets.join(", ")));
|
|
234
|
+
if (s.framework) lines.push(phase("framework", s.framework));
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderCheck(configFilename, app, problems, warnings) {
|
|
239
|
+
const lines = [];
|
|
240
|
+
|
|
241
|
+
if (app) {
|
|
242
|
+
const n = app.services.length;
|
|
243
|
+
// "provisions postgres" for a database the deploy will not create is the same
|
|
244
|
+
// class of lie as printing a config filename for an inferred app — it sends
|
|
245
|
+
// someone looking for an instance that is not there, and it hides the one
|
|
246
|
+
// thing they need to have set.
|
|
247
|
+
const db = app.resources.database;
|
|
248
|
+
const external = db && db.provider === "external" ? db : null;
|
|
249
|
+
const provisioned = [
|
|
250
|
+
db && !external ? "postgres" : null,
|
|
251
|
+
app.resources.bucket ? "bucket" : null,
|
|
252
|
+
].filter(Boolean);
|
|
253
|
+
// Which of the two sources this came from, always. "Plan ready:
|
|
254
|
+
// bay.json" printed for an inferred app sends someone looking for a
|
|
255
|
+
// file that is not there.
|
|
256
|
+
const from = app.source === "config"
|
|
257
|
+
? configFilename
|
|
258
|
+
: `inferred — there is no ${configFilename} (\`bay init\` writes one)`;
|
|
259
|
+
const yours = external ? `, uses your own ${external.engine || "database"} from ${external.urlFrom}` : "";
|
|
260
|
+
lines.push(`${from} — ${n} service${n === 1 ? "" : "s"}${provisioned.length ? `, provisions ${provisioned.join(" + ")}` : ""}${yours}`);
|
|
261
|
+
lines.push("");
|
|
262
|
+
for (const s of app.services) lines.push(...renderService(s), "");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const w of warnings) lines.push(`! ${w}`);
|
|
266
|
+
if (warnings.length) lines.push("");
|
|
267
|
+
|
|
268
|
+
if (problems.length) {
|
|
269
|
+
for (const p of problems) {
|
|
270
|
+
const [head, ...rest] = p.split("\n");
|
|
271
|
+
lines.push(head.startsWith("✕") ? head : `✕ ${head}`, ...rest);
|
|
272
|
+
}
|
|
273
|
+
lines.push("");
|
|
274
|
+
lines.push(`${problems.length} problem${problems.length === 1 ? "" : "s"} — nothing was deployed, and nothing would be.`);
|
|
275
|
+
} else {
|
|
276
|
+
lines.push("✓ nothing here fails before the build. No GCP was touched.");
|
|
277
|
+
}
|
|
278
|
+
return lines;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = { checkApp, renderCheck, renderService, secretWarnings };
|
package/lib/confirm.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a destructive command may proceed, and what to say when it may not.
|
|
5
|
+
*
|
|
6
|
+
* This CLI is "designed for agents, not humans: no interactive prompts" — the
|
|
7
|
+
* first paragraph of index.js says so — which removes the usual answer to "are
|
|
8
|
+
* you sure". A prompt cannot be the safety, so an explicit flag is: the caller
|
|
9
|
+
* has to name the app AND say `--yes`, and the refusal tells them the exact line
|
|
10
|
+
* to run.
|
|
11
|
+
*
|
|
12
|
+
* That is also the right shape for the agent this is built for. A prompt is
|
|
13
|
+
* something an agent works around; a flag is something it has to mean.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} app the app named on the command line
|
|
18
|
+
* @param {{yes?: unknown}} args
|
|
19
|
+
* @returns {string|null} the refusal to print, or null if it may proceed
|
|
20
|
+
*/
|
|
21
|
+
function deletionRefusal(app, args) {
|
|
22
|
+
// `--yes` alone parses to `true`; `--yes <app>` parses to the string, because
|
|
23
|
+
// the parser takes the next token as a value when it is not another flag.
|
|
24
|
+
// Both are accepted — a caller who wrote the app name twice meant it at least
|
|
25
|
+
// as much as one who wrote it once.
|
|
26
|
+
const said = args.yes;
|
|
27
|
+
if (said === true || (typeof said === "string" && said === app)) return null;
|
|
28
|
+
|
|
29
|
+
// A DIFFERENT app name is refused rather than ignored, and this is the case
|
|
30
|
+
// worth having a branch for: `bay delete api --yes web` is somebody
|
|
31
|
+
// editing a previous command and changing one of the two names. Proceeding
|
|
32
|
+
// would delete `api` on the strength of a confirmation that says `web`.
|
|
33
|
+
if (typeof said === "string" && said !== app) {
|
|
34
|
+
return `refusing: you named ${app} but confirmed ${said}. If you mean ${app}, run\n bay delete ${app} --yes`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return [
|
|
38
|
+
`${app} would be deleted, and so would its DATA: the database, the storage`,
|
|
39
|
+
"bucket, the images and the deploy history all go with it.",
|
|
40
|
+
"",
|
|
41
|
+
"This cannot be undone, and there is no prompt to say yes to — run",
|
|
42
|
+
` bay delete ${app} --yes`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { deletionRefusal };
|