@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/index.js ADDED
@@ -0,0 +1,1269 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /*
5
+ * bay — the deploy/debug surface for a coding agent.
6
+ * Designed for agents, not humans: no interactive prompts, --json everywhere,
7
+ * token auth (a human logs in once, the agent inherits the token), stdout=data,
8
+ * stderr=logs, meaningful exit codes.
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const os = require("os");
13
+ const path = require("path");
14
+ const http = require("http");
15
+ const { spawn, spawnSync } = require("child_process");
16
+ const { readEnvFiles, selectEnv, encodeEnvHeader } = require("./lib/envfile");
17
+ const { joinExecArgs } = require("./lib/exec-args");
18
+ const { whoHeader } = require("./lib/who");
19
+ const { deletionRefusal } = require("./lib/confirm");
20
+
21
+ const brand = require("./lib/brand");
22
+
23
+ // Copy an existing sign-in across to the new path, once, before anything reads
24
+ // it. See lib/brand.js: a published CLI lives on other people's machines until
25
+ // they upgrade, so nothing here may simply be renamed.
26
+ brand.migrateConfig();
27
+
28
+ const CFG_DIR = brand.configDir();
29
+ const CFG = path.join(CFG_DIR, "config.json");
30
+ const DEFAULT_URL = brand.DEFAULT_URL;
31
+
32
+ // ---------- output ----------
33
+ // A reader that stops reading — `bay check | head -5`, an agent piping into
34
+ // grep — closes the pipe under us, and Node turns that into an unhandled EPIPE:
35
+ // twenty lines of stack trace printed over the output that was actually asked for,
36
+ // and a non-zero exit that reads as the command having failed. It did not; the
37
+ // reader got what it wanted and left.
38
+ for (const s of [process.stdout, process.stderr]) s.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); });
39
+
40
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
41
+ const c = (n) => (s) => (COLOR ? `\x1b[${n}m${s}\x1b[0m` : String(s));
42
+ const dim = c("2"), bold = c("1"), green = c("32"), red = c("31"), cyan = c("36"), yellow = c("33");
43
+ // Set once a deploy knows its slug: every progress line is also appended to
44
+ // ~/.bay/deploys/<slug>.log, so a build survives the terminal it was
45
+ // started in. Best-effort — a log that cannot be written must never stop a deploy.
46
+ let deployLog = null;
47
+ function startDeployLog(slug) {
48
+ try {
49
+ const dir = path.join(CFG_DIR, "deploys");
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ deployLog = fs.openSync(path.join(dir, `${slug}.log`), "a");
52
+ } catch { deployLog = null; }
53
+ }
54
+ function info(s) {
55
+ process.stderr.write(s + "\n"); // logs -> stderr
56
+ // eslint-disable-next-line no-control-regex
57
+ if (deployLog !== null) { try { fs.writeSync(deployLog, s.replace(/\x1b\[[0-9;]*m/g, "") + "\n"); } catch { /* full disk, etc. */ } }
58
+ }
59
+ function print(s) { process.stdout.write(s + "\n"); } // data -> stdout
60
+ function die(s, code = 1) { process.stderr.write(red("✗ ") + s + "\n"); process.exit(code); }
61
+
62
+ /** What the last green deploy decided, written beside the project. */
63
+ // Written under the new name, read under either: a repository that already has
64
+ // `supersonic.lock.json` must keep being understood, or the next deploy decides
65
+ // everything again from scratch and quietly disagrees with the file sitting
66
+ // next to it. See lib/brand.js.
67
+ const LOCKFILE = "bay.lock.json";
68
+ function lockfilePath() { return brand.projectFile(process.cwd(), "lock.json"); }
69
+
70
+ /**
71
+ * Record the decisions a successful deploy made.
72
+ *
73
+ * Written HERE and not by the server, because the server has a clone and the
74
+ * clone is not your folder. That is also why a `--github` deploy gets nothing:
75
+ * there is no working tree on this machine to write into, and inventing one
76
+ * would put a file in whatever directory the command happened to run from.
77
+ *
78
+ * A lockfile, not a form. A first deploy on a bare folder still requires nothing;
79
+ * this appears only after something has worked, and it says what was chosen —
80
+ * which for `versionFrom: "platform default"` is the only line the author did not
81
+ * choose and the only one that can move under them.
82
+ *
83
+ * A SIDECAR rather than fields in supersonic.json: `parseAppConfig` has a fixed
84
+ * key list and silently drops what it does not know, so writing there would be
85
+ * committing the accepted-and-ignored defect while claiming to document against
86
+ * it.
87
+ *
88
+ * Best-effort throughout. A read-only directory, a permissions error, a
89
+ * `--prebuilt` deploy with nothing to record: none of them is a reason to turn a
90
+ * successful deploy into a failure at the very last step.
91
+ */
92
+ function writeLockfile(decided, slug) {
93
+ if (!decided || typeof decided !== "object") return;
94
+ try {
95
+ const body = JSON.stringify({
96
+ $comment: "Written by bay after a successful deploy. Safe to commit, safe to delete.",
97
+ slug: slug || undefined,
98
+ decided,
99
+ }, null, 2) + "\n";
100
+ const path = lockfilePath();
101
+ if (require("node:fs").existsSync(path) && require("node:fs").readFileSync(path, "utf8") === body) return;
102
+ require("node:fs").writeFileSync(path, body);
103
+ info(dim(` wrote ${LOCKFILE} — what this deploy decided, so you can see it and change it`));
104
+ } catch { /* never fail a green deploy over a note about it */ }
105
+ }
106
+ function json(o) { print(JSON.stringify(o, null, 2)); }
107
+
108
+ // ---------- config ----------
109
+ function loadCfg() { try { return JSON.parse(fs.readFileSync(CFG, "utf8")); } catch { return {}; } }
110
+ function saveCfg(cfg) { fs.mkdirSync(CFG_DIR, { recursive: true }); fs.writeFileSync(CFG, JSON.stringify(cfg, null, 2)); }
111
+ function baseUrl() { return (brand.envAny("URL") || loadCfg().url || DEFAULT_URL).replace(/\/$/, ""); }
112
+ function token() { return brand.envAny("TOKEN") || loadCfg().token || ""; }
113
+
114
+ // ---------- api ----------
115
+ async function api(pathname, { method = "GET", body, stream = false } = {}) {
116
+ const tok = token();
117
+ if (!tok) die("not authenticated — run: bay login");
118
+ const res = await fetch(baseUrl() + pathname, {
119
+ method,
120
+ headers: {
121
+ Authorization: "Bearer " + tok,
122
+ ...brand.protoHeaders("who", whoHeader(process.env)),
123
+ ...(body ? { "Content-Type": "application/json" } : {}),
124
+ },
125
+ body: body ? JSON.stringify(body) : undefined,
126
+ });
127
+ if (res.status === 401) die("token invalid or expired — run: bay login");
128
+ if (res.status === 403) die("forbidden — you don't own that app (or it doesn't exist)");
129
+ if (stream) return res;
130
+ const data = await res.json().catch(() => ({}));
131
+ if (!res.ok && data.error) die(data.error);
132
+ if (data.error) info(dim("! " + data.error));
133
+ return data;
134
+ }
135
+
136
+ // ---------- browser ----------
137
+ function openBrowser(url) {
138
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
139
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
140
+ try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); return true; } catch { return false; }
141
+ }
142
+
143
+ // ---------- commands ----------
144
+ async function login(args) {
145
+ const url = (args.url || brand.envAny("URL") || DEFAULT_URL).replace(/\/$/, "");
146
+ // Explicit token (agents / CI / headless).
147
+ if (args.token) {
148
+ const ok = await validToken(url, String(args.token));
149
+ if (!ok) return die("that token was rejected");
150
+ saveCfg({ ...loadCfg(), url, token: String(args.token) });
151
+ print(green("✓ ") + `logged in to ${url}`);
152
+ process.exit(0);
153
+ }
154
+ await loopbackAuth(url, "/cli", "logged in");
155
+ }
156
+
157
+ // Create an account without leaving the terminal: opens the browser to sign up,
158
+ // then the web hands a token back and the agent can continue straight to deploy.
159
+ async function signup(args) {
160
+ const url = (args.url || brand.envAny("URL") || DEFAULT_URL).replace(/\/$/, "");
161
+ await loopbackAuth(url, "/signup", "signed up");
162
+ }
163
+
164
+ // Browser loopback core: open the web at `startPath`, spin up a local server,
165
+ // and resolve with the CLI token the web hands back (or null on timeout). Does
166
+ // NOT save/print/exit — callers decide, so `deploy` can auto-auth and continue.
167
+ async function runLoopback(url, startPath) {
168
+ const server = http.createServer();
169
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
170
+ const port = server.address().port;
171
+ const authUrl = `${url}${startPath}?port=${port}&name=${encodeURIComponent(os.hostname())}`;
172
+
173
+ const done = new Promise((resolve) => {
174
+ server.on("request", (req, res) => {
175
+ const u = new URL(req.url, `http://127.0.0.1:${port}`);
176
+ if (u.pathname !== "/callback") { res.writeHead(404); res.end(); return; }
177
+ const tok = u.searchParams.get("token") || "";
178
+ res.writeHead(200, { "Content-Type": "text/html" });
179
+ res.end("<body style='font-family:monospace;text-align:center;padding-top:20vh'><h2>&#10003; Bay CLI connected</h2><p>You can close this tab and return to your terminal.</p></body>");
180
+ resolve(tok);
181
+ });
182
+ });
183
+
184
+ info(`Opening browser…\n${dim(authUrl)}`);
185
+ if (!openBrowser(authUrl)) info("Couldn't open a browser automatically — open the URL above manually.");
186
+
187
+ let timer;
188
+ const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error("timed out")), 300000); });
189
+ const shutdown = () => { clearTimeout(timer); try { server.closeAllConnections?.(); } catch { /* noop */ } server.close(); };
190
+ try {
191
+ const tok = await Promise.race([done, timeout]);
192
+ shutdown();
193
+ return tok || null;
194
+ } catch {
195
+ shutdown();
196
+ return null;
197
+ }
198
+ }
199
+
200
+ // The `login`/`signup` wrapper: authenticate, then report and exit.
201
+ async function loopbackAuth(url, startPath, verb) {
202
+ const tok = await runLoopback(url, startPath);
203
+ if (!tok) return die(`${verb} timed out — try again, or use: bay login --token <token>`);
204
+ saveCfg({ ...loadCfg(), url, token: tok });
205
+ print(green("✓ ") + `${verb} to ${url}`);
206
+ process.exit(0);
207
+ }
208
+
209
+ // Make `deploy` (and other primary commands) a single command: if there's no
210
+ // token, sign the human in via the browser once, then keep going.
211
+ async function ensureAuth() {
212
+ if (token()) return;
213
+ const url = baseUrl();
214
+ info(dim("Not signed in — opening a browser to sign in (just this once)…"));
215
+ const tok = await runLoopback(url, "/cli");
216
+ if (!tok) die("sign-in timed out — run `bay login`, then `bay deploy` again");
217
+ saveCfg({ ...loadCfg(), url, token: tok });
218
+ info(green("✓ ") + "signed in — continuing…");
219
+ }
220
+
221
+ // Prove a token is valid by hitting any authorized endpoint (200 == good).
222
+ async function validToken(url, tok) {
223
+ try {
224
+ const r = await fetch(url.replace(/\/$/, "") + "/api/apps", { headers: { Authorization: "Bearer " + tok } });
225
+ return r.ok;
226
+ } catch { return false; }
227
+ }
228
+
229
+ async function whoami(args) {
230
+ const tok = token();
231
+ if (!tok) { if (args.json) return json({ loggedIn: false }); print("not logged in"); return; }
232
+ // Resolve the actual account (email + plan) so you can see WHO you are, not
233
+ // just that a token exists.
234
+ const res = await fetch(baseUrl() + "/api/account", { headers: { Authorization: "Bearer " + tok } });
235
+ const acct = res.ok ? await res.json().catch(() => ({})) : {};
236
+ if (args.json) return json({ loggedIn: res.ok, url: baseUrl(), email: acct.email || null, plan: acct.plan || null, source: brand.envAny("TOKEN") ? "env" : "config" });
237
+ if (!res.ok) return die("token invalid — run: bay login");
238
+ const src = process.env.BAY_TOKEN ? "env token" : "saved token";
239
+ const who = acct.email ? " as " + acct.email : "";
240
+ const plan = acct.plan ? ` · ${acct.plan}` : "";
241
+ print(`logged in to ${baseUrl()}${who}${plan} (${src})`);
242
+ }
243
+
244
+ function logout() {
245
+ try { const cfg = loadCfg(); delete cfg.token; delete cfg.email; saveCfg(cfg); } catch { /* ignore */ }
246
+ print("logged out");
247
+ }
248
+
249
+ async function apps(args) {
250
+ const d = await api("/api/apps");
251
+ const list = d.apps || [];
252
+ if (args.json) return json(list);
253
+ if (!list.length) { info("no apps yet — deploy one with: bay deploy"); return; }
254
+ for (const a of list) {
255
+ // The list endpoint has always sent `status: "building"` for a deploy in
256
+ // flight and the CLI has always thrown it away, so an app that was building
257
+ // normally appeared here as a dead one — the same "ready or down" flattening
258
+ // that `status` had.
259
+ const building = a.status === "building";
260
+ const dot = a.ready ? green("●") : building ? yellow("◐") : red("○");
261
+ const note = building ? dim(` ${a.stage || "deploying…"}`) : "";
262
+ print(`${dot} ${bold(a.slug.padEnd(22))} ${dim(a.url || `${a.slug}.thebay.cloud`)}${note}`);
263
+ }
264
+ }
265
+
266
+ async function status(args) {
267
+ const app = needApp(args);
268
+ const d = await api(`/api/apps/${app}`);
269
+ if (args.json) return json(d);
270
+ // Three states, because there are three. Reporting a deploy in progress as
271
+ // "down" is how `status` came to say `○ down · revision — · env none` about an
272
+ // app that was building normally and whose URL was answering 200 — a confident
273
+ // answer to a question it could not yet answer.
274
+ const dot = d.ready ? green("● live") : d.deploying ? yellow("◐ deploying") : red("○ down");
275
+ print(`${bold(app)} ${dot}${d.deploying && d.stage ? dim(` · ${d.stage}`) : ""}`);
276
+ print(dim(" url ") + `${app}.thebay.cloud`);
277
+ print(dim(" revision ") + (d.revision || "—"));
278
+ print(dim(" image ") + (d.image ? d.image.split("/").pop() : "—"));
279
+ print(dim(" region ") + (d.region || "—"));
280
+ print(dim(" database ") + (d.cloudsql ? d.cloudsql.split(":").pop() : "none"));
281
+ print(dim(" env ") + (d.envKeys && d.envKeys.length ? d.envKeys.join(", ") : "none"));
282
+ print(dim(" repo ") + (d.repo || "—"));
283
+ // An app is not always one program. A worker, a cron and a release are
284
+ // invisible everywhere else in this command, and "is my worker running" is
285
+ // the question people actually ask.
286
+ if (d.processes && d.processes.length) {
287
+ const line = d.processes
288
+ .map((p) => `${p.name}(${p.kind})${p.kind === "web" || p.kind === "worker" ? (p.running ? " ✓" : " ✗") : ""}`)
289
+ .join(" ");
290
+ print(dim(" runs ") + line);
291
+ }
292
+ }
293
+
294
+ async function logs(args) {
295
+ const app = needApp(args);
296
+ const qs = new URLSearchParams();
297
+ if (args.severity) qs.set("severity", args.severity);
298
+ if (args.limit) qs.set("limit", String(args.limit));
299
+ if (args.since) qs.set("since", args.since);
300
+ const q = qs.toString() ? "?" + qs.toString() : "";
301
+
302
+ if (args.follow) {
303
+ let seen = new Set();
304
+ info(dim(`tailing ${app} — ctrl-c to stop`));
305
+ for (;;) {
306
+ const d = await api(`/api/apps/${app}/logs${q}`);
307
+ for (const l of d.logs || []) {
308
+ const key = l.time + l.message;
309
+ if (seen.has(key)) continue;
310
+ seen.add(key);
311
+ print(logLine(l));
312
+ }
313
+ if (seen.size > 2000) seen = new Set();
314
+ await new Promise((r) => setTimeout(r, 2500));
315
+ }
316
+ }
317
+
318
+ const d = await api(`/api/apps/${app}/logs${q}`);
319
+ if (args.json) return json(d.logs || []);
320
+ if (!(d.logs || []).length) { info("no logs in that window"); return; }
321
+ for (const l of d.logs) print(logLine(l));
322
+ }
323
+
324
+ function logLine(l) {
325
+ const sev = (l.severity || "").toUpperCase();
326
+ const tag = sev === "ERROR" || sev === "CRITICAL" ? red(sev) : sev === "WARNING" ? cyan(sev) : dim(sev || "INFO");
327
+ return `${dim((l.time || "").slice(11, 19))} ${tag} ${l.message}`;
328
+ }
329
+
330
+ async function errors(args) {
331
+ const app = needApp(args);
332
+ const d = await api(`/api/apps/${app}/errors`);
333
+ const list = d.errors || [];
334
+ if (args.json) return json(list);
335
+ if (!list.length) { print(green("✓ no production errors in the last 7 days")); return; }
336
+ for (const e of list) print(`${red("✗")} ${dim((e.time || "").slice(0, 19))} ${e.message}`);
337
+ }
338
+
339
+ async function diagnose(args) {
340
+ const app = needApp(args);
341
+ info(dim("analyzing — reading logs + repo…"));
342
+ const d = await api(`/api/apps/${app}/diagnose`, { method: "POST", body: { error: args.error } });
343
+ if (args.json) return json(d);
344
+ if (d.healthy) { print(green("✓ ") + d.message); return; }
345
+ info(dim("diagnosing: ") + (d.subject || d.error || "").slice(0, 200));
346
+ print("");
347
+ print(bold("Fix prompt (paste into your coding agent):"));
348
+ print(d.fixPrompt || "(no prompt returned)");
349
+ }
350
+
351
+ async function env(args) {
352
+ const app = needApp(args);
353
+ const [, sub, ...rest] = args._; // _[0] is the app; subverb + rest follow
354
+ if (!sub) {
355
+ const d = await api(`/api/apps/${app}/env`);
356
+ if (args.json) return json(d.keys || []);
357
+ if (!(d.keys || []).length) { info("no env vars set"); return; }
358
+ for (const k of d.keys) print(k);
359
+ return;
360
+ }
361
+ if (sub === "set") {
362
+ const set = {};
363
+ for (const kv of rest) { const i = kv.indexOf("="); if (i > 0) set[kv.slice(0, i)] = kv.slice(i + 1); }
364
+ if (!Object.keys(set).length) die("usage: bay env <app> set KEY=VALUE [KEY2=VALUE2]");
365
+ const d = await api(`/api/apps/${app}/env`, { method: "POST", body: { set } });
366
+ // "new revision" is Cloud Run's word, and an app on a node has none — its
367
+ // process is restarted in place. Worse, it was printed unconditionally, so
368
+ // it announced a rollout for a write that had changed nothing. The server
369
+ // says what actually happened; this repeats it rather than guessing.
370
+ print(green("✓ ") + `set ${Object.keys(set).join(", ")}${d?.note ? ` — ${d.note}` : ""}`);
371
+ if (args.json) json(d);
372
+ return;
373
+ }
374
+ if (sub === "unset") {
375
+ if (!rest.length) die("usage: bay env <app> unset KEY [KEY2]");
376
+ const d = await api(`/api/apps/${app}/env`, { method: "POST", body: { unset: rest } });
377
+ print(green("✓ ") + `unset ${rest.join(", ")}${d?.note ? ` — ${d.note}` : ""}`);
378
+ if (args.json) json(d);
379
+ return;
380
+ }
381
+ die(`unknown env subcommand: ${sub}`);
382
+ }
383
+
384
+ /**
385
+ * The repair agent's fix, as a patch, on stdout and nothing else.
386
+ *
387
+ * The agent's edits happen in the copy of the repo the server unpacked, which is
388
+ * deleted when the deploy ends — so a rescued app left this folder still broken
389
+ * and the next deploy shipped the same code again. Straight to stdout so it can
390
+ * be piped: `bay patch <app> | git apply`. Every other word this command
391
+ * says goes to stderr, so the pipe carries the patch alone.
392
+ */
393
+ async function patch(args) {
394
+ const app = needApp(args);
395
+ const res = await api(`/api/apps/${app}/patch`, { stream: true });
396
+ const body = await res.text();
397
+ if (res.status === 404) { info(dim(body.trim())); process.exit(1); }
398
+ if (!res.ok) die(body.trim() || `could not fetch the patch (${res.status})`);
399
+ process.stdout.write(body);
400
+ }
401
+
402
+ async function rollback(args) {
403
+ const app = needApp(args);
404
+ const d = await api(`/api/apps/${app}/rollback`, { method: "POST" });
405
+ if (args.json) return json(d);
406
+ // `revision` was Cloud Run's word for it and there are no revisions any more:
407
+ // a rollback is one write moving `desired_release` to the version before, and
408
+ // the reconciler places it. Printing `d.revision` here said "now serving
409
+ // undefined" the moment the route stopped being Cloud-Run-shaped.
410
+ print(green("✓ ") + `rolled back to version ${d.version} — the fleet is placing it now`);
411
+ // Said every time, because it is the half a rollback cannot do. The API
412
+ // returns the same sentence; printing the server's own wording keeps the two
413
+ // from drifting into different promises.
414
+ if (d.note) print(dim(" " + d.note));
415
+ }
416
+
417
+ /**
418
+ * Delete an app. There is no undo and there is no prompt.
419
+ *
420
+ * The confirmation is a flag rather than a question because this CLI has none —
421
+ * "designed for agents, not humans" is the first thing index.js says about
422
+ * itself. `lib/confirm.js` holds the decision and the wording; this function is
423
+ * the call.
424
+ */
425
+ async function del(args) {
426
+ const app = needApp(args);
427
+ const refusal = deletionRefusal(app, args);
428
+ if (refusal) die(refusal);
429
+ const d = await api(`/api/apps/${app}/delete`, { method: "POST" });
430
+ if (args.json) return json(d);
431
+ print(green("✓ ") + `${app} deleted — its database, bucket and images went with it`);
432
+ }
433
+
434
+ async function exec(args) {
435
+ const app = needApp(args);
436
+ const command = joinExecArgs(args._raw || []);
437
+ if (!command) die('usage: bay exec <app> -- <command> e.g. bay exec myapp -- node -v');
438
+ info(dim(`exec in ${app} (isolated instance, app env + db attached)`));
439
+ info(dim("cold-starting a one-off container — can take ~30–60s…"));
440
+ const d = await api(`/api/apps/${app}/exec`, { method: "POST", body: { command } });
441
+ if (args.json) return json(d);
442
+ if (d.output) print(d.output);
443
+ else info(dim("(no output)"));
444
+ if (d.exitCode) { info(red(`exited ${d.exitCode}`)); process.exitCode = d.exitCode; }
445
+ }
446
+
447
+ async function open(args) {
448
+ const app = needApp(args);
449
+ const url = `https://${app}.thebay.cloud`;
450
+ info(`opening ${url}`);
451
+ openBrowser(url);
452
+ }
453
+
454
+ /**
455
+ * Write a DRAFT supersonic.json from this repository. No model, no network, ~2s.
456
+ *
457
+ * Deliberately not "generate a config": the file it writes is a first draft for an
458
+ * agent to correct, and the two are different products. Never ask a state-1 agent
459
+ * to produce JSON from nothing — ask it to correct a draft, because review is the
460
+ * thing agents are good at and authoring from an empty file is the thing they are
461
+ * not.
462
+ */
463
+ async function init(args) {
464
+ const { resolver, detector } = require("./lib/resolver");
465
+ const { buildDraft, renderDraft, unknownsFor } = require("./lib/draft");
466
+ const r = resolver();
467
+ const dir = path.resolve(args._[0] || process.cwd());
468
+ const target = path.join(dir, r.CONFIG_FILENAME);
469
+
470
+ // Refusing to overwrite is the whole point of the file existing. A hand-written
471
+ // config is the ONE input the platform is required to obey, and replacing it
472
+ // with a detector's guess would be this command undoing its own reason to exist.
473
+ if (fs.existsSync(target) && !args.force) {
474
+ return die(`${r.CONFIG_FILENAME} already exists — read it, or \`bay init --force\` to overwrite it with a fresh draft`);
475
+ }
476
+
477
+ const { config, candidates } = await buildDraft(dir, { resolver: r, detect: detector() });
478
+ const unknowns = unknownsFor(dir, config, candidates);
479
+ fs.writeFileSync(target, JSON.stringify(config, null, 2) + "\n");
480
+
481
+ if (args.json) {
482
+ return json({
483
+ wrote: r.CONFIG_FILENAME,
484
+ draft: true,
485
+ config,
486
+ undetermined: unknowns.map(([question, field]) => ({ question, field })),
487
+ });
488
+ }
489
+ for (const line of renderDraft(r.CONFIG_FILENAME, config, unknowns)) print(line);
490
+
491
+ // The draft is written whatever happens next — a config that needs a correction
492
+ // is exactly what this command produces — but a draft the resolver would already
493
+ // refuse is worth saying now rather than on the next command.
494
+ const { checkApp } = require("./lib/check");
495
+ const { problems } = await checkApp(dir, { resolver: r, detect: detector() });
496
+ if (problems.length) {
497
+ print("");
498
+ print(yellow("! ") + "and `bay check` would already fail on it:");
499
+ for (const p of problems) print(" " + p);
500
+ }
501
+ }
502
+
503
+ /**
504
+ * The local dry run. resolve() + validate(), nothing else, no cloud.
505
+ *
506
+ * Non-zero on any error, because the caller is an agent in a loop and an exit code
507
+ * is the only part of this output it is guaranteed to read.
508
+ */
509
+ async function check(args) {
510
+ const { resolver, detector } = require("./lib/resolver");
511
+ const { checkApp, renderCheck, secretWarnings } = require("./lib/check");
512
+ const r = resolver();
513
+ const dir = path.resolve(args._[0] || process.cwd());
514
+
515
+ const { app, problems, warnings } = await checkApp(dir, { resolver: r, detect: detector() });
516
+ if (app) {
517
+ // The local .env is not the deployed environment, so this can only warn — but
518
+ // it is the same file `deploy` carries up, which makes it the best available
519
+ // answer to "will this secret have a value when it lands".
520
+ const available = [...Object.keys(readEnvFiles(dir)), ...Object.keys(process.env)];
521
+ warnings.push(...secretWarnings(r, app, available));
522
+ }
523
+
524
+ if (args.json) {
525
+ json({ ok: problems.length === 0, source: app ? app.source : null, services: app ? app.services : [], resources: app ? app.resources : null, problems, warnings });
526
+ } else {
527
+ for (const line of renderCheck(r.CONFIG_FILENAME, app, problems, warnings)) {
528
+ print(problems.length && line.startsWith("✕") ? red(line) : line.startsWith("! ") ? yellow(line) : line);
529
+ }
530
+ }
531
+ if (problems.length) process.exit(1);
532
+ }
533
+
534
+ /**
535
+ * Flags that used to run the app on this machine and tunnel the public URL to it.
536
+ *
537
+ * Named rather than ignored. `parse()` accepts any `--flag` it is handed, so a
538
+ * removed one would be swallowed in silence — and the caller would go on believing
539
+ * a preview was being served from their laptop while the address showed something
540
+ * else entirely. Silence is the wrong answer to "I asked for a thing you no longer
541
+ * do", especially for the agents that make up most of the callers here.
542
+ */
543
+ const REMOVED_DEPLOY_FLAGS = ["dev-cmd", "dev-port", "no-preview"];
544
+
545
+ /**
546
+ * Every flag `ship` understands.
547
+ *
548
+ * `parse()` accepts anything that starts with `--`, which is fine for a command
549
+ * that only reads. It is not fine for this one: a typo'd flag was silently
550
+ * dropped and the deploy went ahead anyway, so `--drt-run` reserved a slug,
551
+ * uploaded a folder and created an app. Found by doing exactly that by accident
552
+ * while testing the alias below.
553
+ *
554
+ * The cost lands on agents hardest. A person sees the app appear; an agent reads
555
+ * "deploying — your app will be live at" and reports success for the thing it did
556
+ * not ask for.
557
+ */
558
+ const SHIP_FLAGS = ["run", "wait", "no-env", "github", "repo", "prebuilt", "json", "help"];
559
+
560
+ async function deploy(args) {
561
+ if (args.help) return usage(true);
562
+ const removed = REMOVED_DEPLOY_FLAGS.filter((f) => args[f] !== undefined);
563
+ if (removed.length) {
564
+ die(
565
+ `${removed.map((f) => "--" + f).join(", ")} ${removed.length > 1 ? "were" : "was"} removed in 0.11.0.\n` +
566
+ " Nothing needs to run on your machine any more: the URL is live the moment you ship\n" +
567
+ " and shows the build itself, then becomes your app. Drop the flag and ship."
568
+ );
569
+ }
570
+ const unknown = Object.keys(args).filter(
571
+ (k) => k !== "_" && k !== "_raw" && k !== "_cmd" && !SHIP_FLAGS.includes(k),
572
+ );
573
+ if (unknown.length) {
574
+ die(
575
+ `${unknown.map((f) => "--" + f).join(", ")} ${unknown.length > 1 ? "are not flags" : "is not a flag"} ` +
576
+ `bay ship understands.\n` +
577
+ ` It takes: ${SHIP_FLAGS.map((f) => "--" + f).join(", ")}\n` +
578
+ " Nothing was shipped. Fix the flag and run it again."
579
+ );
580
+ }
581
+ // One command: sign in automatically the first time, then deploy. No separate
582
+ // `bay login` step required.
583
+ await ensureAuth();
584
+ // URL-first by default: a live link appears in ~0.1s — the address answers with
585
+ // the room, which draws the build as it happens — while the real build runs on
586
+ // the server. `--prebuilt` opts back into the old build-here-and-upload path.
587
+ if (!args.prebuilt) return urlFirstDeploy(args);
588
+ // GitHub / a git URL is a pickable option — the default is straight from this folder.
589
+ if (args.github || args.repo) {
590
+ let repo = args.repo;
591
+ if (!repo) { repo = await gitOrigin(); if (!repo) die("no git remote 'origin' found here — pass --repo <url>"); }
592
+ info(cyan("▸ ") + "deploying from " + bold(repo));
593
+ const res = await api("/api/deploy", { method: "POST", body: { repo }, stream: true });
594
+ return consumeDeploy(res, args);
595
+ }
596
+ // Default: deploy this folder straight from your computer — no git, no setup.
597
+ const appName = path.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "") || "app";
598
+
599
+ // The fast path: your machine already has this project and builds it in seconds, so
600
+ // build here and send only the result. Uploading sources and rebuilding them in the
601
+ // cloud is ~80s; this is ~15s, and nothing at all when the output has not changed.
602
+ if (!args["cloud-build"]) {
603
+ const done = await tryPrebuilt(appName, args);
604
+ if (done !== null) return done;
605
+ }
606
+
607
+ info(cyan("▸ ") + "packaging " + bold(appName) + " from this folder…");
608
+ const tgz = await packageFolder();
609
+ const body = fs.readFileSync(tgz);
610
+ try { fs.unlinkSync(tgz); } catch { /* ignore */ }
611
+ info(dim(`uploading ${(body.length / 1048576).toFixed(1)} MB`));
612
+ const tok = token();
613
+ if (!tok) die("not authenticated — run: bay login");
614
+ const res = await fetch(baseUrl() + "/api/deploy", {
615
+ method: "POST",
616
+ headers: {
617
+ Authorization: "Bearer " + tok,
618
+ "Content-Type": "application/gzip",
619
+ ...brand.protoHeaders("upload", "1"),
620
+ ...brand.protoHeaders("app", appName),
621
+ ...brand.protoHeaders("who", whoHeader(process.env)),
622
+ },
623
+ body,
624
+ });
625
+ if (res.status === 401) die("token invalid or expired — run: bay login");
626
+ if (res.status === 403) die("forbidden");
627
+ if (!res.body) die("no response stream");
628
+ return consumeDeploy(res, args);
629
+ }
630
+
631
+ /**
632
+ * URL-first deploy — a live URL in ~0.1s, the real build behind it. The default.
633
+ *
634
+ * Reserve the slug → print the URL, which is already live and showing the room →
635
+ * run the real build on the server → the same URL becomes the app when it lands.
636
+ * Every stack gets the same thing; nothing has to run locally for the link to be
637
+ * worth sending to somebody.
638
+ */
639
+ async function urlFirstDeploy(args) {
640
+ let repo = args.repo;
641
+ if (args.github && !repo) { repo = await gitOrigin(); }
642
+ const folderName = path.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "") || "app";
643
+
644
+ // 1) reserve the slug → a URL right away (live immediately, any stack)
645
+ const r = await api("/api/deploy/reserve", { method: "POST", body: repo ? { repo } : { name: folderName } });
646
+ const { slug, url } = r;
647
+ // Not "✓ live". Nothing has been built yet — this is the moment the slug was
648
+ // reserved, and the build can still fail. Eight agents in a row read that
649
+ // checkmark as "done", stopped watching, and reported a working deploy for an
650
+ // app that never came up. The URL is real and does work from here (it serves a
651
+ // build page), so it stays; what leaves is the claim that the app is on it.
652
+ print(dim("⧗ ") + "deploying — your app will be live at " + bold(url));
653
+
654
+ // Default: DON'T hold the caller hostage for the whole build. A coding agent
655
+ // that runs `bay deploy` should get the live URL and its prompt back in
656
+ // ~1s, not sit blocked for two minutes. So the build is handed to a detached
657
+ // background worker that keeps the deploy connection open (the server keeps
658
+ // building, CPU allocated, until it lands) and logs to
659
+ // ~/.bay/deploys/<slug>.log. Pass --wait to stay attached and stream the
660
+ // build here instead.
661
+ if (!args.wait) {
662
+ const logDir = path.join(CFG_DIR, "deploys");
663
+ fs.mkdirSync(logDir, { recursive: true });
664
+ const logFile = path.join(logDir, `${slug}.log`);
665
+ const out = fs.openSync(logFile, "a");
666
+ const child = spawn(process.execPath, [process.argv[1], "__deploy-worker"], {
667
+ cwd: process.cwd(),
668
+ detached: true,
669
+ stdio: ["ignore", out, out],
670
+ env: {
671
+ ...process.env,
672
+ SS_BG_SLUG: slug, SS_BG_URL: url, SS_BG_REPO: repo || "",
673
+ SS_BG_FOLDER: folderName,
674
+ SS_BG_NOENV: args["no-env"] ? "1" : "",
675
+ SS_BG_RUN: args["run"] || "",
676
+ },
677
+ });
678
+ child.unref();
679
+ // Say — in the foreground, where the agent actually sees it — what the link
680
+ // shows while the build runs. It is not a placeholder any more: the address
681
+ // answers with the room, which draws the build as it happens and turns into
682
+ // the app the moment it first responds.
683
+ print(dim(" the link is live now — it shows the build, and becomes your app when it comes up"));
684
+ // Said here, not in the worker's log, because this is the only output the agent
685
+ // that ran the deploy will read. Names only — the values must never reach a log.
686
+ if (!args["no-env"]) {
687
+ const candidates = Object.keys(selectEnv(readEnvFiles(process.cwd())).send);
688
+ if (candidates.length) print(dim(" carrying from .env: ") + candidates.join(", ") + dim(" (vars already set on the app are left alone)"));
689
+ }
690
+ print(dim(" build finishing in the background · watch: ") + bold(`bay logs ${slug} --follow`));
691
+ process.exit(0);
692
+ }
693
+
694
+ await runBuildAndWait({ slug, url, repo, folderName, args });
695
+ }
696
+
697
+ /**
698
+ * The vars to carry up with this deploy, read from the project's local `.env`.
699
+ *
700
+ * Asking the app which keys it already has is what keeps a redeploy from overwriting a
701
+ * deliberately-set production value with whatever is in the developer's `.env` — very
702
+ * often a test key. A slug reserved seconds ago has no service yet and answers with no
703
+ * keys, which is right: on a first deploy everything local is new.
704
+ */
705
+ async function collectEnv(slug, args) {
706
+ const none = { send: {}, skipped: [] };
707
+ if (args["no-env"]) return none;
708
+ const local = readEnvFiles(process.cwd());
709
+ if (!Object.keys(local).length) return none;
710
+ let existingKeys = [];
711
+ try { existingKeys = (await api(`/api/apps/${slug}/env`)).keys || []; } catch { /* no service yet */ }
712
+ return selectEnv(local, { existingKeys, platformOwned: ownedHere() });
713
+ }
714
+
715
+ /**
716
+ * Which names THIS repo's deploy will write for itself.
717
+ *
718
+ * Asked of the control plane's own resolver rather than answered here, because it
719
+ * is not a property of the name: DATABASE_URL belongs to the platform when the
720
+ * platform provisions the database and to the app when the app already has one.
721
+ * Answering it locally from a hard-coded list is what made the CLI strip the one
722
+ * variable a bring-your-own-database deploy cannot run without.
723
+ *
724
+ * Undefined on any failure — no config, a malformed one, a build with no bundled
725
+ * resolver — which leaves `selectEnv` on its own conservative set. A variable
726
+ * wrongly skipped is printed to the user with a reason; a variable wrongly sent
727
+ * points a live app at a laptop.
728
+ */
729
+ function ownedHere() {
730
+ try {
731
+ const { resolver } = require("./lib/resolver");
732
+ const r = resolver();
733
+ const config = r.readAppConfig(process.cwd());
734
+ if (!config) return undefined;
735
+ // Already OR'd with every service's `uses`/`needsDB` by parseAppConfig, so
736
+ // this is the same answer the server will reach.
737
+ const database = config.resources && config.resources.database;
738
+ return (name) => r.platformOwned(name, database);
739
+ } catch {
740
+ return undefined;
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Steps 2–3 of a URL-first deploy: kick off the real build on the server and
746
+ * stream it to completion. Runs either in the foreground (`--wait`) or inside
747
+ * the detached background worker.
748
+ *
749
+ * The address is already live while this runs — it was reserved in step 1 and
750
+ * the edge answers it with the room, which draws the build as it happens.
751
+ */
752
+ async function runBuildAndWait({ slug, url, repo, folderName, args }) {
753
+ // Keep a local copy of the build output whichever path this is.
754
+ //
755
+ // The log file used to be written only by the detached worker, because that is
756
+ // where its stdout was redirected — so `--wait`, the mode where you are watching
757
+ // and most likely to lose the terminal, was the one mode that kept no record.
758
+ // `bay logs` then reads from the server and shows the RUNNING app's logs,
759
+ // which for a failed deploy is nothing at all.
760
+ startDeployLog(slug);
761
+
762
+ // The app's own secrets, from the project's local .env. They ride the deploy request
763
+ // rather than the tarball, so they land on the first revision — an app that needs an
764
+ // API key comes up working instead of crash-looping until someone sets it by hand.
765
+ const { send: envVars } = await collectEnv(slug, args);
766
+ const envKeys = Object.keys(envVars);
767
+ if (envKeys.length) info(cyan("▸ ") + `carrying ${envKeys.length} var${envKeys.length > 1 ? "s" : ""} from .env: ` + dim(envKeys.join(", ")));
768
+
769
+ // The real build, on the reserved slug, on the server (your machine stays free).
770
+ let res;
771
+ const runCmd = args.run || process.env.SS_BG_RUN || "";
772
+ if (repo) {
773
+ res = await api("/api/deploy", { method: "POST", body: { repo, slug, secrets: envVars, run: runCmd }, stream: true });
774
+ } else {
775
+ // Not "to build in the cloud" — most apps take the prebuilt runner and the
776
+ // server says "no image to build" twenty lines later, so the two read as a
777
+ // contradiction. What is true of every deploy is that the code is going up.
778
+ info(cyan("▸ ") + "uploading " + bold(folderName) + "…");
779
+ const tgz = await packageFolder();
780
+ const body = fs.readFileSync(tgz);
781
+ try { fs.unlinkSync(tgz); } catch { /* ignore */ }
782
+ const headers = {
783
+ Authorization: "Bearer " + token(),
784
+ "Content-Type": "application/gzip",
785
+ ...brand.protoHeaders("upload", "1"),
786
+ ...brand.protoHeaders("app", folderName),
787
+ ...brand.protoHeaders("slug", slug),
788
+ ...brand.protoHeaders("who", whoHeader(process.env)),
789
+ };
790
+ // How to run the app in production, worked out by the agent. Encoded because it
791
+ // has spaces/flags. The runner uses it as SUPERSONIC_RUN.
792
+ if (runCmd) Object.assign(headers, brand.protoHeaders("run", encodeURIComponent(runCmd)));
793
+ // The upload's body is the tarball, so the vars go in a header. Past what Cloud Run
794
+ // will carry there we say so and set nothing: silently dropping half an environment
795
+ // would surface later as an app that is broken for no visible reason.
796
+ const envHeader = encodeEnvHeader(envVars);
797
+ if (envHeader) Object.assign(headers, brand.protoHeaders("env", envHeader));
798
+ else if (envKeys.length) info(red("! ") + ".env is too large to send with the build — set them after it lands: " + bold(`bay env ${slug} set KEY=VALUE`));
799
+
800
+ // The bytes go to the bucket, not through the API. Attempted for every size
801
+ // rather than only over the cap, so the path a large project depends on is
802
+ // the same one every deploy exercises — a fallback that only runs for the
803
+ // biggest uploads is a fallback nobody finds out is broken.
804
+ const placed = await uploadSourceToBucket(body);
805
+ if (placed) {
806
+ Object.assign(headers, brand.protoHeaders("source-object", placed.object));
807
+ Object.assign(headers, brand.protoHeaders("source-key", placed.key));
808
+ res = await fetch(baseUrl() + "/api/deploy", { method: "POST", headers });
809
+ } else if (body.length >= BODY_LIMIT) {
810
+ // Said here because nowhere else can. The 413 comes from Google's front
811
+ // end, so the server has no record to report and no log line to show.
812
+ cleanup();
813
+ die(`this project is ${(body.length / 1e6).toFixed(1)} MB packed, and the direct upload path caps at 32 MB — `
814
+ + `the bucket upload could not be prepared, so there is no way to send it right now. Nothing was deployed.`);
815
+ } else {
816
+ res = await fetch(baseUrl() + "/api/deploy", { method: "POST", headers, body });
817
+ }
818
+ if (res.status === 401) { cleanup(); die("token invalid or expired — run: bay login"); }
819
+ }
820
+ await consumeDeploy(res, args, slug); // when the build goes live the proxy serves it on `url`
821
+ print(green("✓ ") + "build is live at " + bold(url));
822
+ cleanup();
823
+ }
824
+
825
+ /** The detached background worker spawned by the default deploy. Finishes the
826
+ * build after the foreground command has already returned the live URL. */
827
+ async function deployWorker() {
828
+ const slug = process.env.SS_BG_SLUG, url = process.env.SS_BG_URL;
829
+ if (!slug || !url) die("deploy worker: missing context");
830
+ await runBuildAndWait({
831
+ slug, url,
832
+ repo: process.env.SS_BG_REPO || "",
833
+ folderName: process.env.SS_BG_FOLDER || "app",
834
+ args: {
835
+ "no-env": process.env.SS_BG_NOENV === "1" || undefined,
836
+ _: [],
837
+ },
838
+ });
839
+ }
840
+
841
+ /**
842
+ * Build here and upload only the result.
843
+ *
844
+ * Returns null to mean "not this path, carry on with the cloud build" — which happens
845
+ * for server apps, when the detector can't place the project, and whenever the local
846
+ * build fails. Every one of those falls back rather than failing: someone whose machine
847
+ * is misconfigured still gets a deploy.
848
+ */
849
+ async function tryPrebuilt(appName, args) {
850
+ let detect, prebuilt;
851
+ try {
852
+ detect = require("./vendor/detector.js");
853
+ prebuilt = require("./lib/prebuilt.js");
854
+ } catch {
855
+ return null; // detector not bundled — old install, use the cloud
856
+ }
857
+
858
+ let stack;
859
+ try { stack = detect.detectStack(process.cwd()); } catch { return null; }
860
+
861
+ const plan = prebuilt.planFor(stack);
862
+ if (plan.mode === "cloud") return null;
863
+
864
+ const outDir = path.resolve(process.cwd(), plan.outputDir);
865
+
866
+ if (plan.mode === "build") {
867
+ const warn = prebuilt.nodeVersionWarning(stack);
868
+ if (warn) info(dim("! " + warn));
869
+
870
+ if (!fs.existsSync(path.join(process.cwd(), "node_modules")) && plan.installCommand) {
871
+ info(cyan("▸ ") + "installing dependencies…");
872
+ if (!runLocal(plan.installCommand)) {
873
+ info(dim("! install failed here — building in the cloud instead"));
874
+ return null;
875
+ }
876
+ }
877
+ info(cyan("▸ ") + "building " + bold(appName) + "…");
878
+ if (!runLocal(plan.buildCommand)) {
879
+ info(dim("! build failed here — building in the cloud instead"));
880
+ return null;
881
+ }
882
+ }
883
+
884
+ if (!prebuilt.hasOutput(outDir)) {
885
+ info(dim(`! nothing in ${plan.outputDir}/ — building in the cloud instead`));
886
+ return null;
887
+ }
888
+
889
+ const hash = prebuilt.hashDir(outDir);
890
+
891
+ // Already live? Then there is nothing to send.
892
+ try {
893
+ const pre = await api("/api/deploy/preflight", { method: "POST", body: { app: appName, hash } });
894
+ if (pre && pre.skip) {
895
+ info(green("✓ ") + "already live, nothing changed — " + bold(pre.url));
896
+ return pre.url;
897
+ }
898
+ } catch { /* preflight is an optimisation; never let it stop a deploy */ }
899
+
900
+ const tgz = await packageDir(outDir);
901
+ const body = fs.readFileSync(tgz);
902
+ try { fs.unlinkSync(tgz); } catch { /* ignore */ }
903
+ info(dim(`uploading ${(body.length / 1048576).toFixed(1)} MB of built output`));
904
+
905
+ const tok = token();
906
+ if (!tok) die("not authenticated — run: bay login");
907
+ const res = await fetch(baseUrl() + "/api/deploy", {
908
+ method: "POST",
909
+ headers: {
910
+ Authorization: "Bearer " + tok,
911
+ "Content-Type": "application/gzip",
912
+ ...brand.protoHeaders("upload", "1"),
913
+ ...brand.protoHeaders("prebuilt", "1"),
914
+ ...brand.protoHeaders("hash", hash),
915
+ ...brand.protoHeaders("app", appName),
916
+ ...brand.protoHeaders("who", whoHeader(process.env)),
917
+ },
918
+ body,
919
+ });
920
+ if (res.status === 401) die("token invalid or expired — run: bay login");
921
+ if (res.status === 403) die("forbidden");
922
+ if (!res.body) die("no response stream");
923
+ return consumeDeploy(res, args);
924
+ }
925
+
926
+ /** Run a shell command in the project, streaming its output. True when it succeeded. */
927
+ function runLocal(command) {
928
+ const r = spawnSync(command, { shell: true, stdio: "inherit", cwd: process.cwd() });
929
+ return r.status === 0;
930
+ }
931
+
932
+ /** Pack a single directory's contents into a temp .tgz. */
933
+ function packageDir(dir) {
934
+ return new Promise((resolve, reject) => {
935
+ const out = path.join(os.tmpdir(), "ss-built-" + process.pid + ".tgz");
936
+ // COPYFILE_DISABLE=1 stops macOS `tar` from synthesizing AppleDouble `._*`
937
+ // entries. Without it those land in the archive, extract on the Linux build
938
+ // side, and break framework builds (Next tries to compile `._page.js`). No-op
939
+ // off macOS. Belt-and-suspenders: also drop any `._*` already on disk.
940
+ const p = spawn("tar", ["--exclude=._*", "-czf", out, "-C", dir, "."], {
941
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
942
+ stdio: ["ignore", "ignore", "pipe"],
943
+ });
944
+ let err = ""; p.stderr.on("data", (d) => (err += d));
945
+ p.on("error", () => reject(new Error("could not run `tar` — is it installed?")));
946
+ p.on("close", () => (fs.existsSync(out) ? resolve(out) : reject(new Error("packaging failed: " + err.trim()))));
947
+ });
948
+ }
949
+
950
+ async function redeploy(args) {
951
+ const app = needApp(args);
952
+ const d = await api(`/api/apps/${app}`);
953
+ if (!d.repo) die(`${app} was deployed from a computer — run \`bay deploy\` in its folder to ship an update`);
954
+ info(cyan("▸ ") + "redeploying " + bold(app));
955
+ const res = await api("/api/deploy", { method: "POST", body: { repo: d.repo }, stream: true });
956
+ return consumeDeploy(res, args, app);
957
+ }
958
+
959
+ /**
960
+ * Zip the current folder into a temp .tgz. `lib/bundle.js` decides what goes in it
961
+ * — and, unlike the denylist this replaced, says what it left out and why.
962
+ */
963
+ function packageFolder() {
964
+ const { packageFolder: pack } = require("./lib/bundle.js");
965
+ return pack(process.cwd(), (line) => {
966
+ if (line.level === "warn") info(yellow("! ") + line.text);
967
+ else if (line.level === "detail") info(dim(" " + line.text));
968
+ else info(dim(" " + line.text));
969
+ });
970
+ }
971
+
972
+ /**
973
+ * What a request body may weigh before Google's front end throws it away.
974
+ *
975
+ * Cloud Run's cap, and it is enforced ABOVE the service: a larger POST is
976
+ * answered 413 by the front end, so the control plane never sees the request,
977
+ * logs nothing, and leaves the app sitting at "reserved" forever. Excalidraw
978
+ * bundles to 36.3 MB and hit exactly this — the deploy looked like it had simply
979
+ * stopped. Anything at or above this goes to the bucket instead.
980
+ */
981
+ const BODY_LIMIT = 32 * 1024 * 1024;
982
+
983
+ /**
984
+ * The tarball's encryption, mirroring lib/deploy-runs.ts on the server.
985
+ *
986
+ * The bucket is readable by the shared app-runtime identity, so source left
987
+ * there in the clear would be readable from inside every other customer's
988
+ * container. Encrypting here rather than server-side is what lets the bytes skip
989
+ * the control plane entirely; the key travels with the deploy request and is
990
+ * stored in the same Postgres column a server-side upload would have written.
991
+ *
992
+ * aes-256-cbc, key derived by scrypt from a 32-byte hex passphrase under a fixed
993
+ * salt, IV prefixed to the ciphertext. If either side changes, both must.
994
+ */
995
+ function encryptSource(buf, pass) {
996
+ const { createCipheriv, scryptSync } = require("node:crypto");
997
+ const iv = require("node:crypto").randomBytes(16);
998
+ const cipher = createCipheriv("aes-256-cbc", scryptSync(pass, "bay-deploy-run", 32), iv);
999
+ return Buffer.concat([iv, cipher.update(buf), cipher.final()]);
1000
+ }
1001
+
1002
+ /**
1003
+ * Put the source in the bucket ourselves and hand back the reference.
1004
+ *
1005
+ * Returns null when the server cannot mint a URL, which the caller treats as
1006
+ * "use the body" — correct for a small project and a refusal for a large one,
1007
+ * because the body is precisely what does not work at size.
1008
+ */
1009
+ async function uploadSourceToBucket(body) {
1010
+ let spot;
1011
+ try {
1012
+ const r = await fetch(baseUrl() + "/api/deploy/upload-url", {
1013
+ method: "POST",
1014
+ headers: { Authorization: "Bearer " + token(), "Content-Type": "application/json" },
1015
+ });
1016
+ if (r.status === 401) die("token invalid or expired — run: bay login");
1017
+ if (!r.ok) return null;
1018
+ spot = await r.json();
1019
+ } catch { return null; }
1020
+ if (!spot || !spot.uploadUrl || !spot.object) return null;
1021
+
1022
+ const key = require("node:crypto").randomBytes(32).toString("hex");
1023
+ const sealed = encryptSource(body, key);
1024
+ const put = await fetch(spot.uploadUrl, {
1025
+ method: "PUT",
1026
+ headers: { "Content-Type": "application/octet-stream" },
1027
+ body: sealed,
1028
+ });
1029
+ if (!put.ok) die(`upload failed (${put.status}) — the code never left your machine, so nothing was deployed`);
1030
+ return { object: spot.object, key };
1031
+ }
1032
+
1033
+ /**
1034
+ * Follow a deploy on the server after the stream to it has died.
1035
+ *
1036
+ * The build runs on the control plane, not here — so a dropped socket says
1037
+ * nothing about whether the deploy worked. It has already gone both ways: a
1038
+ * Prisma app was reported failed six seconds before its own health probe passed,
1039
+ * and a killed instance ended streams on deploys that went on to land. The
1040
+ * server's own record is the only thing that knows, so ask it rather than
1041
+ * inferring from a closed connection.
1042
+ */
1043
+ async function followDeployOnServer(slug, ms = 180000) {
1044
+ const deadline = Date.now() + ms;
1045
+ let announced = false;
1046
+ for (;;) {
1047
+ let deploy = null;
1048
+ // Deliberately not `api()`: it exits the process on a non-200, and a blip
1049
+ // while polling must not become the verdict.
1050
+ try {
1051
+ const r = await fetch(baseUrl() + `/api/apps/${slug}/deploy-status`, {
1052
+ headers: { Authorization: "Bearer " + token() },
1053
+ });
1054
+ if (r.ok) deploy = (await r.json().catch(() => ({}))).deploy || null;
1055
+ } catch { /* transient — keep waiting */ }
1056
+ if (deploy && (deploy.status === "live" || deploy.status === "failed")) return deploy;
1057
+ if (Date.now() >= deadline) return null;
1058
+ if (!announced) {
1059
+ announced = true;
1060
+ info(dim(" lost the connection to the build — following it on the server instead…"));
1061
+ }
1062
+ await new Promise((r) => setTimeout(r, 3000));
1063
+ }
1064
+ }
1065
+
1066
+ async function consumeDeploy(res, args, knownSlug) {
1067
+ const reader = res.body.getReader();
1068
+ const dec = new TextDecoder();
1069
+ let buf = "";
1070
+ // The deploy stream announces the slug up front, so even the call paths that
1071
+ // let the server pick one can follow the deploy after the stream dies.
1072
+ let slug = knownSlug || null;
1073
+ // The server's id for this deploy, announced as the stream's first event. It
1074
+ // is what joins this run to its rows in deploy_stages / deploy_events /
1075
+ // deploy_failures; without it a caller measuring a deploy has only the slug
1076
+ // and a guess at the time window.
1077
+ let runId = null;
1078
+ // A TRANSPORT FAILURE IS NOT A VERDICT ON THE DEPLOY, and it used to be
1079
+ // reported as one. `reader.read()` throws when the response body is cut —
1080
+ // undici's message for that is the single word "terminated" — and with nothing
1081
+ // catching it, that word travelled all the way to the user as `✗ terminated`
1082
+ // for a deploy that had SUCCEEDED. Observed on 13 Aug: the job completed in
1083
+ // 1m3s, the app was live on the new digest, and the CLI called it a failure
1084
+ // because the stream had been cut at five minutes.
1085
+ //
1086
+ // The comment below already says what to do about a stream that ends without a
1087
+ // result — "that is a fact about this connection, not about the deploy" — and
1088
+ // an exception is the same fact arriving by a different door. So it lands in
1089
+ // the same place: stop reading, and go ask the server what actually happened.
1090
+ let streamError = null;
1091
+ try {
1092
+ for (;;) {
1093
+ const { value, done } = await reader.read();
1094
+ if (done) break;
1095
+ buf += dec.decode(value, { stream: true });
1096
+ const parts = buf.split("\n\n");
1097
+ buf = parts.pop();
1098
+ for (const p of parts) {
1099
+ const raw = p.replace(/^data: /, "").trim();
1100
+ if (!raw) continue;
1101
+ let ev; try { ev = JSON.parse(raw); } catch { continue; }
1102
+ if (ev.slug) slug = ev.slug;
1103
+ if (ev.runId) runId = ev.runId;
1104
+ if (ev.type === "run") continue; // bookkeeping, not narration
1105
+ if (ev.type === "log") info(" " + dim(ev.line));
1106
+ else if (ev.type === "detected") info(" " + cyan(`detected ${ev.stack?.framework || "app"}`));
1107
+ else if (ev.type === "done") { writeLockfile(ev.decided, ev.slug); if (args.json) json({ ok: true, slug: ev.slug, url: ev.url, runId }); else print(green("✓ live: ") + ev.url); process.exit(0); }
1108
+ else if (ev.type === "error") { if (args.json) json({ ok: false, error: ev.message, slug, runId }); die(ev.message); }
1109
+ }
1110
+ }
1111
+ } catch (e) {
1112
+ streamError = e && e.message ? e.message : String(e);
1113
+ }
1114
+ // The server can answer with a plain JSON error instead of a stream — a plan limit,
1115
+ // a rejected request. That is not a build that timed out, and saying so sent someone
1116
+ // to `bay logs` looking for a failure that never happened. If what arrived
1117
+ // parses as an error object, report what it actually said.
1118
+ const trailing = (buf || "").trim();
1119
+ if (trailing) {
1120
+ let body; try { body = JSON.parse(trailing.replace(/^data: /, "")); } catch { /* not JSON */ }
1121
+ if (body && body.error) {
1122
+ if (args.json) json({ ok: false, error: body.error, upgrade: !!body.upgrade, paywall: !!body.paywall });
1123
+ die(body.error);
1124
+ }
1125
+ }
1126
+
1127
+ // Stream closed with no terminal `done`/`error`. That is a fact about this
1128
+ // connection, not about the deploy: the build is still running on the server.
1129
+ // Ask the server what actually happened before saying anything.
1130
+ if (slug) {
1131
+ const deploy = await followDeployOnServer(slug);
1132
+ if (deploy?.status === "live") {
1133
+ const url = deploy.url || `https://${slug}.thebay.cloud`;
1134
+ if (args.json) json({ ok: true, slug, url, runId });
1135
+ else print(green("✓ live: ") + url);
1136
+ process.exit(0);
1137
+ }
1138
+ if (deploy?.status === "failed") {
1139
+ const why = deploy.error || deploy.stage || "the deploy failed";
1140
+ if (args.json) json({ ok: false, slug, error: why, runId });
1141
+ die(why);
1142
+ }
1143
+ // Still building when we ran out of patience. Say that, rather than calling a
1144
+ // deploy failed that may be minutes from landing.
1145
+ if (args.json) json({ ok: false, slug, error: "still building — connection lost", pending: true, runId });
1146
+ die(`lost contact with the build and it was still running after 3 minutes. It may still land — check: bay logs ${slug}`);
1147
+ }
1148
+
1149
+ // No slug to ask about — the stream died before it named one, so there is
1150
+ // nothing to follow. The transport error is worth printing HERE, and only
1151
+ // here: it is all the information there is.
1152
+ const lost = streamError ? ` (the connection failed: ${streamError})` : "";
1153
+ if (args.json) json({ ok: false, error: "deploy stream ended without a result", streamError });
1154
+ die(`the deploy ended without confirming it went live${lost} — the build may have failed or timed out. Check: bay apps · bay logs <app>`);
1155
+ }
1156
+
1157
+ // ---------- helpers ----------
1158
+ function needApp(args) { const a = args._[0]; if (!a) die("missing app name — usage: bay " + args._cmd + " <app>"); return a; }
1159
+ function gitOrigin() {
1160
+ return new Promise((resolve) => {
1161
+ const p = spawn("git", ["remote", "get-url", "origin"], { stdio: ["ignore", "pipe", "ignore"] });
1162
+ let o = ""; p.stdout.on("data", (d) => (o += d));
1163
+ p.on("error", () => resolve(""));
1164
+ p.on("close", (code) => resolve(code === 0 ? o.trim() : ""));
1165
+ });
1166
+ }
1167
+
1168
+ function usage(all = false) {
1169
+ if (!all) {
1170
+ print(`${bold("bay")} — publish your app in one command
1171
+
1172
+ ${bold("just run this in your project folder:")}
1173
+ ${green("bay ship")} publish this folder and print the live URL
1174
+ (opens a browser to sign in the first time;
1175
+ ${dim("`deploy` is the same command")})
1176
+
1177
+ ${bold("before you ship")} ${dim("(local, ~2s, no cloud)")}
1178
+ bay init write a draft supersonic.json from this repo
1179
+ bay check what each phase would run, and what would fail
1180
+
1181
+ ${bold("when something's wrong")}
1182
+ bay logs <app> recent logs
1183
+ bay diagnose <app> AI fix-prompt for your coding agent
1184
+ bay apps list your apps
1185
+ bay open <app> open the app in a browser
1186
+ bay login sign in manually
1187
+
1188
+ ${dim("more commands: bay help --all · --json on any command for machine output")}`);
1189
+ return;
1190
+ }
1191
+ print(`${bold("bay")} — deploy & debug from your coding agent
1192
+
1193
+ ${bold("setup")}
1194
+ bay signup create an account (opens browser, one time)
1195
+ bay login [--url <u>] [--token <t>] authenticate (browser, one time)
1196
+ bay logout
1197
+ bay whoami
1198
+
1199
+ ${bold("author")} ${dim("(local: no cloud, no build, no model — about two seconds)")}
1200
+ bay init [dir] [--force] write a DRAFT supersonic.json for an agent to correct
1201
+ bay check [dir] resolve + validate it, and print what each phase would run
1202
+
1203
+ ${bold("ship")} ${dim("(URL-first: a live link in ~0.1s, the build drawn on it while it runs)")}
1204
+ bay ship ship this folder — live URL now, build behind it
1205
+ ${dim("(`deploy` does the same thing and always will — every flag below works with either)")}
1206
+ bay ship --run "<prod start cmd>" how to run it in PROD — you know the stack
1207
+ e.g. --run "uvicorn main:app --host 0.0.0.0 --port $PORT"
1208
+ bay ship --wait stay attached and stream the build (default: returns once live)
1209
+ bay ship --no-env don't carry .env up (default: sets vars your app doesn't have yet)
1210
+ bay ship --github [--repo <url>] deploy from GitHub / a git URL instead
1211
+ bay ship --prebuilt old path: build here, upload the result
1212
+ bay reship <app> rebuild from the app's source
1213
+ bay patch <app> the repair agent's fix, to pipe into git apply
1214
+ bay rollback <app> roll back to the previous version
1215
+ bay delete <app> --yes delete an app (its database and bucket are kept)
1216
+
1217
+ ${bold("inspect")}
1218
+ bay apps list your apps
1219
+ bay status <app> revision, url, env, database
1220
+ bay logs <app> [--severity error] [--limit 50] [--since 1h] [--follow]
1221
+ bay errors <app> production errors (7d)
1222
+ bay diagnose <app> [--error "..."] AI fix-prompt for your agent
1223
+ bay exec <app> -- <command> run a command in the app's env (isolated)
1224
+
1225
+ ${bold("config")}
1226
+ bay env <app> list env var keys
1227
+ bay env <app> set KEY=VALUE set env var(s)
1228
+ bay env <app> unset KEY remove env var(s)
1229
+ bay open <app> open the app in a browser
1230
+
1231
+ ${dim("global: --json for machine-readable output · $BAY_TOKEN overrides login")}`);
1232
+ }
1233
+
1234
+ // ---------- arg parsing ----------
1235
+ function parse(argv) {
1236
+ const args = { _: [], _raw: [] };
1237
+ for (let i = 0; i < argv.length; i++) {
1238
+ const t = argv[i];
1239
+ if (t === "--") { args._raw = argv.slice(i + 1); break; } // everything after `--` is a passthrough command
1240
+ if (t.startsWith("--")) {
1241
+ const eq = t.indexOf("=");
1242
+ if (eq > -1) args[t.slice(2, eq)] = t.slice(eq + 1);
1243
+ else if (argv[i + 1] && !argv[i + 1].startsWith("--") && argv[i + 1] !== "--") args[t.slice(2)] = argv[++i];
1244
+ else args[t.slice(2)] = true;
1245
+ } else args._.push(t);
1246
+ }
1247
+ return args;
1248
+ }
1249
+
1250
+ /**
1251
+ * `ship` is the word; `deploy` is the alias, and it is permanent.
1252
+ *
1253
+ * The product language calls the act of sending your work out `ship` — it is
1254
+ * what people say, and it leaves `deploy` free to mean the thing sysadmins do.
1255
+ * But `deploy` is typed by every existing user and written into every agent
1256
+ * prompt, README and script that already exists, so it does not get deprecated,
1257
+ * warned about, or removed. Two words, one command, forever.
1258
+ */
1259
+ const COMMANDS = { signup, login, logout, whoami, apps, status, logs, errors, diagnose, env, patch, rollback, exec, open, init, check, ship: deploy, deploy, reship: redeploy, redeploy, delete: del, rm: del, "__deploy-worker": deployWorker };
1260
+
1261
+ (async () => {
1262
+ const [, , cmd, ...rest] = process.argv;
1263
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return usage(rest.includes("--all") || rest.includes("-a"));
1264
+ const fn = COMMANDS[cmd];
1265
+ if (!fn) { info(red(`unknown command: ${cmd}`)); usage(); process.exit(1); }
1266
+ const args = parse(rest);
1267
+ args._cmd = cmd;
1268
+ await fn(args);
1269
+ })().catch((e) => die(e && e.message ? e.message : String(e)));