@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/CHANGELOG.md +75 -0
- package/LICENSE +34 -0
- package/README.md +30 -13
- package/index.js +612 -88
- package/lib/brand.js +4 -1
- package/lib/check.js +15 -2
- package/lib/confirm.js +1 -1
- package/lib/draft.js +1 -1
- package/lib/home.js +46 -0
- package/lib/resolver.js +3 -3
- package/lib/rows.js +61 -0
- package/lib/share-args.js +93 -0
- package/lib/who.js +4 -3
- package/package.json +10 -10
- package/vendor/README.md +1 -1
- package/vendor/detector.js +2 -2
- package/vendor/inputs.json +2 -1
- package/vendor/resolve.js +9 -7
package/index.js
CHANGED
|
@@ -2,10 +2,26 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
* bay — the
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 (
|
|
112
|
-
function token() { return
|
|
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
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
|
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:
|
|
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 =
|
|
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 —
|
|
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}.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
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
|
|
326
|
-
const
|
|
327
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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("▸ ") + "
|
|
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 =
|
|
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 =
|
|
1120
|
+
const folderName = appNameFrom(args);
|
|
643
1121
|
|
|
644
1122
|
// 1) reserve the slug → a URL right away (live immediately, any stack)
|
|
645
|
-
|
|
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("⧗ ") + "
|
|
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
|
-
//
|
|
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("
|
|
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
|
|
954
|
-
info(cyan("▸ ") + "
|
|
955
|
-
|
|
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
|
-
|
|
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
|
|
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}.
|
|
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
|
|
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")}
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
|
1179
|
-
bay check
|
|
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>
|
|
1183
|
-
bay diagnose <app>
|
|
1184
|
-
bay apps
|
|
1185
|
-
bay open <app>
|
|
1186
|
-
bay login
|
|
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")} —
|
|
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
|
-
|
|
1206
|
-
bay
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
bay ship --
|
|
1210
|
-
bay ship --
|
|
1211
|
-
bay ship --
|
|
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;
|