@thebaycloud/cli 1.0.0 → 1.1.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 CHANGED
@@ -2,10 +2,26 @@
2
2
  "use strict";
3
3
 
4
4
  /*
5
- * bay — the deploy/debug surface for a coding agent.
5
+ * bay — the ship/debug surface for a coding agent.
6
6
  * Designed for agents, not humans: no interactive prompts, --json everywhere,
7
7
  * token auth (a human logs in once, the agent inherits the token), stdout=data,
8
8
  * stderr=logs, meaningful exit codes.
9
+ *
10
+ * THE OLD NAME STILL WORKS, EVERYWHERE, AND IS NOT DEPRECATED.
11
+ *
12
+ * The command is `bay`; package.json also installs it as `supersonic`. The
13
+ * config is ~/.bay; ~/.supersonic is still read. The variables are BAY_TOKEN and
14
+ * BAY_URL; SUPERSONIC_TOKEN and SUPERSONIC_URL are still honoured. Every one of
15
+ * those pairs exists because the old spelling is written into scripts, CI
16
+ * configs and agent prompts that nobody is going to edit, and a rename that
17
+ * silently stops reading them does not read as a rename — it reads as the
18
+ * platform signing you out and losing your account.
19
+ *
20
+ * The wire is a different question and the answer is no. `x-supersonic-*`
21
+ * headers, `SUPERSONIC_RUN`, `SUPERSONIC_CODE_BUCKET` and `supersonic.json` are
22
+ * what the CONTROL PLANE reads, not what a person types. Renaming them here
23
+ * would break every deploy against a server that has not been renamed on the
24
+ * same afternoon, so they stay until the server moves first.
9
25
  */
10
26
 
11
27
  const fs = require("fs");
@@ -16,19 +32,35 @@ const { spawn, spawnSync } = require("child_process");
16
32
  const { readEnvFiles, selectEnv, encodeEnvHeader } = require("./lib/envfile");
17
33
  const { joinExecArgs } = require("./lib/exec-args");
18
34
  const { whoHeader } = require("./lib/who");
19
- const { deletionRefusal } = require("./lib/confirm");
20
-
21
35
  const brand = require("./lib/brand");
36
+ const { deletionRefusal } = require("./lib/confirm");
37
+ const { configDirIn, envVarFrom } = require("./lib/home");
22
38
 
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();
39
+ /**
40
+ * Where the session lives: ~/.bay, unless ~/.supersonic is the one that exists.
41
+ *
42
+ * Not "read both and merge" — that would have two files disagreeing about which
43
+ * account you are, with the winner decided by the order of two lines. The rule
44
+ * is: the new directory if it is there, otherwise the old one if IT is there,
45
+ * otherwise the new one. So an existing user stays signed in and keeps writing
46
+ * to the file they already have, and a new one never creates the old name.
47
+ */
48
+ const CFG_DIR = configDirIn(os.homedir(), fs.existsSync, path.join);
29
49
  const CFG = path.join(CFG_DIR, "config.json");
50
+ // From lib/brand, not a second copy of it.
30
51
  const DEFAULT_URL = brand.DEFAULT_URL;
31
52
 
53
+ /**
54
+ * A variable under its new name, falling back to the old one.
55
+ *
56
+ * `BAY_TOKEN` is the name now; `SUPERSONIC_TOKEN` is what is exported in every
57
+ * CI job that already ships to this platform. The new name wins when both are
58
+ * set, which is the only sane precedence: somebody who set both was migrating.
59
+ */
60
+ function envVar(name) {
61
+ return envVarFrom(process.env, name);
62
+ }
63
+
32
64
  // ---------- output ----------
33
65
  // A reader that stops reading — `bay check | head -5`, an agent piping into
34
66
  // grep — closes the pipe under us, and Node turns that into an unhandled EPIPE:
@@ -41,7 +73,7 @@ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
41
73
  const c = (n) => (s) => (COLOR ? `\x1b[${n}m${s}\x1b[0m` : String(s));
42
74
  const dim = c("2"), bold = c("1"), green = c("32"), red = c("31"), cyan = c("36"), yellow = c("33");
43
75
  // 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
76
+ // <config dir>/deploys/<slug>.log, so a build survives the terminal it was
45
77
  // started in. Best-effort — a log that cannot be written must never stop a deploy.
46
78
  let deployLog = null;
47
79
  function startDeployLog(slug) {
@@ -60,12 +92,7 @@ function print(s) { process.stdout.write(s + "\n"); } // data -> stdout
60
92
  function die(s, code = 1) { process.stderr.write(red("✗ ") + s + "\n"); process.exit(code); }
61
93
 
62
94
  /** 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"); }
95
+ const LOCKFILE = "supersonic.lock.json";
69
96
 
70
97
  /**
71
98
  * Record the decisions a successful deploy made.
@@ -93,14 +120,14 @@ function writeLockfile(decided, slug) {
93
120
  if (!decided || typeof decided !== "object") return;
94
121
  try {
95
122
  const body = JSON.stringify({
96
- $comment: "Written by bay after a successful deploy. Safe to commit, safe to delete.",
123
+ $comment: "Written by bay after a successful ship. Safe to commit, safe to delete.",
97
124
  slug: slug || undefined,
98
125
  decided,
99
126
  }, null, 2) + "\n";
100
- const path = lockfilePath();
127
+ const path = require("node:path").join(process.cwd(), LOCKFILE);
101
128
  if (require("node:fs").existsSync(path) && require("node:fs").readFileSync(path, "utf8") === body) return;
102
129
  require("node:fs").writeFileSync(path, body);
103
- info(dim(` wrote ${LOCKFILE} — what this deploy decided, so you can see it and change it`));
130
+ info(dim(` wrote ${LOCKFILE} — what this ship decided, so you can see it and change it`));
104
131
  } catch { /* never fail a green deploy over a note about it */ }
105
132
  }
106
133
  function json(o) { print(JSON.stringify(o, null, 2)); }
@@ -108,11 +135,19 @@ function json(o) { print(JSON.stringify(o, null, 2)); }
108
135
  // ---------- config ----------
109
136
  function loadCfg() { try { return JSON.parse(fs.readFileSync(CFG, "utf8")); } catch { return {}; } }
110
137
  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 || ""; }
138
+ function baseUrl() { return (envVar("URL") || loadCfg().url || DEFAULT_URL).replace(/\/$/, ""); }
139
+ function token() { return envVar("TOKEN") || loadCfg().token || ""; }
113
140
 
114
141
  // ---------- api ----------
115
- async function api(pathname, { method = "GET", body, stream = false } = {}) {
142
+ /**
143
+ * @param {object} [opts]
144
+ * @param {boolean} [opts.quiet] Don't narrate a 200-with-an-error-field. Some
145
+ * routes — the database one — answer 200 and put the failure in the body,
146
+ * because for them "this database does not exist" is an answer rather than a
147
+ * transport failure. Their callers print it themselves, and without this the
148
+ * reader sees the same sentence twice, once dim and once red.
149
+ */
150
+ async function api(pathname, { method = "GET", body, stream = false, quiet = false } = {}) {
116
151
  const tok = token();
117
152
  if (!tok) die("not authenticated — run: bay login");
118
153
  const res = await fetch(baseUrl() + pathname, {
@@ -129,7 +164,7 @@ async function api(pathname, { method = "GET", body, stream = false } = {}) {
129
164
  if (stream) return res;
130
165
  const data = await res.json().catch(() => ({}));
131
166
  if (!res.ok && data.error) die(data.error);
132
- if (data.error) info(dim("! " + data.error));
167
+ if (data.error && !quiet) info(dim("! " + data.error));
133
168
  return data;
134
169
  }
135
170
 
@@ -142,7 +177,7 @@ function openBrowser(url) {
142
177
 
143
178
  // ---------- commands ----------
144
179
  async function login(args) {
145
- const url = (args.url || brand.envAny("URL") || DEFAULT_URL).replace(/\/$/, "");
180
+ const url = (args.url || envVar("URL") || DEFAULT_URL).replace(/\/$/, "");
146
181
  // Explicit token (agents / CI / headless).
147
182
  if (args.token) {
148
183
  const ok = await validToken(url, String(args.token));
@@ -157,7 +192,7 @@ async function login(args) {
157
192
  // Create an account without leaving the terminal: opens the browser to sign up,
158
193
  // then the web hands a token back and the agent can continue straight to deploy.
159
194
  async function signup(args) {
160
- const url = (args.url || brand.envAny("URL") || DEFAULT_URL).replace(/\/$/, "");
195
+ const url = (args.url || envVar("URL") || DEFAULT_URL).replace(/\/$/, "");
161
196
  await loopbackAuth(url, "/signup", "signed up");
162
197
  }
163
198
 
@@ -213,7 +248,7 @@ async function ensureAuth() {
213
248
  const url = baseUrl();
214
249
  info(dim("Not signed in — opening a browser to sign in (just this once)…"));
215
250
  const tok = await runLoopback(url, "/cli");
216
- if (!tok) die("sign-in timed out — run `bay login`, then `bay deploy` again");
251
+ if (!tok) die("sign-in timed out — run `bay login`, then `bay ship` again");
217
252
  saveCfg({ ...loadCfg(), url, token: tok });
218
253
  info(green("✓ ") + "signed in — continuing…");
219
254
  }
@@ -233,9 +268,9 @@ async function whoami(args) {
233
268
  // just that a token exists.
234
269
  const res = await fetch(baseUrl() + "/api/account", { headers: { Authorization: "Bearer " + tok } });
235
270
  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" });
271
+ if (args.json) return json({ loggedIn: res.ok, url: baseUrl(), email: acct.email || null, plan: acct.plan || null, source: envVar("TOKEN") ? "env" : "config" });
237
272
  if (!res.ok) return die("token invalid — run: bay login");
238
- const src = process.env.BAY_TOKEN ? "env token" : "saved token";
273
+ const src = envVar("TOKEN") ? "env token" : "saved token";
239
274
  const who = acct.email ? " as " + acct.email : "";
240
275
  const plan = acct.plan ? ` · ${acct.plan}` : "";
241
276
  print(`logged in to ${baseUrl()}${who}${plan} (${src})`);
@@ -250,7 +285,7 @@ async function apps(args) {
250
285
  const d = await api("/api/apps");
251
286
  const list = d.apps || [];
252
287
  if (args.json) return json(list);
253
- if (!list.length) { info("no apps yet — deploy one with: bay deploy"); return; }
288
+ if (!list.length) { info("no apps yet — ship one with: bay ship"); return; }
254
289
  for (const a of list) {
255
290
  // The list endpoint has always sent `status: "building"` for a deploy in
256
291
  // flight and the CLI has always thrown it away, so an app that was building
@@ -259,7 +294,7 @@ async function apps(args) {
259
294
  const building = a.status === "building";
260
295
  const dot = a.ready ? green("●") : building ? yellow("◐") : red("○");
261
296
  const note = building ? dim(` ${a.stage || "deploying…"}`) : "";
262
- print(`${dot} ${bold(a.slug.padEnd(22))} ${dim(a.url || `${a.slug}.thebay.cloud`)}${note}`);
297
+ print(`${dot} ${bold(a.slug.padEnd(22))} ${dim(a.url || `${a.slug}.${brand.DOMAIN}`)}${note}`);
263
298
  }
264
299
  }
265
300
 
@@ -273,7 +308,9 @@ async function status(args) {
273
308
  // answer to a question it could not yet answer.
274
309
  const dot = d.ready ? green("● live") : d.deploying ? yellow("◐ deploying") : red("○ down");
275
310
  print(`${bold(app)} ${dot}${d.deploying && d.stage ? dim(` · ${d.stage}`) : ""}`);
276
- print(dim(" url ") + `${app}.thebay.cloud`);
311
+ // The server's own answer, for the same reason `open` asks for it: the root is
312
+ // not this file's to know.
313
+ print(dim(" url ") + (d.url || `https://${app}.${brand.DOMAIN}`));
277
314
  print(dim(" revision ") + (d.revision || "—"));
278
315
  print(dim(" image ") + (d.image ? d.image.split("/").pop() : "—"));
279
316
  print(dim(" region ") + (d.region || "—"));
@@ -291,40 +328,105 @@ async function status(args) {
291
328
  }
292
329
  }
293
330
 
331
+ /**
332
+ * `bay logs` — the same reader the dashboard uses.
333
+ *
334
+ * A FILTER, not a line count. An app printing a thousand lines an hour makes
335
+ * "the last sixty" the wrong question, so: a bare level, `key=value` for a facet,
336
+ * and anything else is a search.
337
+ *
338
+ * bay logs the last hundred, newest first
339
+ * bay logs error errors only
340
+ * bay logs --source edge requests
341
+ * bay logs --follow a live tail
342
+ * bay logs checkout --follow a live tail, filtered
343
+ *
344
+ * `--follow` is a real stream now. It used to poll every 2.5 seconds and
345
+ * de-duplicate against a Set of every line it had seen — which meant lines
346
+ * arrived in clumps, memory grew until the Set was thrown away wholesale, and a
347
+ * line could still be missed if it landed between two polls with the same text.
348
+ */
294
349
  async function logs(args) {
295
350
  const app = needApp(args);
296
351
  const qs = new URLSearchParams();
297
- if (args.severity) qs.set("severity", args.severity);
352
+
353
+ // Free words are the search. `bay logs error` is the level, because that is
354
+ // what somebody means by it.
355
+ const words = (args._ || []).slice(1).filter((w) => w !== app);
356
+ const levels = ["debug", "info", "warn", "error"];
357
+ const text = [];
358
+ for (const w of words) {
359
+ if (levels.includes(String(w).toLowerCase())) qs.set("level", String(w).toLowerCase());
360
+ else text.push(w);
361
+ }
362
+ if (text.length) qs.set("q", text.join(" "));
363
+
364
+ if (args.source) qs.set("source", args.source);
365
+ if (args.level) qs.set("level", args.level);
366
+ // `--severity ERROR` is what this took before the log view existed.
367
+ if (args.severity) qs.set("level", String(args.severity).toLowerCase().replace("warning", "warn"));
368
+ if (args.status) qs.set("status", String(args.status));
369
+ if (args.path) qs.set("path", args.path);
370
+ if (args.face) qs.set("face", args.face);
371
+ if (args.window) qs.set("window", args.window);
372
+ if (args.since) qs.set("window", args.since);
298
373
  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
374
 
302
375
  if (args.follow) {
303
- let seen = new Set();
304
376
  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));
377
+ const res = await api(`/api/apps/${app}/logs/tail?${qs}`, { stream: true });
378
+ if (!res.ok || !res.body) die("could not open the tail");
379
+ // SSE by hand: frames are separated by a blank line, and a frame we do not
380
+ // recognise (the heartbeat comment) is skipped rather than parsed.
381
+ let buf = "";
382
+ for await (const chunk of res.body) {
383
+ buf += Buffer.from(chunk).toString("utf8");
384
+ let cut;
385
+ while ((cut = buf.indexOf("\n\n")) !== -1) {
386
+ const frame = buf.slice(0, cut);
387
+ buf = buf.slice(cut + 2);
388
+ const ev = /^event: (.+)$/m.exec(frame);
389
+ const data = /^data: (.*)$/m.exec(frame);
390
+ if (!ev || !data) continue;
391
+ if (ev[1] === "row") { try { print(logLine(JSON.parse(data[1]))); } catch { /* not a row */ } }
392
+ else if (ev[1] === "broken") {
393
+ // A tail that stops silently looks exactly like an app that went quiet.
394
+ try { info(red("the tail stopped: " + JSON.parse(data[1]).why)); } catch { /* ignore */ }
395
+ return;
396
+ }
312
397
  }
313
- if (seen.size > 2000) seen = new Set();
314
- await new Promise((r) => setTimeout(r, 2500));
315
398
  }
399
+ return;
316
400
  }
317
401
 
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));
402
+ const d = await api(`/api/apps/${app}/logs/query?${qs}`);
403
+ const rows = d.rows || [];
404
+ if (args.json) return json(rows);
405
+ if (!rows.length) {
406
+ // "Nothing since" and "no logs" are different facts: a file tail has no
407
+ // history, so an app that has printed nothing has no lines and is not broken.
408
+ info(`nothing since ${(d.since || "").slice(0, 19).replace("T", " ")}`);
409
+ return;
410
+ }
411
+ // Oldest first on a terminal: you read down the page, and the newest line
412
+ // should be the one left above your prompt.
413
+ for (const r of rows.slice().reverse()) print(logLine(r));
414
+ if (d.cursor) info(dim("older lines exist — narrow with a filter, or raise --limit"));
322
415
  }
323
416
 
417
+ /** One row, from the shared reader. Falls back to the old shape so an older
418
+ * control plane still prints. */
324
419
  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}`;
420
+ const at = dim(String(l.at || l.time || "").slice(11, 19));
421
+ const level = String(l.level || l.severity || "info").toLowerCase();
422
+ const tag = level.startsWith("err") || level === "critical" ? red("ERROR")
423
+ : level.startsWith("warn") ? cyan("WARN ")
424
+ : dim(level === "debug" ? "DEBUG" : "INFO ");
425
+ const src = l.source && l.source !== "app" ? dim(` ${l.source}`) : "";
426
+ const body = l.http
427
+ ? `${l.http.method} ${l.http.path} ${l.http.status} ${l.http.ms}ms`
428
+ : l.msg ?? l.message ?? "";
429
+ return `${at} ${tag}${src} ${body}`;
328
430
  }
329
431
 
330
432
  async function errors(args) {
@@ -342,10 +444,25 @@ async function diagnose(args) {
342
444
  const d = await api(`/api/apps/${app}/diagnose`, { method: "POST", body: { error: args.error } });
343
445
  if (args.json) return json(d);
344
446
  if (d.healthy) { print(green("✓ ") + d.message); return; }
447
+
448
+ // A DIAGNOSIS WITHOUT A PROMPT IS STILL A DIAGNOSIS.
449
+ //
450
+ // The server answers `{healthy:false, message:"… is still deploying — nothing
451
+ // to diagnose until it lands"}` for a build that has not finished, and this
452
+ // used to fall through to `(no prompt returned)` and throw the message away. A
453
+ // deploy report recorded exactly that: twelve minutes into a stalled build, the
454
+ // command whose job is to explain the failure printed an empty prompt and no
455
+ // reason.
456
+ if (!d.fixPrompt) {
457
+ if (d.message) { info(d.message); return; }
458
+ info("nothing to diagnose — no failure was recorded for this app");
459
+ return;
460
+ }
461
+
345
462
  info(dim("diagnosing: ") + (d.subject || d.error || "").slice(0, 200));
346
463
  print("");
347
464
  print(bold("Fix prompt (paste into your coding agent):"));
348
- print(d.fixPrompt || "(no prompt returned)");
465
+ print(d.fixPrompt);
349
466
  }
350
467
 
351
468
  async function env(args) {
@@ -354,7 +471,10 @@ async function env(args) {
354
471
  if (!sub) {
355
472
  const d = await api(`/api/apps/${app}/env`);
356
473
  if (args.json) return json(d.keys || []);
357
- if (!(d.keys || []).length) { info("no env vars set"); return; }
474
+ // The note, when there is one. "No env vars set" and "we could not read them"
475
+ // are opposite facts, and printing the first about the second is how a deploy
476
+ // report came to record a variable as missing that was actually there.
477
+ if (!(d.keys || []).length) { info(d.note || "no env vars set"); return; }
358
478
  for (const k of d.keys) print(k);
359
479
  return;
360
480
  }
@@ -381,6 +501,335 @@ async function env(args) {
381
501
  die(`unknown env subcommand: ${sub}`);
382
502
  }
383
503
 
504
+ // ---------- the rest of the platform ----------
505
+ //
506
+ // Everything below exists because the answer to "can I do that from the CLI?"
507
+ // has to be yes. A command that sends someone to the dashboard for one setting
508
+ // sends an AGENT nowhere at all: it cannot open a browser, cannot click, and
509
+ // cannot tell its user what it could not do. Domains, access, the database, the
510
+ // connected repository, the plan and the tokens were all dashboard-only, and
511
+ // each of them is a place an agent-driven deploy stopped.
512
+ //
513
+ // None of them needed a new endpoint. The control plane resolves a Bearer token
514
+ // exactly where it resolves a session cookie (apps/web/lib/session.ts), so every
515
+ // route the dashboard calls already answers a CLI that asks.
516
+
517
+ /**
518
+ * The custom domains attached to one app: read them, attach one, detach one.
519
+ *
520
+ * The DNS record is printed by `printRecord` from what the SERVER decided, never
521
+ * from a rule this file carries. An apex cannot be a CNAME and a subdomain
522
+ * should be one, and that decision lives in apps/web/lib/dns-record.ts — a copy
523
+ * here would agree today and, the first time its public-suffix list grows, tell
524
+ * somebody to create a record their registrar refuses.
525
+ */
526
+ async function domains(args) {
527
+ const app = needApp(args);
528
+ const [, sub, host] = args._;
529
+
530
+ if (!sub) {
531
+ const d = await api(`/api/apps/${app}/domains`);
532
+ if (args.json) return json(d);
533
+ const list = d.domains || [];
534
+ if (!list.length) {
535
+ info(`no domain connected — ${bold(`bay domains ${app} add <hostname>`)}`);
536
+ if (d.allowed === false) info(dim("custom domains are not on this account's plan"));
537
+ return;
538
+ }
539
+ for (const dom of list) printDomain(dom, d.dns);
540
+ // Said once, under the list, and only when it is true. A private app cannot
541
+ // answer at a domain it does not own the cookie for — the edge sends those
542
+ // visitors back to the platform address — so a domain that is `live` and an
543
+ // app that is private is a working certificate onto a redirect.
544
+ if (d.visibility && d.visibility !== "public") {
545
+ print("");
546
+ info(dim(`${app} is ${d.visibility}: visitors at a custom domain are sent back to its platform address to sign in.`));
547
+ info(dim(`make it public with: bay share ${app} public`));
548
+ }
549
+ return;
550
+ }
551
+
552
+ if (sub === "add") {
553
+ if (!host) die(`usage: bay domains ${app} add <hostname>`);
554
+ const d = await api(`/api/apps/${app}/domains`, { method: "POST", body: { hostname: host } });
555
+ if (args.json) return json(d);
556
+ print(green("✓ ") + `${d.domain.hostname} is attached to ${app}`);
557
+ printDomain(d.domain, d.dns);
558
+ return;
559
+ }
560
+
561
+ if (sub === "remove" || sub === "rm") {
562
+ if (!host) die(`usage: bay domains ${app} remove <hostname>`);
563
+ const d = await api(`/api/apps/${app}/domains?hostname=${encodeURIComponent(host)}`, { method: "DELETE" });
564
+ if (args.json) return json(d);
565
+ print(green("✓ ") + `${host} detached — its certificate is gone, so stop pointing DNS at us`);
566
+ return;
567
+ }
568
+
569
+ die(`unknown: domains ${sub}\nusage: bay domains <app> [add <hostname> | remove <hostname>]`);
570
+ }
571
+
572
+ /** One domain, its state, and — while it is not live — the record to create. */
573
+ function printDomain(d, dns) {
574
+ const state = {
575
+ live: () => `${green("● live")}`,
576
+ securing: () => `${yellow("◐ securing")} ${dim("— the certificate is being issued, usually a few minutes")}`,
577
+ pending_dns: () => `${yellow("○ waiting")} ${dim("— for the DNS record below")}`,
578
+ failed: () => `${red("✗ failed")}`,
579
+ }[d.status];
580
+ print(`${bold(d.hostname)} ${state ? state() : dim(d.status)}`);
581
+ if (d.detail) print(dim(" " + d.detail));
582
+ if (d.status !== "live") printRecord(d, dns);
583
+ }
584
+
585
+ /**
586
+ * The record, as the fields a DNS panel actually asks for.
587
+ *
588
+ * `guessed` is printed rather than hidden. It means the server could not place
589
+ * the name against its suffix list and fell back to `A`, which works everywhere
590
+ * — but somebody moving a subdomain would rather know they can use a CNAME.
591
+ */
592
+ function printRecord(d, dns) {
593
+ const r = d.record;
594
+ if (!r) {
595
+ // An older control plane, which sends `dns` without deciding the record. Both
596
+ // options, plainly, rather than a rule this file would have to keep.
597
+ if (!dns) return;
598
+ print(dim(" create ONE of these where your DNS is managed:"));
599
+ print(dim(" A @ ") + dns.ip + dim(" (at the root of the domain)"));
600
+ print(dim(" CNAME <sub> ") + dns.cname + dim(" (for a subdomain)"));
601
+ return;
602
+ }
603
+ print(dim(" create this record where your DNS is managed:"));
604
+ print(dim(" type ") + r.type);
605
+ print(dim(" name ") + r.name + (r.name === "@" ? dim(" (the root of the domain)") : ""));
606
+ print(dim(" value ") + r.value);
607
+ if (r.guessed) print(dim(" (A works at any name; a subdomain could also use a CNAME to " + (dns ? dns.cname : "its platform address") + ")"));
608
+ }
609
+
610
+ /**
611
+ * Who can open the app: the visibility, the people, and the rules.
612
+ *
613
+ * `bay share <app>` alone shows it. Everything else is one word — a
614
+ * visibility, or add/remove with an address or a @domain — and the parsing is in
615
+ * lib/share-args.js, because reading `acme.com` as a person instead of as a
616
+ * company is a mistake that would not look like one afterwards.
617
+ */
618
+ async function share(args) {
619
+ const app = needApp(args);
620
+ const { parseShare, shareBody } = require("./lib/share-args");
621
+ const action = parseShare(args._.slice(1));
622
+ if (action.kind === "error") die(action.why);
623
+
624
+ const path = `/api/apps/${app}/share`;
625
+ const d =
626
+ action.kind === "show" ? await api(path)
627
+ : action.kind === "visibility" ? await api(path, { method: "POST", body: { visibility: action.visibility } })
628
+ : await api(path, { method: "POST", body: shareBody(action) });
629
+
630
+ if (args.json) return json(d);
631
+
632
+ if (action.kind === "add") {
633
+ print(green("✓ ") + (action.audience === "email"
634
+ ? `${action.value} can open ${app} — they have been emailed the link`
635
+ : `anyone with an @${action.value} address can open ${app}`));
636
+ }
637
+ if (action.kind === "remove") print(green("✓ ") + `removed ${action.value}`);
638
+ printAccess(app, d);
639
+ }
640
+
641
+ function printAccess(app, d) {
642
+ const said = {
643
+ private: `${red("● private")} ${dim("— only you")}`,
644
+ shared: `${yellow("● shared")} ${dim("— you, and the people and rules below")}`,
645
+ public: `${green("● public")} ${dim("— anyone with the link, no sign-in")}`,
646
+ }[d.visibility];
647
+ print(`${bold(app)} ${said || dim(String(d.visibility))}`);
648
+
649
+ for (const email of d.grants || []) print(dim(" person ") + email);
650
+ for (const domain of d.domains || []) print(dim(" rule ") + `everyone @${domain}`);
651
+ // Said once, under the rules, because it is the difference between a rule that
652
+ // works and a person who cannot get in: a rule admits an address the identity
653
+ // provider PROVED — Google, or a verified GitHub address. Somebody who signed
654
+ // up with a password at the same domain is refused, and the page they see says
655
+ // to sign in with Google instead.
656
+ if ((d.domains || []).length) info(dim(" a rule admits verified addresses only — password signups at that domain are not let in"));
657
+
658
+ // A pending request is somebody who is currently looking at a locked door. It
659
+ // is the only line here that is waiting on the reader, so it says the command
660
+ // that answers it.
661
+ for (const email of d.requests || []) {
662
+ print(`${yellow(" asking")} ${email} ${dim(`— bay share ${app} add ${email}`)}`);
663
+ }
664
+
665
+ if (d.workspaceDomain && !(d.domains || []).includes(d.workspaceDomain)) {
666
+ info(dim(` everyone at your company: bay share ${app} add @${d.workspaceDomain}`));
667
+ }
668
+ }
669
+
670
+ /**
671
+ * The app's database, read-only, from here.
672
+ *
673
+ * `db <app>` lists the tables, `db <app> <table>` reads rows, and `--sql`
674
+ * asks one SELECT. Read-only is the server's rule, not a convention this file
675
+ * follows: the route refuses anything that does not begin with SELECT, and
676
+ * refuses a second statement. Which is the right rule — a CLI that could DROP a
677
+ * production table on a typo'd argument is not a tool an agent should be handed.
678
+ */
679
+ async function db(args) {
680
+ const app = needApp(args);
681
+ const [, table] = args._;
682
+ const { renderTable } = require("./lib/rows");
683
+
684
+ if (args.sql) {
685
+ const d = await api(`/api/apps/${app}/db`, { method: "POST", body: { sql: String(args.sql) }, quiet: true });
686
+ if (args.json) return json(d);
687
+ if (d.error) die(d.error);
688
+ if (!(d.rows || []).length) { info("no rows"); return; }
689
+ for (const l of renderTable(d.columns, d.rows)) print(l);
690
+ return;
691
+ }
692
+
693
+ if (!table) {
694
+ const d = await api(`/api/apps/${app}/db`, { quiet: true });
695
+ if (args.json) return json(d);
696
+ if (d.error) die(d.error);
697
+ const list = d.tables || [];
698
+ if (!list.length) { info("this database has no tables yet"); return; }
699
+ info(dim(d.database));
700
+ for (const t of list) {
701
+ const n = t.rowsExact ? String(t.rows) : `~${t.rows}`;
702
+ print(`${bold(t.name.padEnd(28))} ${dim(n.padStart(8) + " rows " + t.columns + " cols")}`);
703
+ }
704
+ info(dim(`\nread one: bay db ${app} <table>`));
705
+ return;
706
+ }
707
+
708
+ const qs = new URLSearchParams({ table });
709
+ if (args.limit) qs.set("limit", String(args.limit));
710
+ if (args.offset) qs.set("offset", String(args.offset));
711
+ const d = await api(`/api/apps/${app}/db?${qs.toString()}`, { quiet: true });
712
+ if (args.json) return json(d);
713
+ if (d.error) die(d.error);
714
+ const names = (d.columns || []).map((c) => (typeof c === "string" ? c : c.name));
715
+ for (const l of renderTable(names, d.rows || [])) print(l);
716
+ // The count is the reason `--limit`/`--offset` exist, so it is said even when
717
+ // one page is the whole table.
718
+ const shown = (d.rows || []).length;
719
+ info(dim(`${shown} of ${d.totalExact ? d.total : "~" + d.total} rows${d.orderedBy ? ", " + d.orderedBy : ""}`));
720
+ }
721
+
722
+ /**
723
+ * The repository this app follows, and whether a push to it ships.
724
+ *
725
+ * Connecting one is still a browser flow — it installs a GitHub App, which is
726
+ * GitHub's consent screen and cannot be automated away. Everything after that
727
+ * is here: which branch, whether pushes deploy, and disconnecting.
728
+ */
729
+ async function git(args) {
730
+ const app = needApp(args);
731
+ const [, sub] = args._;
732
+ const path = `/api/apps/${app}/git`;
733
+
734
+ if (sub === "disconnect") {
735
+ const d = await api(path, { method: "DELETE" });
736
+ if (args.json) return json(d);
737
+ print(green("✓ ") + `${app} no longer follows a repository — it keeps running, and reship still works`);
738
+ return;
739
+ }
740
+
741
+ const wantsBranch = typeof args.branch === "string";
742
+ const wantsAuto = args.auto !== undefined;
743
+ if (wantsBranch || wantsAuto) {
744
+ const body = {};
745
+ if (wantsBranch) body.branch = args.branch;
746
+ if (wantsAuto) {
747
+ const on = args.auto === true || /^(on|yes|true)$/i.test(String(args.auto));
748
+ const off = /^(off|no|false)$/i.test(String(args.auto));
749
+ if (!on && !off) die(`--auto takes on or off, not "${args.auto}"`);
750
+ body.autoDeploy = on;
751
+ }
752
+ const d = await api(path, { method: "PUT", body });
753
+ if (args.json) return json(d);
754
+ print(green("✓ ") + `${d.repo} · ${d.branch} · ${d.autoDeploy ? "ships on push" : "manual"}`);
755
+ return;
756
+ }
757
+
758
+ const d = await api(path);
759
+ if (args.json) return json(d);
760
+ if (!d.connected) {
761
+ info(`${app} follows no repository`);
762
+ info(dim(`connect one at ${baseUrl()}/apps/${app} — it installs a GitHub App, which needs a browser`));
763
+ return;
764
+ }
765
+ print(`${bold(d.repo)} ${dim(d.url)}`);
766
+ print(dim(" branch ") + d.branch);
767
+ print(dim(" push ") + (d.autoDeploy ? green("ships automatically") : "does nothing until you reship"));
768
+ }
769
+
770
+ /**
771
+ * The plan, and every meter that can stop a deploy.
772
+ *
773
+ * `whoami` says which account. This says what that account may still do — which
774
+ * is the question behind every 402 the API returns, and the one an agent should
775
+ * be able to answer before it starts an eleven-minute build.
776
+ */
777
+ async function plan(args) {
778
+ const d = await api("/api/account");
779
+ if (args.json) return json(d);
780
+
781
+ print(`${bold(d.plan || "free")}${d.locked ? red(" · locked") : ""}${d.email ? dim(" " + d.email) : ""}`);
782
+ const u = d.usage || {};
783
+ // null is the wire form of unlimited — see the `cap()` in the account route.
784
+ const meter = (label, used, max) =>
785
+ print(dim(" " + label.padEnd(12)) + `${used ?? 0}${max === null || max === undefined ? dim(" of unlimited") : dim(" of " + max)}`);
786
+ meter("apps", u.apps, u.maxApps);
787
+ meter("public", u.publicApps, u.maxPublicApps);
788
+ meter("builds", u.builds, u.monthlyBuilds);
789
+ meter("agent runs", u.agentRuns, u.monthlyAgentRuns);
790
+
791
+ const f = d.features || {};
792
+ const has = (on, name) => (on ? green("✓ ") + name : dim("· " + name));
793
+ print(" " + [has(f.autoFix, "auto-fix"), has(f.customDomains, "custom domains"), has(f.canRemoveBadge, "badge off")].join(" "));
794
+ info(dim(`\nchange plan: ${baseUrl()}/settings`));
795
+ }
796
+
797
+ /**
798
+ * Every CLI holding a key to this account, and the way to take one back.
799
+ *
800
+ * A token is minted in a browser — that is the whole point of the loopback flow
801
+ * — but revoking one must not need the same browser. A laptop that is gone, a CI
802
+ * runner that was decommissioned, a token pasted into the wrong terminal: those
803
+ * are moments when a person wants one command, not a dashboard.
804
+ */
805
+ async function tokens(args) {
806
+ const [sub, id] = args._;
807
+
808
+ if (sub === "revoke") {
809
+ if (!id) die("usage: bay tokens revoke <id> (ids come from: bay tokens)");
810
+ const d = await api("/api/account/tokens", { method: "POST", body: { revoke: id } });
811
+ if (args.json) return json(d);
812
+ // `ok` is read rather than assumed. This route answers 404 for an id that is
813
+ // not yours, which `api` turns into a refusal — but its sibling
814
+ // /api/cli/token answers 200 with `ok: false` for the same case, and a
815
+ // command that printed ✓ on that would be telling somebody a key had been
816
+ // taken back when it had not.
817
+ if (d.ok === false) die(`no token ${id} on this account — check: bay tokens`);
818
+ print(green("✓ ") + `revoked ${id} — that CLI is signed out`);
819
+ return;
820
+ }
821
+
822
+ const d = await api("/api/account/tokens");
823
+ const list = d.tokens || [];
824
+ if (args.json) return json(list);
825
+ if (!list.length) { info("no CLI tokens on this account"); return; }
826
+ for (const t of list) {
827
+ const last = t.last_used_at ? new Date(t.last_used_at).toISOString().slice(0, 10) : "never";
828
+ print(`${dim(t.id)} ${bold((t.name || "cli").padEnd(20))} ${dim("added " + String(t.created_at).slice(0, 10) + " · last used " + last)}`);
829
+ }
830
+ info(dim("\nrevoke one: bay tokens revoke <id>"));
831
+ }
832
+
384
833
  /**
385
834
  * The repair agent's fix, as a patch, on stdout and nothing else.
386
835
  *
@@ -444,9 +893,19 @@ async function exec(args) {
444
893
  if (d.exitCode) { info(red(`exited ${d.exitCode}`)); process.exitCode = d.exitCode; }
445
894
  }
446
895
 
896
+ /**
897
+ * Open the app in a browser, at the address the SERVER says it has.
898
+ *
899
+ * This used to build `https://<app>.supersonic.cv` here. That is one root
900
+ * hardcoded into a published npm package: it survives a rebrand, it ignores
901
+ * every custom domain the app has, and it cannot be corrected without a release.
902
+ * The app row knows its own URL — ask it, and fall back to the old spelling only
903
+ * if the answer is missing.
904
+ */
447
905
  async function open(args) {
448
906
  const app = needApp(args);
449
- const url = `https://${app}.thebay.cloud`;
907
+ const d = await api(`/api/apps/${app}`);
908
+ const url = d.url || `https://${app}.${brand.DOMAIN}`;
450
909
  info(`opening ${url}`);
451
910
  openBrowser(url);
452
911
  }
@@ -555,7 +1014,26 @@ const REMOVED_DEPLOY_FLAGS = ["dev-cmd", "dev-port", "no-preview"];
555
1014
  * "deploying — your app will be live at" and reports success for the thing it did
556
1015
  * not ask for.
557
1016
  */
558
- const SHIP_FLAGS = ["run", "wait", "no-env", "github", "repo", "prebuilt", "json", "help"];
1017
+ const SHIP_FLAGS = ["name", "run", "wait", "no-env", "github", "repo", "prebuilt", "json", "help"];
1018
+
1019
+ /**
1020
+ * What the app is called.
1021
+ *
1022
+ * The folder's name by default, which is right nearly always — you ship from the
1023
+ * project's root and that is what the project is called. `--name` exists because
1024
+ * the dashboard asks for a name before it hands you this command, and a name the
1025
+ * user typed there has to survive into the deploy; without the flag the CLI would
1026
+ * refuse the whole command (SHIP_FLAGS above is a hard list) and the name would be
1027
+ * silently the folder's anyway.
1028
+ *
1029
+ * The server decides the SLUG from this — see resolveSlug — and reuses the one it
1030
+ * already gave a deploy of the same name, which is what makes a redeploy land on
1031
+ * the same address instead of creating a second app beside the first.
1032
+ */
1033
+ function appNameFrom(args) {
1034
+ const raw = typeof args.name === "string" && args.name.trim() ? args.name : path.basename(process.cwd());
1035
+ return raw.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "") || "app";
1036
+ }
559
1037
 
560
1038
  async function deploy(args) {
561
1039
  if (args.help) return usage(true);
@@ -589,12 +1067,12 @@ async function deploy(args) {
589
1067
  if (args.github || args.repo) {
590
1068
  let repo = args.repo;
591
1069
  if (!repo) { repo = await gitOrigin(); if (!repo) die("no git remote 'origin' found here — pass --repo <url>"); }
592
- info(cyan("▸ ") + "deploying from " + bold(repo));
1070
+ info(cyan("▸ ") + "shipping from " + bold(repo));
593
1071
  const res = await api("/api/deploy", { method: "POST", body: { repo }, stream: true });
594
1072
  return consumeDeploy(res, args);
595
1073
  }
596
1074
  // 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";
1075
+ const appName = appNameFrom(args);
598
1076
 
599
1077
  // The fast path: your machine already has this project and builds it in seconds, so
600
1078
  // build here and send only the result. Uploading sources and rebuilding them in the
@@ -639,24 +1117,29 @@ async function deploy(args) {
639
1117
  async function urlFirstDeploy(args) {
640
1118
  let repo = args.repo;
641
1119
  if (args.github && !repo) { repo = await gitOrigin(); }
642
- const folderName = path.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "") || "app";
1120
+ const folderName = appNameFrom(args);
643
1121
 
644
1122
  // 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 } });
1123
+ // The repo names the app when it is a repo deploy, exactly as before a
1124
+ // folder name sent alongside would change the slug every existing GitHub
1125
+ // deploy resolves to. `--name` overrides either, because it was typed.
1126
+ const reserveBody = repo ? { repo } : { name: folderName };
1127
+ if (args.name) reserveBody.name = folderName;
1128
+ const r = await api("/api/deploy/reserve", { method: "POST", body: reserveBody });
646
1129
  const { slug, url } = r;
647
1130
  // Not "✓ live". Nothing has been built yet — this is the moment the slug was
648
1131
  // reserved, and the build can still fail. Eight agents in a row read that
649
1132
  // checkmark as "done", stopped watching, and reported a working deploy for an
650
1133
  // app that never came up. The URL is real and does work from here (it serves a
651
1134
  // 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));
1135
+ print(dim("⧗ ") + "shipping — your app will be live at " + bold(url));
653
1136
 
654
1137
  // Default: DON'T hold the caller hostage for the whole build. A coding agent
655
1138
  // that runs `bay deploy` should get the live URL and its prompt back in
656
1139
  // ~1s, not sit blocked for two minutes. So the build is handed to a detached
657
1140
  // background worker that keeps the deploy connection open (the server keeps
658
1141
  // building, CPU allocated, until it lands) and logs to
659
- // ~/.bay/deploys/<slug>.log. Pass --wait to stay attached and stream the
1142
+ // <config dir>/deploys/<slug>.log. Pass --wait to stay attached and stream the
660
1143
  // build here instead.
661
1144
  if (!args.wait) {
662
1145
  const logDir = path.join(CFG_DIR, "deploys");
@@ -826,7 +1309,7 @@ async function runBuildAndWait({ slug, url, repo, folderName, args }) {
826
1309
  * build after the foreground command has already returned the live URL. */
827
1310
  async function deployWorker() {
828
1311
  const slug = process.env.SS_BG_SLUG, url = process.env.SS_BG_URL;
829
- if (!slug || !url) die("deploy worker: missing context");
1312
+ if (!slug || !url) die("ship worker: missing context");
830
1313
  await runBuildAndWait({
831
1314
  slug, url,
832
1315
  repo: process.env.SS_BG_REPO || "",
@@ -950,9 +1433,13 @@ function packageDir(dir) {
950
1433
  async function redeploy(args) {
951
1434
  const app = needApp(args);
952
1435
  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 });
1436
+ if (!d.repo) die(`${app} was shipped from a computer — run \`bay ship\` in its folder to send an update`);
1437
+ info(cyan("▸ ") + "reshipping " + bold(app));
1438
+ // The slug travels, because the server would otherwise resolve one from the
1439
+ // repo URL — and an app created under a name of its own (the dashboard asks
1440
+ // for one) does not answer to that name, so the redeploy would build a
1441
+ // SECOND app beside the one being redeployed.
1442
+ const res = await api("/api/deploy", { method: "POST", body: { repo: d.repo, slug: app }, stream: true });
956
1443
  return consumeDeploy(res, args, app);
957
1444
  }
958
1445
 
@@ -995,7 +1482,11 @@ const BODY_LIMIT = 32 * 1024 * 1024;
995
1482
  function encryptSource(buf, pass) {
996
1483
  const { createCipheriv, scryptSync } = require("node:crypto");
997
1484
  const iv = require("node:crypto").randomBytes(16);
998
- const cipher = createCipheriv("aes-256-cbc", scryptSync(pass, "bay-deploy-run", 32), iv);
1485
+ // The salt is NOT renamed, and must not be. It is half of a key the control
1486
+ // plane derives with the same literal (apps/web/lib/deploy-runs.ts, KEY_SALT):
1487
+ // change it here and every upload decrypts to garbage on a server that still
1488
+ // says the old word.
1489
+ const cipher = createCipheriv("aes-256-cbc", scryptSync(pass, "supersonic-deploy-run", 32), iv);
999
1490
  return Buffer.concat([iv, cipher.update(buf), cipher.final()]);
1000
1491
  }
1001
1492
 
@@ -1026,7 +1517,7 @@ async function uploadSourceToBucket(body) {
1026
1517
  headers: { "Content-Type": "application/octet-stream" },
1027
1518
  body: sealed,
1028
1519
  });
1029
- if (!put.ok) die(`upload failed (${put.status}) — the code never left your machine, so nothing was deployed`);
1520
+ if (!put.ok) die(`upload failed (${put.status}) — the code never left your machine, so nothing was shipped`);
1030
1521
  return { object: spot.object, key };
1031
1522
  }
1032
1523
 
@@ -1130,7 +1621,7 @@ async function consumeDeploy(res, args, knownSlug) {
1130
1621
  if (slug) {
1131
1622
  const deploy = await followDeployOnServer(slug);
1132
1623
  if (deploy?.status === "live") {
1133
- const url = deploy.url || `https://${slug}.thebay.cloud`;
1624
+ const url = deploy.url || `https://${slug}.${brand.DOMAIN}`;
1134
1625
  if (args.json) json({ ok: true, slug, url, runId });
1135
1626
  else print(green("✓ live: ") + url);
1136
1627
  process.exit(0);
@@ -1151,7 +1642,7 @@ async function consumeDeploy(res, args, knownSlug) {
1151
1642
  // here: it is all the information there is.
1152
1643
  const lost = streamError ? ` (the connection failed: ${streamError})` : "";
1153
1644
  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>`);
1645
+ die(`the ship ended without confirming it went live${lost} — the build may have failed or timed out. Check: bay apps · bay logs <app>`);
1155
1646
  }
1156
1647
 
1157
1648
  // ---------- helpers ----------
@@ -1170,25 +1661,30 @@ function usage(all = false) {
1170
1661
  print(`${bold("bay")} — publish your app in one command
1171
1662
 
1172
1663
  ${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")})
1664
+ ${green("bay ship")} publish this folder and print the live URL
1665
+ (opens a browser to sign in the first time;
1666
+ ${dim("`bay deploy` is the same command")})
1176
1667
 
1177
1668
  ${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
1669
+ bay init write a draft supersonic.json from this repo
1670
+ bay check what each phase would run, and what would fail
1180
1671
 
1181
1672
  ${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
1673
+ bay logs <app> recent logs
1674
+ bay diagnose <app> AI fix-prompt for your coding agent
1675
+ bay apps list your apps
1676
+ bay open <app> open the app in a browser
1677
+ bay login sign in manually
1678
+
1679
+ ${bold("the rest of the platform")} ${dim("(nothing here needs the dashboard)")}
1680
+ bay share <app> public who can open it
1681
+ bay domains <app> add <host> connect a domain you own
1682
+ bay db <app> read its database
1187
1683
 
1188
1684
  ${dim("more commands: bay help --all · --json on any command for machine output")}`);
1189
1685
  return;
1190
1686
  }
1191
- print(`${bold("bay")} — deploy & debug from your coding agent
1687
+ print(`${bold("bay")} — ship & debug from your coding agent
1192
1688
 
1193
1689
  ${bold("setup")}
1194
1690
  bay signup create an account (opens browser, one time)
@@ -1202,13 +1698,14 @@ ${bold("author")} ${dim("(local: no cloud, no build, no model — about two seco
1202
1698
 
1203
1699
  ${bold("ship")} ${dim("(URL-first: a live link in ~0.1s, the build drawn on it while it runs)")}
1204
1700
  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
1701
+ bay ship --name <name> what to call it (default: this folder's name)
1702
+ ${dim("(`bay deploy` does the same thing and always will every flag here works with either)")}
1703
+ bay ship --run "<prod start cmd>" how to run it in PROD — you know the stack
1704
+ e.g. --run "uvicorn main:app --host 0.0.0.0 --port $PORT"
1705
+ bay ship --wait stay attached and stream the build (default: returns once live)
1706
+ bay ship --no-env don't carry .env up (default: sets vars your app doesn't have yet)
1707
+ bay ship --github [--repo <url>] ship from GitHub / a git URL instead
1708
+ bay ship --prebuilt old path: build here, upload the result
1212
1709
  bay reship <app> rebuild from the app's source
1213
1710
  bay patch <app> the repair agent's fix, to pipe into git apply
1214
1711
  bay rollback <app> roll back to the previous version
@@ -1228,6 +1725,33 @@ ${bold("config")}
1228
1725
  bay env <app> unset KEY remove env var(s)
1229
1726
  bay open <app> open the app in a browser
1230
1727
 
1728
+ ${bold("who can open it")}
1729
+ bay share <app> the visibility, the people, the rules, who is asking
1730
+ bay share <app> private|shared|public change it
1731
+ bay share <app> add ada@acme.com invite one person (they get an email)
1732
+ bay share <app> add @acme.com everyone with an address there
1733
+ bay share <app> remove <email|@domain>
1734
+
1735
+ ${bold("its own address")}
1736
+ bay domains <app> what is attached, and the DNS record to create
1737
+ bay domains <app> add acme.com attach a domain you own
1738
+ bay domains <app> remove acme.com
1739
+
1740
+ ${bold("its data")}
1741
+ bay db <app> the tables, with row counts
1742
+ bay db <app> <table> [--limit 50] [--offset 0]
1743
+ bay db <app> --sql "select ..." one read-only statement
1744
+
1745
+ ${bold("shipping from GitHub")}
1746
+ bay git <app> the repo it follows, and whether a push ships
1747
+ bay git <app> --branch main --auto on
1748
+ bay git <app> disconnect
1749
+
1750
+ ${bold("the account")}
1751
+ bay plan plan, every limit, and what is left of it
1752
+ bay tokens every CLI signed in to this account
1753
+ bay tokens revoke <id> take one back
1754
+
1231
1755
  ${dim("global: --json for machine-readable output · $BAY_TOKEN overrides login")}`);
1232
1756
  }
1233
1757
 
@@ -1256,7 +1780,7 @@ function parse(argv) {
1256
1780
  * prompt, README and script that already exists, so it does not get deprecated,
1257
1781
  * warned about, or removed. Two words, one command, forever.
1258
1782
  */
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 };
1783
+ const COMMANDS = { signup, login, logout, whoami, apps, status, logs, errors, diagnose, env, domains, domain: domains, share, access: share, db, git, plan, account: plan, tokens, patch, rollback, exec, open, init, check, ship: deploy, deploy, reship: redeploy, redeploy, delete: del, rm: del, "__deploy-worker": deployWorker };
1260
1784
 
1261
1785
  (async () => {
1262
1786
  const [, , cmd, ...rest] = process.argv;