@mybase-dev/cli 1.0.1 → 1.0.3
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/dist/mybase.js +331 -11
- package/package.json +1 -1
package/dist/mybase.js
CHANGED
|
@@ -139,7 +139,7 @@ function parseDotenv(text) {
|
|
|
139
139
|
// package.json
|
|
140
140
|
var package_default = {
|
|
141
141
|
name: "@mybase-dev/cli",
|
|
142
|
-
version: "1.0.
|
|
142
|
+
version: "1.0.3",
|
|
143
143
|
description: "The mybase CLI: projects, keys, cron, queues, cache, rate limits, analytics, docs, and agent setup for a self-hosted mybase.",
|
|
144
144
|
type: "module",
|
|
145
145
|
repository: {
|
|
@@ -3724,8 +3724,21 @@ var compose_default = `# Runs mybase: the control plane (API + dashboard), the s
|
|
|
3724
3724
|
# Optionally POSTGRES_BIND=127.0.0.1, GARAGE_S3_BIND=127.0.0.1, and VALKEY_BIND=127.0.0.1 to stop exposing those ports on the LAN.
|
|
3725
3725
|
# 5. docker compose up -d
|
|
3726
3726
|
# Apps on serverless hosts reach Postgres at wss://mybase.<domain>/db with @neondatabase/serverless,
|
|
3727
|
+
# or, with a router port forward, at a normal postgres:// URL (public Postgres, below).
|
|
3727
3728
|
# and the cache at https://mybase.<domain>/cache/<project> (@mybase-dev/sdk/cache or @upstash/redis).
|
|
3728
3729
|
# Cloudflare's free plan caps request bodies at 100 MB: bigger dashboard uploads only work on the LAN.
|
|
3730
|
+
#
|
|
3731
|
+
# Public Postgres (any driver, from anywhere): \`mybase server database-public\` sets this up. By hand:
|
|
3732
|
+
# 1. On your router, forward TCP 5432 to this machine (it reveals your home IP; won't work behind CGNAT).
|
|
3733
|
+
# 2. Create a Cloudflare API token with Zone \u203A DNS \u203A Edit + Zone \u203A Zone \u203A Read for your zone.
|
|
3734
|
+
# 3. In .env:
|
|
3735
|
+
# MYBASE_DB_PUBLIC=on
|
|
3736
|
+
# MYBASE_DB_PUBLIC_HOST=db.<domain>
|
|
3737
|
+
# MYBASE_DNS_TOKEN=<token>
|
|
3738
|
+
# 4. docker compose up -d
|
|
3739
|
+
# mybase then keeps an unproxied A record for db.<domain> on your public IP, gets a Let's Encrypt
|
|
3740
|
+
# certificate for it (DNS challenge, no port 80), and connection strings end in ?sslmode=require.
|
|
3741
|
+
# From the internet only project roles can sign in, to their own database, over TLS.
|
|
3729
3742
|
name: mybase
|
|
3730
3743
|
|
|
3731
3744
|
services:
|
|
@@ -3759,6 +3772,12 @@ services:
|
|
|
3759
3772
|
MYBASE_PUBLIC_URL: \${MYBASE_PUBLIC_URL:-http://\${MYBASE_PUBLIC_HOST:-localhost}:\${MYBASE_PORT:-4000}}
|
|
3760
3773
|
# Postgres over WebSocket, for apps that can't reach the Postgres port. Defaults to <MYBASE_PUBLIC_URL>/db.
|
|
3761
3774
|
MYBASE_DB_WS_URL: \${MYBASE_DB_WS_URL:-}
|
|
3775
|
+
# Public Postgres (see the top of this file): on + MYBASE_DB_PUBLIC_HOST + MYBASE_DNS_TOKEN.
|
|
3776
|
+
MYBASE_DB_PUBLIC: \${MYBASE_DB_PUBLIC:-}
|
|
3777
|
+
MYBASE_DNS_TOKEN: \${MYBASE_DNS_TOKEN:-}
|
|
3778
|
+
# Optional: contact for Let's Encrypt, and 1 to use its staging CA (untrusted certs, for testing).
|
|
3779
|
+
MYBASE_ACME_EMAIL: \${MYBASE_ACME_EMAIL:-}
|
|
3780
|
+
MYBASE_ACME_STAGING: \${MYBASE_ACME_STAGING:-}
|
|
3762
3781
|
MYBASE_MAX_UPLOAD: \${MYBASE_MAX_UPLOAD:-1073741824}
|
|
3763
3782
|
VALKEY_HOST: valkey
|
|
3764
3783
|
VALKEY_PORT: 6379
|
|
@@ -3875,6 +3894,8 @@ configs:
|
|
|
3875
3894
|
metadata_dir = "/var/lib/garage/meta"
|
|
3876
3895
|
data_dir = "/var/lib/garage/data"
|
|
3877
3896
|
db_engine = "lmdb"
|
|
3897
|
+
# Garage maps 1 TiB by default; Raspberry Pi kernels give processes only 512 GiB (39-bit VA).
|
|
3898
|
+
lmdb_map_size = "64G"
|
|
3878
3899
|
replication_factor = 1
|
|
3879
3900
|
rpc_bind_addr = "[::]:3901"
|
|
3880
3901
|
rpc_public_addr = "127.0.0.1:3901"
|
|
@@ -3933,6 +3954,10 @@ var SECTIONS = [
|
|
|
3933
3954
|
keys: ["MYBASE_PORT", "MYBASE_PUBLIC_HOST", "MYBASE_PUBLIC_URL", "MYBASE_S3_PUBLIC_URL", "MYBASE_TRUST_PROXY", "POSTGRES_PORT", "GARAGE_S3_PORT", "VALKEY_PORT", "POSTGRES_BIND", "GARAGE_S3_BIND", "VALKEY_BIND"]
|
|
3934
3955
|
},
|
|
3935
3956
|
{ title: "Cloudflare Tunnel (public HTTPS without opening router ports)", keys: ["COMPOSE_PROFILES", "CLOUDFLARE_TUNNEL_TOKEN"] },
|
|
3957
|
+
{
|
|
3958
|
+
title: "Public Postgres (router port forward; `mybase server database-public`)",
|
|
3959
|
+
keys: ["MYBASE_DB_PUBLIC", "MYBASE_DB_PUBLIC_HOST", "MYBASE_DNS_TOKEN", "MYBASE_ACME_EMAIL", "MYBASE_ACME_STAGING"]
|
|
3960
|
+
},
|
|
3936
3961
|
{ title: "Startup: `no` keeps mybase from starting again after a reboot or crash", keys: ["MYBASE_RESTART"] },
|
|
3937
3962
|
{ title: "Tuning", keys: ["CACHE_MAXMEMORY", "GARAGE_CAPACITY", "MYBASE_MAX_UPLOAD", "LOG_LEVEL"] }
|
|
3938
3963
|
];
|
|
@@ -4198,14 +4223,15 @@ import { homedir as homedir2, hostname as hostname3 } from "os";
|
|
|
4198
4223
|
import { join as join3, resolve as resolve4 } from "path";
|
|
4199
4224
|
import { styleText as styleText3 } from "util";
|
|
4200
4225
|
|
|
4201
|
-
// src/cloudflare.ts
|
|
4226
|
+
// ../../packages/shared/src/cloudflare.ts
|
|
4202
4227
|
var API = "https://api.cloudflare.com/client/v4";
|
|
4203
4228
|
var TOKEN_PERMISSIONS = ["Account \u203A Cloudflare Tunnel \u203A Edit", "Zone \u203A DNS \u203A Edit", "Zone \u203A Zone \u203A Read"];
|
|
4229
|
+
var DNS_TOKEN_PERMISSIONS = ["Zone \u203A DNS \u203A Edit", "Zone \u203A Zone \u203A Read"];
|
|
4204
4230
|
var TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens";
|
|
4205
4231
|
|
|
4206
4232
|
class CloudflareError extends Error {
|
|
4207
4233
|
}
|
|
4208
|
-
function cloudflare(token, fetcher = fetch) {
|
|
4234
|
+
function cloudflare(token, fetcher = fetch, permissions = TOKEN_PERMISSIONS) {
|
|
4209
4235
|
async function call(method, path, body) {
|
|
4210
4236
|
const res = await fetcher(`${API}${path}`, {
|
|
4211
4237
|
method,
|
|
@@ -4218,7 +4244,7 @@ function cloudflare(token, fetcher = fetch) {
|
|
|
4218
4244
|
if (!res.ok || !data?.success) {
|
|
4219
4245
|
const message = data?.errors?.map((e) => e.message).join("; ") || res.statusText;
|
|
4220
4246
|
if (res.status === 401 || res.status === 403)
|
|
4221
|
-
throw new CloudflareError(`Cloudflare refused the token (${message}). It needs: ${
|
|
4247
|
+
throw new CloudflareError(`Cloudflare refused the token (${message}). It needs: ${permissions.join(", ")}.`);
|
|
4222
4248
|
throw new CloudflareError(`Cloudflare: ${message}`);
|
|
4223
4249
|
}
|
|
4224
4250
|
return data.result;
|
|
@@ -4226,6 +4252,19 @@ function cloudflare(token, fetcher = fetch) {
|
|
|
4226
4252
|
return {
|
|
4227
4253
|
listAccounts: () => call("GET", "/accounts?per_page=50"),
|
|
4228
4254
|
listZones: () => call("GET", "/zones?per_page=50&status=active"),
|
|
4255
|
+
async zoneFor(host) {
|
|
4256
|
+
const zones = await call("GET", "/zones?per_page=50&status=active");
|
|
4257
|
+
return zoneOf(zones, host);
|
|
4258
|
+
},
|
|
4259
|
+
async upsertRecord(zoneId, record) {
|
|
4260
|
+
const path = `/zones/${zoneId}/dns_records`;
|
|
4261
|
+
const existing = await call("GET", `${path}?type=${record.type}&name=${encodeURIComponent(record.name)}`);
|
|
4262
|
+
if (existing[0])
|
|
4263
|
+
return call("PUT", `${path}/${existing[0].id}`, record);
|
|
4264
|
+
return call("POST", path, record);
|
|
4265
|
+
},
|
|
4266
|
+
createRecord: (zoneId, record) => call("POST", `/zones/${zoneId}/dns_records`, record),
|
|
4267
|
+
deleteRecord: (zoneId, id) => call("DELETE", `/zones/${zoneId}/dns_records/${id}`),
|
|
4229
4268
|
async ensureTunnel(accountId, name) {
|
|
4230
4269
|
const existing = await call("GET", `/accounts/${accountId}/cfd_tunnel?name=${encodeURIComponent(name)}&is_deleted=false`);
|
|
4231
4270
|
if (existing[0])
|
|
@@ -4252,7 +4291,16 @@ function cloudflare(token, fetcher = fetch) {
|
|
|
4252
4291
|
};
|
|
4253
4292
|
}
|
|
4254
4293
|
var pointsAtTunnel = (record, tunnelId) => record.content === `${tunnelId}.cfargotunnel.com`;
|
|
4255
|
-
|
|
4294
|
+
function zoneOf(zones, host) {
|
|
4295
|
+
const name = host.toLowerCase().replace(/\.$/, "");
|
|
4296
|
+
let best = null;
|
|
4297
|
+
for (const zone of zones) {
|
|
4298
|
+
const z = zone.name.toLowerCase();
|
|
4299
|
+
if ((name === z || name.endsWith(`.${z}`)) && z.length > (best?.name.length ?? -1))
|
|
4300
|
+
best = zone;
|
|
4301
|
+
}
|
|
4302
|
+
return best;
|
|
4303
|
+
}
|
|
4256
4304
|
// src/server-setup.ts
|
|
4257
4305
|
var DEFAULT_DIR = join3(homedir2(), "mybase");
|
|
4258
4306
|
var USERNAME_PATTERN = /^[a-zA-Z0-9_.@-]{1,64}$/;
|
|
@@ -4481,6 +4529,70 @@ async function bindStep(lan, existing) {
|
|
|
4481
4529
|
}));
|
|
4482
4530
|
return Object.fromEntries(BIND_KEYS.map((k) => [k, lock ? "127.0.0.1" : null]));
|
|
4483
4531
|
}
|
|
4532
|
+
var DB_PUBLIC_KEYS = ["MYBASE_DB_PUBLIC", "MYBASE_DB_PUBLIC_HOST", "MYBASE_DNS_TOKEN", "MYBASE_ACME_EMAIL", "MYBASE_ACME_STAGING"];
|
|
4533
|
+
async function dbPublicStep(existing) {
|
|
4534
|
+
ui.note([
|
|
4535
|
+
`Apps anywhere get a normal postgres:// URL, with any driver. You need:`,
|
|
4536
|
+
`\u2022 Your router to forward TCP port ${existing.POSTGRES_PORT || "5432"} to this machine. ${dim("Shows your home IP; not possible behind CGNAT.")}`,
|
|
4537
|
+
`\u2022 A Cloudflare token that mybase keeps, to point db.<domain> at your IP and get a TLS certificate.`,
|
|
4538
|
+
` Create one at ${cyan(TOKEN_URL)} ${dim("(Custom token)")} with only:`,
|
|
4539
|
+
...DNS_TOKEN_PERMISSIONS.map((perm) => ` \u2022 ${perm}`)
|
|
4540
|
+
].join(`
|
|
4541
|
+
`), "Public Postgres");
|
|
4542
|
+
const tunnelHost = URL.parse(existing.MYBASE_PUBLIC_URL ?? "")?.hostname ?? "";
|
|
4543
|
+
for (;; ) {
|
|
4544
|
+
const token = (await ask(password({ message: "Cloudflare API token (DNS edit)", validate: (v) => v?.trim() ? undefined : "required" }))).trim();
|
|
4545
|
+
const cf = cloudflare(token, fetch, DNS_TOKEN_PERMISSIONS);
|
|
4546
|
+
const s = ui.spinner();
|
|
4547
|
+
s.start("Looking up your domains");
|
|
4548
|
+
let zones;
|
|
4549
|
+
try {
|
|
4550
|
+
zones = await cf.listZones();
|
|
4551
|
+
} catch (err) {
|
|
4552
|
+
s.error(err.message);
|
|
4553
|
+
if (await ask(confirm2({ message: "Try another token?" })))
|
|
4554
|
+
continue;
|
|
4555
|
+
throw new CliError("public Postgres not set up: no working token");
|
|
4556
|
+
}
|
|
4557
|
+
if (zones.length === 0) {
|
|
4558
|
+
s.error("No active domains on this token");
|
|
4559
|
+
if (await ask(confirm2({ message: "Try another token?" })))
|
|
4560
|
+
continue;
|
|
4561
|
+
throw new CliError("public Postgres not set up: the token can't see any domain");
|
|
4562
|
+
}
|
|
4563
|
+
s.stop(`Found ${zones.length} domain${zones.length === 1 ? "" : "s"}`);
|
|
4564
|
+
const preferred = zoneOf(zones, existing.MYBASE_DB_PUBLIC_HOST || tunnelHost);
|
|
4565
|
+
const zoneId = zones.length === 1 ? zones[0].id : await ask(select({ message: "Which domain?", options: zones.map((z) => ({ value: z.id, label: z.name })), initialValue: preferred?.id }));
|
|
4566
|
+
const chosen = zones.find((z) => z.id === zoneId);
|
|
4567
|
+
const suggested = existing.MYBASE_DB_PUBLIC_HOST && zoneOf([chosen], existing.MYBASE_DB_PUBLIC_HOST) ? existing.MYBASE_DB_PUBLIC_HOST : `db.${chosen.name}`;
|
|
4568
|
+
const host = (await ask(text({
|
|
4569
|
+
message: "Hostname for the database",
|
|
4570
|
+
placeholder: suggested,
|
|
4571
|
+
defaultValue: suggested,
|
|
4572
|
+
validate: (v) => {
|
|
4573
|
+
const h = (v || suggested).trim().toLowerCase();
|
|
4574
|
+
if (validHost(h))
|
|
4575
|
+
return validHost(h);
|
|
4576
|
+
return zoneOf([chosen], h) ? undefined : `must end in ${chosen.name}`;
|
|
4577
|
+
}
|
|
4578
|
+
}))).trim().toLowerCase() || suggested;
|
|
4579
|
+
const record = await cf.findRecord(chosen.id, host).catch(() => null);
|
|
4580
|
+
if (record && record.type !== "A") {
|
|
4581
|
+
const replace = await ask(confirm2({ message: `${host} already has a ${record.type} record (${record.content}). Replace it?`, initialValue: false }));
|
|
4582
|
+
if (!replace)
|
|
4583
|
+
continue;
|
|
4584
|
+
await cf.deleteRecord(chosen.id, record.id);
|
|
4585
|
+
}
|
|
4586
|
+
const email = (await ask(text({ message: "Email for Let's Encrypt expiry notices (optional)", placeholder: "skip" }))).trim();
|
|
4587
|
+
return {
|
|
4588
|
+
MYBASE_DB_PUBLIC: "on",
|
|
4589
|
+
MYBASE_DB_PUBLIC_HOST: host,
|
|
4590
|
+
MYBASE_DNS_TOKEN: token,
|
|
4591
|
+
MYBASE_ACME_EMAIL: email || null,
|
|
4592
|
+
POSTGRES_BIND: null
|
|
4593
|
+
};
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4484
4596
|
async function adminStep() {
|
|
4485
4597
|
const when = await ask(select({
|
|
4486
4598
|
message: "Create the admin account now?",
|
|
@@ -4604,6 +4716,22 @@ async function writeCompose(dir, interactive, replace) {
|
|
|
4604
4716
|
await Bun.write(file, compose_default);
|
|
4605
4717
|
return true;
|
|
4606
4718
|
}
|
|
4719
|
+
function pullHint(output) {
|
|
4720
|
+
const failed = /Image (\S+) Error/.exec(output)?.[1];
|
|
4721
|
+
if (/unauthorized|denied|requested access to the resource/i.test(output)) {
|
|
4722
|
+
const image = failed ?? "an image";
|
|
4723
|
+
return failed?.startsWith("ghcr.io/") ? `
|
|
4724
|
+
|
|
4725
|
+
${image} is private on GitHub's registry (or doesn't exist). Its owner has to make the package public (GitHub \u2192 Packages \u2192 Package settings \u2192 Change visibility), or sign in first: docker login ghcr.io` : `
|
|
4726
|
+
|
|
4727
|
+
The registry refused ${image}: check the image name (MYBASE_IMAGE / MYBASE_POSTGRES_IMAGE in .env), or docker login.`;
|
|
4728
|
+
}
|
|
4729
|
+
if (/timeout|no such host|network|TLS handshake/i.test(output))
|
|
4730
|
+
return `
|
|
4731
|
+
|
|
4732
|
+
This machine couldn't reach the registry. Check its internet connection and DNS, then run setup again.`;
|
|
4733
|
+
return "";
|
|
4734
|
+
}
|
|
4607
4735
|
async function missingImages(dir) {
|
|
4608
4736
|
const images = await compose(dir, ["config", "--images"]);
|
|
4609
4737
|
const missing = [];
|
|
@@ -4615,6 +4743,14 @@ async function missingImages(dir) {
|
|
|
4615
4743
|
return missing;
|
|
4616
4744
|
}
|
|
4617
4745
|
var imageExists = (image) => Bun.spawnSync(["docker", "image", "inspect", image], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
4746
|
+
async function brokenServices(dir) {
|
|
4747
|
+
const ps = await compose(dir, ["ps", "-a", "--format", "json"]);
|
|
4748
|
+
if (!ps.ok || !ps.stdout)
|
|
4749
|
+
return [];
|
|
4750
|
+
const rows = ps.stdout.startsWith("[") ? JSON.parse(ps.stdout) : ps.stdout.split(`
|
|
4751
|
+
`).filter(Boolean).map((l) => JSON.parse(l));
|
|
4752
|
+
return rows.filter((r) => r.State !== "running" || r.Health === "unhealthy").map((r) => r.Service);
|
|
4753
|
+
}
|
|
4618
4754
|
async function startStack(dir, env, pull = true) {
|
|
4619
4755
|
const s = ui.spinner();
|
|
4620
4756
|
if (pull) {
|
|
@@ -4625,7 +4761,7 @@ async function startStack(dir, env, pull = true) {
|
|
|
4625
4761
|
if (missing.length) {
|
|
4626
4762
|
s.error("Couldn't download the images");
|
|
4627
4763
|
throw new CliError(`couldn't download ${missing.join(", ")}:
|
|
4628
|
-
${tail(pulled.stderr || pulled.stdout)}`);
|
|
4764
|
+
${tail(pulled.stderr || pulled.stdout)}${pullHint(pulled.stderr || pulled.stdout)}`);
|
|
4629
4765
|
}
|
|
4630
4766
|
s.stop("Using the images already on this machine (couldn't download newer ones)");
|
|
4631
4767
|
} else
|
|
@@ -4642,11 +4778,21 @@ ${tail(up.stderr || up.stdout)}`);
|
|
|
4642
4778
|
const version = await waitHealthy(localUrl(env));
|
|
4643
4779
|
if (!version) {
|
|
4644
4780
|
s.error("mybase didn't come up");
|
|
4645
|
-
const
|
|
4646
|
-
|
|
4647
|
-
|
|
4781
|
+
const broken = await brokenServices(dir);
|
|
4782
|
+
const sections = [];
|
|
4783
|
+
for (const service of broken.length ? broken : ["mybase"]) {
|
|
4784
|
+
const logs = await compose(dir, ["logs", "--no-log-prefix", "--tail", "25", service]);
|
|
4785
|
+
sections.push(`\u2500\u2500 ${service} \u2500\u2500
|
|
4786
|
+
${logs.stdout || logs.stderr || "(no output)"}`);
|
|
4787
|
+
}
|
|
4788
|
+
const which = broken.length ? `Not running: ${broken.join(", ")}.` : "Every container is up, but the API isn't answering.";
|
|
4789
|
+
throw new CliError(`mybase didn't answer at ${localUrl(env)}/health within 3 minutes. ${which}
|
|
4648
4790
|
|
|
4649
|
-
|
|
4791
|
+
${sections.join(`
|
|
4792
|
+
|
|
4793
|
+
`)}
|
|
4794
|
+
|
|
4795
|
+
Fix the problem, then run \`mybase server setup --dir ${dir}\` again (keys are kept). \`mybase server status\` shows every container.`);
|
|
4650
4796
|
}
|
|
4651
4797
|
s.stop(`mybase ${version} is running`);
|
|
4652
4798
|
return version;
|
|
@@ -4862,6 +5008,9 @@ var serverUsage = ` server setup [--dir <dir>] Install mybase on this machi
|
|
|
4862
5008
|
server status [--dir <dir>] Containers, health, and whether mybase starts on boot
|
|
4863
5009
|
server update [--dir <dir>] Download new images and restart (also refreshes compose.yml)
|
|
4864
5010
|
server tunnel [--dir <dir>] Add or replace the Cloudflare Tunnel [--tunnel-token <t> --domain <d>] | --off
|
|
5011
|
+
server database-public [--dir <dir>] A normal postgres:// URL from anywhere: router port forward +
|
|
5012
|
+
Let's Encrypt TLS + DNS kept on your IP (Cloudflare)
|
|
5013
|
+
[--host <db.domain> --dns-token <t> [--email <e>] [--staging]] | --off
|
|
4865
5014
|
server autostart on|off Start mybase when this machine boots (and restart it after crashes)
|
|
4866
5015
|
Default dir: the last one setup used, else ~/mybase`;
|
|
4867
5016
|
var dim2 = (s) => styleText4("dim", s);
|
|
@@ -4929,7 +5078,7 @@ async function update(args) {
|
|
|
4929
5078
|
const version = await startStack(dir, values);
|
|
4930
5079
|
const minor = (v) => v.split(".").slice(0, 2).join(".");
|
|
4931
5080
|
if (minor(version) !== minor(CLI_VERSION)) {
|
|
4932
|
-
ui.warn(`The server is ${version}, this CLI ${CLI_VERSION}. Update the CLI:
|
|
5081
|
+
ui.warn(`The server is ${version}, this CLI ${CLI_VERSION}. Update the CLI: mybase update --cli`);
|
|
4933
5082
|
}
|
|
4934
5083
|
if (jsonMode)
|
|
4935
5084
|
return print({ dir, version, composeUpdated: replaced }, () => {});
|
|
@@ -4968,6 +5117,53 @@ async function tunnel(args) {
|
|
|
4968
5117
|
return print({ dir, version, publicUrl: host ? `https://${host}` : null }, () => {});
|
|
4969
5118
|
outro(host ? `Public at https://${host}. Turn on two-factor sign-in in Settings if you haven't.` : "Tunnel off. mybase is reachable on this machine and your network only.");
|
|
4970
5119
|
}
|
|
5120
|
+
async function databasePublic(args) {
|
|
5121
|
+
const dir = await serverDir(args);
|
|
5122
|
+
const { text, values } = await loadEnv(dir);
|
|
5123
|
+
const interactive = !jsonMode && process.stdin.isTTY === true;
|
|
5124
|
+
const off = args.includes("--off");
|
|
5125
|
+
if (!jsonMode)
|
|
5126
|
+
intro(`${styleText4("bold", " mybase ")} ${dim2("server database-public")}`);
|
|
5127
|
+
let updates;
|
|
5128
|
+
if (off) {
|
|
5129
|
+
updates = Object.fromEntries(DB_PUBLIC_KEYS.map((k) => [k, null]));
|
|
5130
|
+
} else if (flag(args, "--dns-token")) {
|
|
5131
|
+
const host = flag(args, "--host");
|
|
5132
|
+
if (!host)
|
|
5133
|
+
throw usageError("--dns-token needs --host (like db.example.com)");
|
|
5134
|
+
updates = {
|
|
5135
|
+
MYBASE_DB_PUBLIC: "on",
|
|
5136
|
+
MYBASE_DB_PUBLIC_HOST: host.toLowerCase(),
|
|
5137
|
+
MYBASE_DNS_TOKEN: flag(args, "--dns-token"),
|
|
5138
|
+
MYBASE_ACME_EMAIL: flag(args, "--email") ?? null,
|
|
5139
|
+
MYBASE_ACME_STAGING: args.includes("--staging") ? "1" : null,
|
|
5140
|
+
POSTGRES_BIND: null
|
|
5141
|
+
};
|
|
5142
|
+
} else if (interactive) {
|
|
5143
|
+
updates = await dbPublicStep(values);
|
|
5144
|
+
} else
|
|
5145
|
+
throw usageError("without a terminal, pass --host <db.domain> --dns-token <t>, or --off");
|
|
5146
|
+
if (!off && values.POSTGRES_BIND === "127.0.0.1")
|
|
5147
|
+
ui.info("Postgres now listens on your network again (POSTGRES_BIND), so the router can reach it.");
|
|
5148
|
+
await saveEnv(dir, mergeEnv(text, updates));
|
|
5149
|
+
const version = await startStack(dir, values, false);
|
|
5150
|
+
const port = values.POSTGRES_PORT || "5432";
|
|
5151
|
+
const lan = addresses().find((a) => a.label.startsWith("LAN IP"))?.value ?? "<this machine's LAN IP>";
|
|
5152
|
+
const host = off ? null : updates.MYBASE_DB_PUBLIC_HOST;
|
|
5153
|
+
if (jsonMode)
|
|
5154
|
+
return print({ dir, version, host, port: Number(port), forward: host ? `${lan}:${port}` : null }, () => {});
|
|
5155
|
+
if (!host)
|
|
5156
|
+
return outro("Public Postgres off. Connection strings point at your network address again. Remove the port forward on your router.");
|
|
5157
|
+
ui.note([
|
|
5158
|
+
`On your router (usually "Port forwarding" or "Virtual server"):`,
|
|
5159
|
+
` TCP ${port} (external) \u2192 ${lan}:${port}`,
|
|
5160
|
+
``,
|
|
5161
|
+
dim2("mybase now points the DNS record at your public IP and requests the certificate (about a minute)."),
|
|
5162
|
+
dim2("Check progress in the dashboard (Database \u2192 Settings) or with `mybase doctor`.")
|
|
5163
|
+
].join(`
|
|
5164
|
+
`), "Last step");
|
|
5165
|
+
outro(`New connection strings: postgres://<project>:<password>@${host}:${port}/<project>?sslmode=require`);
|
|
5166
|
+
}
|
|
4971
5167
|
async function autostart(args) {
|
|
4972
5168
|
const mode = args[0];
|
|
4973
5169
|
if (mode !== "on" && mode !== "off")
|
|
@@ -4998,6 +5194,8 @@ async function server(command, args) {
|
|
|
4998
5194
|
await update(args);
|
|
4999
5195
|
else if (command === "tunnel")
|
|
5000
5196
|
await tunnel(args);
|
|
5197
|
+
else if (command === "database-public")
|
|
5198
|
+
await databasePublic(args);
|
|
5001
5199
|
else if (command === "autostart")
|
|
5002
5200
|
await autostart(args);
|
|
5003
5201
|
else
|
|
@@ -5005,6 +5203,125 @@ async function server(command, args) {
|
|
|
5005
5203
|
return true;
|
|
5006
5204
|
}
|
|
5007
5205
|
|
|
5206
|
+
// src/update.ts
|
|
5207
|
+
import { chmod as chmod3, rename, rm } from "fs/promises";
|
|
5208
|
+
import { join as join5 } from "path";
|
|
5209
|
+
var updateUsage = ` update [--version <v>] [--cli] [--yes]
|
|
5210
|
+
Update this CLI to the latest release (or <v>), then the server on this
|
|
5211
|
+
machine, if there is one. --cli: only the CLI`;
|
|
5212
|
+
var REPO = "landonharter/mybase";
|
|
5213
|
+
var isCompiled = () => Bun.main.startsWith("/$bunfs/") || Bun.main.includes("~BUN");
|
|
5214
|
+
function assetName(platform = process.platform, arch = process.arch) {
|
|
5215
|
+
const os = platform === "win32" ? "windows" : platform;
|
|
5216
|
+
if (!["linux", "darwin", "windows"].includes(os))
|
|
5217
|
+
throw new CliError(`no mybase binary for ${platform}`);
|
|
5218
|
+
const cpu = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : null;
|
|
5219
|
+
if (!cpu)
|
|
5220
|
+
throw new CliError(`no mybase binary for ${arch} CPUs`);
|
|
5221
|
+
return `mybase-${os}-${cpu}${os === "windows" ? ".exe" : ""}`;
|
|
5222
|
+
}
|
|
5223
|
+
async function latestVersion() {
|
|
5224
|
+
const res = await fetch(`https://github.com/${REPO}/releases/latest`, { redirect: "manual" }).catch(() => {
|
|
5225
|
+
throw new CliError("can't reach github.com to check for updates");
|
|
5226
|
+
});
|
|
5227
|
+
const tag = /\/tag\/v?([^/?#]+)$/.exec(res.headers.get("location") ?? "")?.[1];
|
|
5228
|
+
if (!tag)
|
|
5229
|
+
throw new CliError(`couldn't find the latest release (GitHub answered ${res.status})`);
|
|
5230
|
+
return tag;
|
|
5231
|
+
}
|
|
5232
|
+
function newer(a, b) {
|
|
5233
|
+
const pa = a.split(/[.-]/).map((n) => Number.parseInt(n, 10) || 0);
|
|
5234
|
+
const pb = b.split(/[.-]/).map((n) => Number.parseInt(n, 10) || 0);
|
|
5235
|
+
for (let i = 0;i < 3; i++)
|
|
5236
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
5237
|
+
return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
5238
|
+
return false;
|
|
5239
|
+
}
|
|
5240
|
+
async function replaceBinary(version) {
|
|
5241
|
+
const target = process.execPath;
|
|
5242
|
+
const url = `https://github.com/${REPO}/releases/download/v${version}/${assetName()}`;
|
|
5243
|
+
const res = await fetch(url).catch(() => {
|
|
5244
|
+
throw new CliError(`can't download ${url}`);
|
|
5245
|
+
});
|
|
5246
|
+
if (!res.ok)
|
|
5247
|
+
throw new CliError(`can't download ${url} (${res.status})`);
|
|
5248
|
+
const temp = `${target}.new`;
|
|
5249
|
+
try {
|
|
5250
|
+
const writer = Bun.file(temp).writer();
|
|
5251
|
+
for await (const chunk of res.body)
|
|
5252
|
+
writer.write(chunk);
|
|
5253
|
+
await writer.end();
|
|
5254
|
+
await chmod3(temp, 493);
|
|
5255
|
+
} catch (err) {
|
|
5256
|
+
const code = err.code;
|
|
5257
|
+
if (code === "EACCES" || code === "EPERM")
|
|
5258
|
+
throw new CliError(`can't write next to ${target}. Run it with sudo, or reinstall: curl -fsSL https://raw.githubusercontent.com/${REPO}/master/scripts/install.sh | sh`);
|
|
5259
|
+
throw err;
|
|
5260
|
+
}
|
|
5261
|
+
const check = Bun.spawnSync([temp, "help"], { stdout: "ignore", stderr: "ignore" });
|
|
5262
|
+
if (check.exitCode !== 0) {
|
|
5263
|
+
await rm(temp, { force: true });
|
|
5264
|
+
throw new CliError(`the downloaded binary doesn't run on this machine; kept ${CLI_VERSION}`);
|
|
5265
|
+
}
|
|
5266
|
+
if (process.platform === "win32") {
|
|
5267
|
+
await rm(`${target}.old`, { force: true });
|
|
5268
|
+
await rename(target, `${target}.old`);
|
|
5269
|
+
}
|
|
5270
|
+
await rename(temp, target);
|
|
5271
|
+
}
|
|
5272
|
+
async function updateServer(bin, dir) {
|
|
5273
|
+
const args = [...bin, "server", "update", "--dir", dir, ...jsonMode ? ["--json", "--yes"] : []];
|
|
5274
|
+
const proc = Bun.spawn(args, { stdio: [jsonMode ? "ignore" : "inherit", jsonMode ? "ignore" : "inherit", "inherit"] });
|
|
5275
|
+
return await proc.exited === 0;
|
|
5276
|
+
}
|
|
5277
|
+
async function update2(args) {
|
|
5278
|
+
const versionIndex = args.indexOf("--version");
|
|
5279
|
+
const wanted = versionIndex === -1 ? null : args[versionIndex + 1]?.replace(/^v/, "");
|
|
5280
|
+
if (versionIndex !== -1 && !wanted)
|
|
5281
|
+
throw new CliError("--version needs a value", 2);
|
|
5282
|
+
const yes = args.includes("--yes");
|
|
5283
|
+
const result = {
|
|
5284
|
+
cli: { from: CLI_VERSION, to: CLI_VERSION, updated: false },
|
|
5285
|
+
server: null
|
|
5286
|
+
};
|
|
5287
|
+
if (!isCompiled()) {
|
|
5288
|
+
if (!jsonMode)
|
|
5289
|
+
console.log("This CLI was installed with npm/bun. Update it with: bun add -g @mybase-dev/cli@latest (or npm i -g)");
|
|
5290
|
+
} else {
|
|
5291
|
+
const target = wanted ?? await latestVersion();
|
|
5292
|
+
result.cli.to = target;
|
|
5293
|
+
if (target === CLI_VERSION || !wanted && !newer(target, CLI_VERSION)) {
|
|
5294
|
+
if (!jsonMode)
|
|
5295
|
+
console.log(`CLI ${CLI_VERSION} is up to date.`);
|
|
5296
|
+
} else {
|
|
5297
|
+
if (!jsonMode)
|
|
5298
|
+
console.log(`Updating the CLI: ${CLI_VERSION} \u2192 ${target}`);
|
|
5299
|
+
await replaceBinary(target);
|
|
5300
|
+
result.cli.updated = true;
|
|
5301
|
+
if (!jsonMode)
|
|
5302
|
+
console.log(`CLI ${target} installed at ${process.execPath}`);
|
|
5303
|
+
}
|
|
5304
|
+
}
|
|
5305
|
+
const dir = (await loadConfig()).server;
|
|
5306
|
+
const installed = dir && await Bun.file(join5(dir, "compose.yml")).exists();
|
|
5307
|
+
if (installed && !args.includes("--cli")) {
|
|
5308
|
+
let go = yes || jsonMode;
|
|
5309
|
+
if (!go && process.stdin.isTTY) {
|
|
5310
|
+
const answer = prompt(`Update the server in ${dir} too? (new images, restart) [Y/n]`);
|
|
5311
|
+
go = answer === null || /^(y|yes|)$/i.test(answer.trim());
|
|
5312
|
+
}
|
|
5313
|
+
if (go) {
|
|
5314
|
+
const bin = isCompiled() ? [process.execPath] : [process.execPath, Bun.main];
|
|
5315
|
+
const ok = await updateServer(bin, dir);
|
|
5316
|
+
result.server = { dir, updated: ok };
|
|
5317
|
+
if (!ok)
|
|
5318
|
+
throw new CliError(`the server update failed${result.cli.updated ? ` (the CLI is ${result.cli.to} now)` : ""}. Run \`mybase server update\` to see why.`);
|
|
5319
|
+
} else if (!jsonMode)
|
|
5320
|
+
console.log("Server left as it is. Update it later with: mybase server update");
|
|
5321
|
+
}
|
|
5322
|
+
print(result, () => {});
|
|
5323
|
+
}
|
|
5324
|
+
|
|
5008
5325
|
// src/users.ts
|
|
5009
5326
|
var usersUsage = ` users enable <project> Turn on users: an auth schema for Better Auth, plus setup steps
|
|
5010
5327
|
users init <project> [--env <secrets env>] Print an auth.ts for Better Auth; --env stores a new BETTER_AUTH_SECRET there
|
|
@@ -5145,6 +5462,7 @@ var projectUsage = ` project create <name> Create a new project (datab
|
|
|
5145
5462
|
var USAGE = {
|
|
5146
5463
|
auth: authUsage,
|
|
5147
5464
|
server: serverUsage,
|
|
5465
|
+
update: updateUsage,
|
|
5148
5466
|
token: tokenUsage,
|
|
5149
5467
|
project: projectUsage,
|
|
5150
5468
|
context: contextUsage,
|
|
@@ -5198,6 +5516,8 @@ async function main(args) {
|
|
|
5198
5516
|
return;
|
|
5199
5517
|
if (command === "run")
|
|
5200
5518
|
return run(args.slice(1));
|
|
5519
|
+
if (command === "update")
|
|
5520
|
+
return update2(args.slice(1));
|
|
5201
5521
|
if (command === "doctor")
|
|
5202
5522
|
return doctor(args.slice(1));
|
|
5203
5523
|
if (command === "audit")
|
package/package.json
CHANGED