@mybase-dev/cli 1.0.2 → 1.0.4

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.
Files changed (2) hide show
  1. package/dist/mybase.js +215 -5
  2. 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.2",
142
+ version: "1.0.4",
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
@@ -3935,6 +3954,10 @@ var SECTIONS = [
3935
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"]
3936
3955
  },
3937
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
+ },
3938
3961
  { title: "Startup: `no` keeps mybase from starting again after a reboot or crash", keys: ["MYBASE_RESTART"] },
3939
3962
  { title: "Tuning", keys: ["CACHE_MAXMEMORY", "GARAGE_CAPACITY", "MYBASE_MAX_UPLOAD", "LOG_LEVEL"] }
3940
3963
  ];
@@ -4200,14 +4223,15 @@ import { homedir as homedir2, hostname as hostname3 } from "os";
4200
4223
  import { join as join3, resolve as resolve4 } from "path";
4201
4224
  import { styleText as styleText3 } from "util";
4202
4225
 
4203
- // src/cloudflare.ts
4226
+ // ../../packages/shared/src/cloudflare.ts
4204
4227
  var API = "https://api.cloudflare.com/client/v4";
4205
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"];
4206
4230
  var TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens";
4207
4231
 
4208
4232
  class CloudflareError extends Error {
4209
4233
  }
4210
- function cloudflare(token, fetcher = fetch) {
4234
+ function cloudflare(token, fetcher = fetch, permissions = TOKEN_PERMISSIONS) {
4211
4235
  async function call(method, path, body) {
4212
4236
  const res = await fetcher(`${API}${path}`, {
4213
4237
  method,
@@ -4220,7 +4244,7 @@ function cloudflare(token, fetcher = fetch) {
4220
4244
  if (!res.ok || !data?.success) {
4221
4245
  const message = data?.errors?.map((e) => e.message).join("; ") || res.statusText;
4222
4246
  if (res.status === 401 || res.status === 403)
4223
- throw new CloudflareError(`Cloudflare refused the token (${message}). It needs: ${TOKEN_PERMISSIONS.join(", ")}.`);
4247
+ throw new CloudflareError(`Cloudflare refused the token (${message}). It needs: ${permissions.join(", ")}.`);
4224
4248
  throw new CloudflareError(`Cloudflare: ${message}`);
4225
4249
  }
4226
4250
  return data.result;
@@ -4228,6 +4252,19 @@ function cloudflare(token, fetcher = fetch) {
4228
4252
  return {
4229
4253
  listAccounts: () => call("GET", "/accounts?per_page=50"),
4230
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}`),
4231
4268
  async ensureTunnel(accountId, name) {
4232
4269
  const existing = await call("GET", `/accounts/${accountId}/cfd_tunnel?name=${encodeURIComponent(name)}&is_deleted=false`);
4233
4270
  if (existing[0])
@@ -4254,7 +4291,16 @@ function cloudflare(token, fetcher = fetch) {
4254
4291
  };
4255
4292
  }
4256
4293
  var pointsAtTunnel = (record, tunnelId) => record.content === `${tunnelId}.cfargotunnel.com`;
4257
-
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
+ }
4258
4304
  // src/server-setup.ts
4259
4305
  var DEFAULT_DIR = join3(homedir2(), "mybase");
4260
4306
  var USERNAME_PATTERN = /^[a-zA-Z0-9_.@-]{1,64}$/;
@@ -4483,6 +4529,70 @@ async function bindStep(lan, existing) {
4483
4529
  }));
4484
4530
  return Object.fromEntries(BIND_KEYS.map((k) => [k, lock ? "127.0.0.1" : null]));
4485
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
+ }
4486
4596
  async function adminStep() {
4487
4597
  const when = await ask(select({
4488
4598
  message: "Create the admin account now?",
@@ -4898,6 +5008,9 @@ var serverUsage = ` server setup [--dir <dir>] Install mybase on this machi
4898
5008
  server status [--dir <dir>] Containers, health, and whether mybase starts on boot
4899
5009
  server update [--dir <dir>] Download new images and restart (also refreshes compose.yml)
4900
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
4901
5014
  server autostart on|off Start mybase when this machine boots (and restart it after crashes)
4902
5015
  Default dir: the last one setup used, else ~/mybase`;
4903
5016
  var dim2 = (s) => styleText4("dim", s);
@@ -5004,6 +5117,101 @@ async function tunnel(args) {
5004
5117
  return print({ dir, version, publicUrl: host ? `https://${host}` : null }, () => {});
5005
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.");
5006
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
+ if (await writeCompose(dir, interactive, true))
5150
+ ui.success(`compose.yml updated to this CLI's (${CLI_VERSION})`);
5151
+ const version = await startStack(dir, values);
5152
+ const status = off ? null : await waitForDbPublic(localUrl(values));
5153
+ const port = values.POSTGRES_PORT || "5432";
5154
+ const lan = addresses().find((a) => a.label.startsWith("LAN IP"))?.value ?? "<this machine's LAN IP>";
5155
+ const host = off ? null : updates.MYBASE_DB_PUBLIC_HOST;
5156
+ if (jsonMode)
5157
+ return print({ dir, version, host, port: Number(port), forward: host ? `${lan}:${port}` : null, status }, () => {});
5158
+ if (!host)
5159
+ return outro("Public Postgres off. Connection strings point at your network address again. Remove the port forward on your router.");
5160
+ ui.note([
5161
+ `On your router (usually "Port forwarding" or "Virtual server"):`,
5162
+ ` TCP ${port} (external) \u2192 ${lan}:${port}`,
5163
+ ``,
5164
+ dim2(status?.tls.active ? "DNS and the certificate are ready." : "Check progress in the dashboard (Database \u2192 Settings) or with `mybase doctor`.")
5165
+ ].join(`
5166
+ `), "Last step");
5167
+ outro(`New connection strings: postgres://<project>:<password>@${host}:${port}/<project>?sslmode=require`);
5168
+ }
5169
+ async function waitForDbPublic(url) {
5170
+ const s = ui.spinner();
5171
+ s.start("Pointing DNS at your public IP and getting a certificate (about a minute)");
5172
+ const deadline = Date.now() + 150000;
5173
+ let last = null;
5174
+ for (;; ) {
5175
+ try {
5176
+ last = await api("/database/public", { url });
5177
+ } catch (err) {
5178
+ const status = err.status;
5179
+ if (status === 401 || status === 403) {
5180
+ s.stop("Can't check progress: this CLI isn't signed in to the server as admin");
5181
+ return null;
5182
+ }
5183
+ if (status !== 404)
5184
+ throw err;
5185
+ last = null;
5186
+ }
5187
+ if (typeof last?.enabled !== "boolean") {
5188
+ s.error("The server doesn't support public Postgres");
5189
+ throw new CliError("the running mybase image is older than this CLI. Run `mybase server update` (or set MYBASE_IMAGE to a newer build), then this command again.");
5190
+ }
5191
+ if (!last.enabled) {
5192
+ s.error("The server didn't get the settings");
5193
+ throw new CliError("MYBASE_DB_PUBLIC isn't reaching the API. Check that compose.yml passes it (`mybase server update` refreshes compose.yml).");
5194
+ }
5195
+ if (last.problem) {
5196
+ s.error(last.problem);
5197
+ throw new CliError(last.problem);
5198
+ }
5199
+ if (last.dns.error || last.tls.error) {
5200
+ s.error([last.dns.error && `DNS: ${last.dns.error}`, last.tls.error && `Certificate: ${last.tls.error}`].filter(Boolean).join("; "));
5201
+ ui.info("mybase keeps retrying (certificate: in 1 h, 6 h, then daily). Fix the cause, then run `mybase server update` to retry now.");
5202
+ return last;
5203
+ }
5204
+ if (last.tls.active) {
5205
+ s.stop(`${last.host} \u2192 ${last.dns.ip}, certificate valid until ${last.tls.notAfter?.slice(0, 10)}`);
5206
+ return last;
5207
+ }
5208
+ if (Date.now() > deadline) {
5209
+ s.stop(`Still working (${last.dns.ip ? `DNS \u2192 ${last.dns.ip}` : "DNS pending"}, certificate pending)`);
5210
+ return last;
5211
+ }
5212
+ await Bun.sleep(3000);
5213
+ }
5214
+ }
5007
5215
  async function autostart(args) {
5008
5216
  const mode = args[0];
5009
5217
  if (mode !== "on" && mode !== "off")
@@ -5034,6 +5242,8 @@ async function server(command, args) {
5034
5242
  await update(args);
5035
5243
  else if (command === "tunnel")
5036
5244
  await tunnel(args);
5245
+ else if (command === "database-public")
5246
+ await databasePublic(args);
5037
5247
  else if (command === "autostart")
5038
5248
  await autostart(args);
5039
5249
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mybase-dev/cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "The mybase CLI: projects, keys, cron, queues, cache, rate limits, analytics, docs, and agent setup for a self-hosted mybase.",
5
5
  "type": "module",
6
6
  "repository": {