@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.
Files changed (2) hide show
  1. package/dist/mybase.js +331 -11
  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.1",
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: ${TOKEN_PERMISSIONS.join(", ")}.`);
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 logs = await compose(dir, ["logs", "--tail", "30", "mybase"]);
4646
- throw new CliError(`mybase didn't answer at ${localUrl(env)}/health within 3 minutes. Its last log lines:
4647
- ${logs.stdout || logs.stderr}
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
- Fix the problem, then run \`mybase server setup --dir ${dir}\` again (keys are kept).`);
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: curl -fsSL https://raw.githubusercontent.com/LandonHarter/mybase/master/scripts/install.sh | sh`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mybase-dev/cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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": {