@mybase-dev/cli 1.0.12 → 1.0.14
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 +950 -125
- package/package.json +1 -1
package/dist/mybase.js
CHANGED
|
@@ -100,6 +100,7 @@ var init_src = __esm(() => {
|
|
|
100
100
|
{ id: "captcha", name: "Captcha", description: "Captcha settings (read), and verifying tokens (write)" },
|
|
101
101
|
{ id: "realtime", name: "Realtime", description: "Channels, members, and settings (read), and publishing messages (write)" },
|
|
102
102
|
{ id: "sandbox", name: "Agent sandboxes", description: "Sandboxes, executions, files, artifacts, and browser connections" },
|
|
103
|
+
{ id: "hosting", name: "Hosting", description: "Sites, deploys, and logs (read); pushing images, deploying, and changing sites (write)" },
|
|
103
104
|
{ id: "audit", name: "Audit log", description: "Who did what: sign-ins, key and project changes, secret reads (read only)" }
|
|
104
105
|
];
|
|
105
106
|
});
|
|
@@ -109,7 +110,7 @@ var package_default;
|
|
|
109
110
|
var init_package = __esm(() => {
|
|
110
111
|
package_default = {
|
|
111
112
|
name: "@mybase-dev/cli",
|
|
112
|
-
version: "1.0.
|
|
113
|
+
version: "1.0.14",
|
|
113
114
|
description: "The mybase CLI: projects, keys, cron, queues, cache, rate limits, analytics, docs, and agent setup for a self-hosted mybase.",
|
|
114
115
|
type: "module",
|
|
115
116
|
repository: {
|
|
@@ -1347,7 +1348,7 @@ ${h}
|
|
|
1347
1348
|
}
|
|
1348
1349
|
}
|
|
1349
1350
|
}).prompt();
|
|
1350
|
-
},
|
|
1351
|
+
}, log2, cancel = (o = "", t) => {
|
|
1351
1352
|
const i = t?.output ?? process.stdout, e = t?.withGuide ?? settings.withGuide ? `${styleText2("gray", S_BAR_END)} ` : "";
|
|
1352
1353
|
i.write(`${e}${styleText2("red", o)}
|
|
1353
1354
|
|
|
@@ -1557,7 +1558,7 @@ ${f}
|
|
|
1557
1558
|
}
|
|
1558
1559
|
}
|
|
1559
1560
|
}).prompt();
|
|
1560
|
-
}, i,
|
|
1561
|
+
}, i, text2 = (t) => new n({
|
|
1561
1562
|
validate: t.validate,
|
|
1562
1563
|
placeholder: t.placeholder,
|
|
1563
1564
|
defaultValue: t.defaultValue,
|
|
@@ -1638,7 +1639,7 @@ var init_dist4 = __esm(() => {
|
|
|
1638
1639
|
`${styleText2("dim", "Space:")} select`,
|
|
1639
1640
|
`${styleText2("dim", "Enter:")} confirm`
|
|
1640
1641
|
];
|
|
1641
|
-
|
|
1642
|
+
log2 = {
|
|
1642
1643
|
message: (s = [], {
|
|
1643
1644
|
symbol: e = styleText2("gray", S_BAR),
|
|
1644
1645
|
secondarySymbol: r = styleText2("gray", S_BAR),
|
|
@@ -1662,22 +1663,22 @@ var init_dist4 = __esm(() => {
|
|
|
1662
1663
|
`);
|
|
1663
1664
|
},
|
|
1664
1665
|
info: (s, e) => {
|
|
1665
|
-
|
|
1666
|
+
log2.message(s, { ...e, symbol: styleText2("blue", S_INFO) });
|
|
1666
1667
|
},
|
|
1667
1668
|
success: (s, e) => {
|
|
1668
|
-
|
|
1669
|
+
log2.message(s, { ...e, symbol: styleText2("green", S_SUCCESS) });
|
|
1669
1670
|
},
|
|
1670
1671
|
step: (s, e) => {
|
|
1671
|
-
|
|
1672
|
+
log2.message(s, { ...e, symbol: styleText2("green", S_STEP_SUBMIT) });
|
|
1672
1673
|
},
|
|
1673
1674
|
warn: (s, e) => {
|
|
1674
|
-
|
|
1675
|
+
log2.message(s, { ...e, symbol: styleText2("yellow", S_WARN) });
|
|
1675
1676
|
},
|
|
1676
1677
|
warning: (s, e) => {
|
|
1677
|
-
|
|
1678
|
+
log2.warn(s, e);
|
|
1678
1679
|
},
|
|
1679
1680
|
error: (s, e) => {
|
|
1680
|
-
|
|
1681
|
+
log2.message(s, { ...e, symbol: styleText2("red", S_ERROR) });
|
|
1681
1682
|
}
|
|
1682
1683
|
};
|
|
1683
1684
|
u2 = {
|
|
@@ -1857,8 +1858,13 @@ services:
|
|
|
1857
1858
|
# one to MYBASE_SECRETS_KEY_OLD for one restart.
|
|
1858
1859
|
MYBASE_SECRETS_KEY: \${MYBASE_SECRETS_KEY:-}
|
|
1859
1860
|
MYBASE_SECRETS_KEY_OLD: \${MYBASE_SECRETS_KEY_OLD:-}
|
|
1860
|
-
#
|
|
1861
|
+
# Who to believe about client IPs and https (login throttling, Secure cookies): \`cloudflare\`
|
|
1862
|
+
# behind the tunnel, or your reverse proxy's IPs/CIDRs (X-Forwarded-For). Unset: nobody.
|
|
1861
1863
|
MYBASE_TRUST_PROXY: \${MYBASE_TRUST_PROXY:-}
|
|
1864
|
+
# Where cron jobs and realtime webhooks may call: \`lan\` (default; private addresses OK) or
|
|
1865
|
+
# \`public\` (public internet only; use this on a cloud server). Loopback, cloud metadata, and
|
|
1866
|
+
# mybase's own containers are always refused.
|
|
1867
|
+
MYBASE_OUTBOUND: \${MYBASE_OUTBOUND:-lan}
|
|
1862
1868
|
# Creates the admin account on first boot instead of the setup page. Ignored once it exists.
|
|
1863
1869
|
MYBASE_ADMIN_USERNAME: \${MYBASE_ADMIN_USERNAME:-admin}
|
|
1864
1870
|
MYBASE_ADMIN_PASSWORD: \${MYBASE_ADMIN_PASSWORD:-}
|
|
@@ -1868,8 +1874,17 @@ services:
|
|
|
1868
1874
|
MYBASE_SANDBOX_WORKER_URL: \${MYBASE_SANDBOX_WORKER_URL:-}
|
|
1869
1875
|
MYBASE_SANDBOX_WORKER_KEY: \${MYBASE_SANDBOX_WORKER_KEY:-}
|
|
1870
1876
|
MYBASE_SANDBOX_CONFIG: \${MYBASE_SANDBOX_CONFIG:-}
|
|
1877
|
+
# Hosting (see the hosting services below). Sites live at <site>.MYBASE_HOSTING_DOMAIN.
|
|
1878
|
+
MYBASE_HOSTING_WORKER_URL: \${MYBASE_HOSTING_WORKER_URL:-}
|
|
1879
|
+
MYBASE_HOSTING_WORKER_KEY: \${MYBASE_HOSTING_WORKER_KEY:-}
|
|
1880
|
+
MYBASE_HOSTING_DOMAIN: \${MYBASE_HOSTING_DOMAIN:-}
|
|
1881
|
+
MYBASE_HOSTING_MEMORY: \${MYBASE_HOSTING_MEMORY:-512m}
|
|
1882
|
+
MYBASE_HOSTING_PORT: \${MYBASE_HOSTING_PORT:-80}
|
|
1871
1883
|
ports:
|
|
1872
1884
|
- "\${MYBASE_BIND:-0.0.0.0}:\${MYBASE_PORT:-4000}:4000"
|
|
1885
|
+
volumes:
|
|
1886
|
+
# Caddy's admin socket (hosting). Only the API shares this volume.
|
|
1887
|
+
- caddy-admin:/run/caddy
|
|
1873
1888
|
|
|
1874
1889
|
# Official Postgres plus extensions projects can enable (see postgres.Dockerfile).
|
|
1875
1890
|
# Upgrading from postgres:17-alpine: the data volume is compatible, but text sort order
|
|
@@ -1967,6 +1982,60 @@ services:
|
|
|
1967
1982
|
start_period: 30s
|
|
1968
1983
|
# Private Compose network only. No published worker or browser-debugging ports.
|
|
1969
1984
|
|
|
1985
|
+
# Hosting (opt-in, COMPOSE_PROFILES=hosting; \`mybase server hosting\` sets it up). Sites run as
|
|
1986
|
+
# containers on this Docker host, one network per project; Caddy routes <site>.<domain> to them.
|
|
1987
|
+
# Image registry: internal. Pushes go through the API (\`docker login <mybase host>\` with an API
|
|
1988
|
+
# key); the Docker daemon pulls from 127.0.0.1:5000, which only this machine can reach.
|
|
1989
|
+
registry:
|
|
1990
|
+
image: registry:3.0.0
|
|
1991
|
+
profiles: [hosting]
|
|
1992
|
+
restart: \${MYBASE_RESTART:-unless-stopped}
|
|
1993
|
+
environment:
|
|
1994
|
+
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
|
1995
|
+
# Upload URLs stay relative, so they work through the API's /v2 proxy.
|
|
1996
|
+
REGISTRY_HTTP_RELATIVEURLS: "true"
|
|
1997
|
+
OTEL_TRACES_EXPORTER: none
|
|
1998
|
+
ports:
|
|
1999
|
+
- "127.0.0.1:\${MYBASE_REGISTRY_PORT:-5000}:5000"
|
|
2000
|
+
volumes:
|
|
2001
|
+
- registry-data:/var/lib/registry
|
|
2002
|
+
|
|
2003
|
+
# Routes <site>.<domain> to each site's live container. Its config comes from the API, over an
|
|
2004
|
+
# admin socket on a volume only the API shares (Caddy is on every project network). TLS ends at
|
|
2005
|
+
# Cloudflare; on a LAN, sites answer on port 80 (MYBASE_HOSTING_PORT).
|
|
2006
|
+
caddy:
|
|
2007
|
+
image: caddy:2.10.2-alpine
|
|
2008
|
+
profiles: [hosting]
|
|
2009
|
+
restart: \${MYBASE_RESTART:-unless-stopped}
|
|
2010
|
+
command: caddy run --resume --config /etc/caddy/initial.json
|
|
2011
|
+
configs:
|
|
2012
|
+
- source: caddy
|
|
2013
|
+
target: /etc/caddy/initial.json
|
|
2014
|
+
ports:
|
|
2015
|
+
- "\${MYBASE_HOSTING_BIND:-0.0.0.0}:\${MYBASE_HOSTING_PORT:-80}:80"
|
|
2016
|
+
volumes:
|
|
2017
|
+
- caddy-admin:/run/caddy
|
|
2018
|
+
- caddy-config:/config
|
|
2019
|
+
- caddy-data:/data
|
|
2020
|
+
|
|
2021
|
+
# Trusted worker that runs site containers. No site ever gets this socket or key, and the
|
|
2022
|
+
# worker isn't on project networks.
|
|
2023
|
+
hosting-worker:
|
|
2024
|
+
image: \${MYBASE_HOSTING_WORKER_IMAGE:-ghcr.io/landonharter/mybase-hosting-worker:latest}
|
|
2025
|
+
profiles: [hosting]
|
|
2026
|
+
restart: \${MYBASE_RESTART:-unless-stopped}
|
|
2027
|
+
environment:
|
|
2028
|
+
MYBASE_HOSTING_WORKER_KEY: \${MYBASE_HOSTING_WORKER_KEY:-}
|
|
2029
|
+
MYBASE_COMPOSE_PROJECT: mybase
|
|
2030
|
+
LOG_LEVEL: \${LOG_LEVEL:-info}
|
|
2031
|
+
volumes:
|
|
2032
|
+
- /var/run/docker.sock:/var/run/docker.sock
|
|
2033
|
+
healthcheck:
|
|
2034
|
+
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:4020/health',{headers:{Authorization:'Bearer '+process.env.MYBASE_HOSTING_WORKER_KEY}}).then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
|
2035
|
+
interval: 30s
|
|
2036
|
+
timeout: 5s
|
|
2037
|
+
start_period: 20s
|
|
2038
|
+
|
|
1970
2039
|
# Cloudflare Tunnel: public HTTPS without opening router ports. Off unless COMPOSE_PROFILES=tunnel.
|
|
1971
2040
|
# Hostnames are set up in the Cloudflare dashboard (see the top of this file).
|
|
1972
2041
|
cloudflared:
|
|
@@ -1985,6 +2054,11 @@ services:
|
|
|
1985
2054
|
- mybase
|
|
1986
2055
|
|
|
1987
2056
|
configs:
|
|
2057
|
+
# Caddy's first config, until the API loads the real one (and after that, its autosave).
|
|
2058
|
+
caddy:
|
|
2059
|
+
content: |
|
|
2060
|
+
{"admin":{"listen":"unix//run/caddy/admin.sock|0666"},"apps":{"http":{"servers":{"sites":{"listen":[":80"],"automatic_https":{"disable":true}}}}}}
|
|
2061
|
+
|
|
1988
2062
|
garage:
|
|
1989
2063
|
content: |
|
|
1990
2064
|
metadata_dir = "/var/lib/garage/meta"
|
|
@@ -2029,6 +2103,10 @@ volumes:
|
|
|
2029
2103
|
garage-meta:
|
|
2030
2104
|
garage-data:
|
|
2031
2105
|
valkey-data:
|
|
2106
|
+
registry-data:
|
|
2107
|
+
caddy-admin:
|
|
2108
|
+
caddy-config:
|
|
2109
|
+
caddy-data:
|
|
2032
2110
|
`;
|
|
2033
2111
|
var init_compose = () => {};
|
|
2034
2112
|
|
|
@@ -2138,7 +2216,7 @@ function versionAtLeast(version, min) {
|
|
|
2138
2216
|
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
2139
2217
|
return true;
|
|
2140
2218
|
}
|
|
2141
|
-
var SECRET_KEYS, SECTIONS, LINE, isSecretKey = (key) => (key in SECRET_KEYS) || key === "CLOUDFLARE_TUNNEL_TOKEN" || key === "MYBASE_SANDBOX_WORKER_KEY";
|
|
2219
|
+
var SECRET_KEYS, SECTIONS, LINE, isSecretKey = (key) => (key in SECRET_KEYS) || key === "CLOUDFLARE_TUNNEL_TOKEN" || key === "MYBASE_SANDBOX_WORKER_KEY" || key === "MYBASE_HOSTING_WORKER_KEY";
|
|
2142
2220
|
var init_server_env = __esm(() => {
|
|
2143
2221
|
SECRET_KEYS = {
|
|
2144
2222
|
POSTGRES_PASSWORD: { bytes: 24, encoding: "hex" },
|
|
@@ -2173,7 +2251,7 @@ var init_server_env = __esm(() => {
|
|
|
2173
2251
|
});
|
|
2174
2252
|
|
|
2175
2253
|
// src/server-docker.ts
|
|
2176
|
-
import { existsSync, readFileSync } from "fs";
|
|
2254
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
2177
2255
|
import { statfs } from "fs/promises";
|
|
2178
2256
|
import { freemem, hostname as hostname2, networkInterfaces, totalmem } from "os";
|
|
2179
2257
|
function run2(cmd, cwd) {
|
|
@@ -2219,7 +2297,7 @@ async function compose(dir, args) {
|
|
|
2219
2297
|
return { ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim() };
|
|
2220
2298
|
}
|
|
2221
2299
|
async function stackExists(dir) {
|
|
2222
|
-
if (!
|
|
2300
|
+
if (!existsSync2(`${dir}/compose.yml`))
|
|
2223
2301
|
return false;
|
|
2224
2302
|
const ps = await compose(dir, ["ps", "-a", "-q"]);
|
|
2225
2303
|
return ps.ok && ps.stdout.length > 0;
|
|
@@ -2271,7 +2349,7 @@ function machine() {
|
|
|
2271
2349
|
}
|
|
2272
2350
|
async function freeDiskGb(dir) {
|
|
2273
2351
|
for (let d = dir;; d = d.replace(/\/[^/]*$/, "") || "/") {
|
|
2274
|
-
if (
|
|
2352
|
+
if (existsSync2(d)) {
|
|
2275
2353
|
try {
|
|
2276
2354
|
const s = await statfs(d);
|
|
2277
2355
|
return s.bavail * s.bsize / 2 ** 30;
|
|
@@ -2368,7 +2446,7 @@ var MIN_COMPOSE = "2.23.1", DOCKER_INSTALL_URL = "https://docs.docker.com/engine
|
|
|
2368
2446
|
var init_server_docker = __esm(() => {
|
|
2369
2447
|
init_compose();
|
|
2370
2448
|
init_server_env();
|
|
2371
|
-
LOG_SERVICES = ["mybase", "postgres", "garage", "valkey", "searxng", "cloudflared", "sandbox-worker"];
|
|
2449
|
+
LOG_SERVICES = ["mybase", "postgres", "garage", "valkey", "searxng", "cloudflared", "sandbox-worker", "hosting-worker", "caddy", "registry"];
|
|
2372
2450
|
LOG_ALIASES = {
|
|
2373
2451
|
api: "mybase",
|
|
2374
2452
|
dashboard: "mybase",
|
|
@@ -2380,16 +2458,18 @@ var init_server_docker = __esm(() => {
|
|
|
2380
2458
|
search: "searxng",
|
|
2381
2459
|
tunnel: "cloudflared",
|
|
2382
2460
|
sandbox: "sandbox-worker",
|
|
2383
|
-
worker: "sandbox-worker"
|
|
2461
|
+
worker: "sandbox-worker",
|
|
2462
|
+
hosting: "hosting-worker",
|
|
2463
|
+
proxy: "caddy"
|
|
2384
2464
|
};
|
|
2385
2465
|
});
|
|
2386
2466
|
|
|
2387
2467
|
// src/server-gvisor.ts
|
|
2388
|
-
import { createHash } from "crypto";
|
|
2389
|
-
import { access, mkdtemp, rm } from "fs/promises";
|
|
2468
|
+
import { createHash as createHash2 } from "crypto";
|
|
2469
|
+
import { access, mkdtemp, rm as rm3 } from "fs/promises";
|
|
2390
2470
|
import { constants as constants2 } from "fs";
|
|
2391
|
-
import { tmpdir } from "os";
|
|
2392
|
-
import { join as
|
|
2471
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
2472
|
+
import { join as join5 } from "path";
|
|
2393
2473
|
async function tail(stream) {
|
|
2394
2474
|
const decoder = new TextDecoder;
|
|
2395
2475
|
let text = "";
|
|
@@ -2443,7 +2523,7 @@ async function installGvisor(info, run = hostRun) {
|
|
|
2443
2523
|
break;
|
|
2444
2524
|
} catch {}
|
|
2445
2525
|
}
|
|
2446
|
-
const root = await mkdtemp(
|
|
2526
|
+
const root = await mkdtemp(join5(tmpdir3(), "mybase-gvisor-"));
|
|
2447
2527
|
try {
|
|
2448
2528
|
let archive = null;
|
|
2449
2529
|
if (!binary) {
|
|
@@ -2456,9 +2536,9 @@ async function installGvisor(info, run = hostRun) {
|
|
|
2456
2536
|
const response = await fetch(`${base}/gvisor.tar.bz2`, { signal: AbortSignal.timeout(180000) });
|
|
2457
2537
|
if (!response.ok || !response.body)
|
|
2458
2538
|
throw new CliError("couldn't download the gVisor release bundle");
|
|
2459
|
-
archive =
|
|
2539
|
+
archive = join5(root, "gvisor.tar.bz2");
|
|
2460
2540
|
const sink = Bun.file(archive).writer();
|
|
2461
|
-
const hash =
|
|
2541
|
+
const hash = createHash2("sha512");
|
|
2462
2542
|
let size = 0;
|
|
2463
2543
|
try {
|
|
2464
2544
|
for await (const chunk of response.body) {
|
|
@@ -2501,7 +2581,7 @@ async function installGvisor(info, run = hostRun) {
|
|
|
2501
2581
|
throw new CliError("gVisor was installed, but Docker's systemd service couldn't reload. Reload the daemon's runtime configuration on this host, then rerun this command; existing containers were not restarted");
|
|
2502
2582
|
return true;
|
|
2503
2583
|
} finally {
|
|
2504
|
-
await
|
|
2584
|
+
await rm3(root, { recursive: true, force: true });
|
|
2505
2585
|
}
|
|
2506
2586
|
}
|
|
2507
2587
|
var hostRun = async (args, options = {}) => {
|
|
@@ -2638,6 +2718,10 @@ function cloudflare(token, fetcher = fetch, permissions = TOKEN_PERMISSIONS) {
|
|
|
2638
2718
|
putIngress: (accountId, tunnelId, rules) => call("PUT", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
|
|
2639
2719
|
config: { ingress: [...rules, { service: "http_status:404" }] }
|
|
2640
2720
|
}),
|
|
2721
|
+
async getIngress(accountId, tunnelId) {
|
|
2722
|
+
const result = await call("GET", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`);
|
|
2723
|
+
return (result.config?.ingress ?? []).flatMap((r) => r.hostname ? [{ hostname: r.hostname, service: r.service }] : []);
|
|
2724
|
+
},
|
|
2641
2725
|
async findRecord(zoneId, name) {
|
|
2642
2726
|
const records = await call("GET", `/zones/${zoneId}/dns_records?name=${encodeURIComponent(name)}`);
|
|
2643
2727
|
return records[0] ?? null;
|
|
@@ -2653,6 +2737,11 @@ function cloudflare(token, fetcher = fetch, permissions = TOKEN_PERMISSIONS) {
|
|
|
2653
2737
|
tunnelToken: (accountId, tunnelId) => call("GET", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`)
|
|
2654
2738
|
};
|
|
2655
2739
|
}
|
|
2740
|
+
function mergeIngress(existing, updates, remove = []) {
|
|
2741
|
+
const drop = new Set([...updates.map((r) => r.hostname), ...remove]);
|
|
2742
|
+
const all = [...updates, ...existing.filter((r) => !drop.has(r.hostname))];
|
|
2743
|
+
return [...all.filter((r) => !r.hostname.startsWith("*.")), ...all.filter((r) => r.hostname.startsWith("*."))];
|
|
2744
|
+
}
|
|
2656
2745
|
function zoneOf(zones, host) {
|
|
2657
2746
|
const name = host.toLowerCase().replace(/\.$/, "");
|
|
2658
2747
|
let best = null;
|
|
@@ -2679,7 +2768,7 @@ var init_cloudflare2 = __esm(() => {
|
|
|
2679
2768
|
// src/server-setup.ts
|
|
2680
2769
|
import { chmod as chmod2, mkdir as mkdir3 } from "fs/promises";
|
|
2681
2770
|
import { homedir as homedir2, hostname as hostname3 } from "os";
|
|
2682
|
-
import { join as
|
|
2771
|
+
import { join as join6, resolve as resolve5 } from "path";
|
|
2683
2772
|
import { styleText as styleText3 } from "util";
|
|
2684
2773
|
function flag(args, name) {
|
|
2685
2774
|
const i = args.indexOf(name);
|
|
@@ -2693,7 +2782,7 @@ function flag(args, name) {
|
|
|
2693
2782
|
async function serverDir(args) {
|
|
2694
2783
|
const given = flag(args, "--dir");
|
|
2695
2784
|
if (given)
|
|
2696
|
-
return
|
|
2785
|
+
return resolve5(given.replace(/^~(?=$|\/)/, homedir2()));
|
|
2697
2786
|
return (await loadConfig()).server ?? DEFAULT_DIR;
|
|
2698
2787
|
}
|
|
2699
2788
|
async function ask(answer) {
|
|
@@ -2732,13 +2821,13 @@ async function preflight(dir) {
|
|
|
2732
2821
|
async function chooseDir(args, interactive) {
|
|
2733
2822
|
let dir = await serverDir(args);
|
|
2734
2823
|
if (interactive && !args.includes("--dir")) {
|
|
2735
|
-
const answer = await ask(
|
|
2824
|
+
const answer = await ask(text2({
|
|
2736
2825
|
message: "Where should mybase keep its files?",
|
|
2737
2826
|
placeholder: tilde(dir),
|
|
2738
2827
|
defaultValue: dir,
|
|
2739
2828
|
validate: (v) => v && !v.trim().startsWith("/") && !v.trim().startsWith("~") ? "use an absolute path (or ~/\u2026)" : undefined
|
|
2740
2829
|
}));
|
|
2741
|
-
dir =
|
|
2830
|
+
dir = resolve5(answer.trim().replace(/^~(?=$|\/)/, homedir2()));
|
|
2742
2831
|
}
|
|
2743
2832
|
const other = otherInstall(dir);
|
|
2744
2833
|
if (other) {
|
|
@@ -2755,7 +2844,7 @@ async function chooseHost(current) {
|
|
|
2755
2844
|
const picked = await ask(select({ message: "Which address should apps on your network use?", options, initialValue: current ?? options[0]?.value }));
|
|
2756
2845
|
if (picked)
|
|
2757
2846
|
return picked;
|
|
2758
|
-
return (await ask(
|
|
2847
|
+
return (await ask(text2({ message: "Address (hostname or IP)", validate: (v) => v?.trim() ? undefined : "required" }))).trim();
|
|
2759
2848
|
}
|
|
2760
2849
|
async function tunnelStep() {
|
|
2761
2850
|
const mode = await ask(select({
|
|
@@ -2816,8 +2905,8 @@ async function autoTunnel() {
|
|
|
2816
2905
|
let apiHost = "";
|
|
2817
2906
|
let s3Host = "";
|
|
2818
2907
|
for (;; ) {
|
|
2819
|
-
const apiSub = await ask(
|
|
2820
|
-
const s3Sub = await ask(
|
|
2908
|
+
const apiSub = await ask(text2({ message: "Subdomain for the dashboard + API", placeholder: "mybase", defaultValue: "mybase" }));
|
|
2909
|
+
const s3Sub = await ask(text2({ message: "Subdomain for S3 storage", placeholder: "s3", defaultValue: "s3" }));
|
|
2821
2910
|
apiHost = `${apiSub.trim()}.${zone.name}`;
|
|
2822
2911
|
s3Host = `${s3Sub.trim()}.${zone.name}`;
|
|
2823
2912
|
if (validHost(apiHost) || validHost(s3Host) || apiHost === s3Host) {
|
|
@@ -2838,10 +2927,11 @@ async function autoTunnel() {
|
|
|
2838
2927
|
}
|
|
2839
2928
|
s.start("Routing hostnames to the tunnel");
|
|
2840
2929
|
try {
|
|
2841
|
-
await cf.
|
|
2930
|
+
const current = tunnel.created ? [] : await cf.getIngress(zone.account.id, tunnel.id).catch(() => []);
|
|
2931
|
+
await cf.putIngress(zone.account.id, tunnel.id, mergeIngress(current, [
|
|
2842
2932
|
{ hostname: apiHost, service: "http://mybase:4000" },
|
|
2843
2933
|
{ hostname: s3Host, service: "http://garage:3900" }
|
|
2844
|
-
]);
|
|
2934
|
+
]));
|
|
2845
2935
|
await cf.upsertCname(zone.id, apiHost, tunnel.id);
|
|
2846
2936
|
await cf.upsertCname(zone.id, s3Host, tunnel.id);
|
|
2847
2937
|
const tunnelToken = await cf.tunnelToken(zone.account.id, tunnel.id);
|
|
@@ -2868,9 +2958,9 @@ async function manualTunnel() {
|
|
|
2868
2958
|
].join(`
|
|
2869
2959
|
`), "Cloudflare Tunnel, by hand");
|
|
2870
2960
|
const token = await ask(password({ message: "Tunnel token", validate: (v) => tunnelIdFromToken(v ?? "") ? undefined : "that isn't a tunnel token (copy the part after --token)" }));
|
|
2871
|
-
const apiHost = (await ask(
|
|
2961
|
+
const apiHost = (await ask(text2({ message: "Hostname for the dashboard + API", placeholder: "mybase.example.com", validate: validHost }))).trim();
|
|
2872
2962
|
const defaultS3 = apiHost.replace(/^[^.]+/, "s3");
|
|
2873
|
-
const s3Host = (await ask(
|
|
2963
|
+
const s3Host = (await ask(text2({ message: "Hostname for S3", placeholder: defaultS3, defaultValue: defaultS3, validate: (v) => validHost(v || defaultS3) }))).trim();
|
|
2874
2964
|
return { env: tunnelEnv(token.trim(), apiHost, s3Host), apiHost, s3Host };
|
|
2875
2965
|
}
|
|
2876
2966
|
async function bindStep(lan, existing) {
|
|
@@ -2916,7 +3006,7 @@ async function dbPublicStep(existing) {
|
|
|
2916
3006
|
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 }));
|
|
2917
3007
|
const chosen = zones.find((z) => z.id === zoneId);
|
|
2918
3008
|
const suggested = existing.MYBASE_DB_PUBLIC_HOST && zoneOf([chosen], existing.MYBASE_DB_PUBLIC_HOST) ? existing.MYBASE_DB_PUBLIC_HOST : `db.${chosen.name}`;
|
|
2919
|
-
const host = (await ask(
|
|
3009
|
+
const host = (await ask(text2({
|
|
2920
3010
|
message: "Hostname for the database",
|
|
2921
3011
|
placeholder: suggested,
|
|
2922
3012
|
defaultValue: suggested,
|
|
@@ -2934,7 +3024,7 @@ async function dbPublicStep(existing) {
|
|
|
2934
3024
|
continue;
|
|
2935
3025
|
await cf.deleteRecord(chosen.id, record.id);
|
|
2936
3026
|
}
|
|
2937
|
-
const email = (await ask(
|
|
3027
|
+
const email = (await ask(text2({ message: "Email for Let's Encrypt expiry notices (optional)", placeholder: "skip" }))).trim();
|
|
2938
3028
|
return {
|
|
2939
3029
|
MYBASE_DB_PUBLIC: "on",
|
|
2940
3030
|
MYBASE_DB_PUBLIC_HOST: host,
|
|
@@ -2954,7 +3044,7 @@ async function adminStep() {
|
|
|
2954
3044
|
}));
|
|
2955
3045
|
if (when === "later")
|
|
2956
3046
|
return null;
|
|
2957
|
-
const username = (await ask(
|
|
3047
|
+
const username = (await ask(text2({ message: "Admin username", placeholder: "admin", defaultValue: "admin", validate: (v) => !v || USERNAME_PATTERN.test(v) ? undefined : "letters, digits, or . _ @ -" }))).trim();
|
|
2958
3048
|
for (;; ) {
|
|
2959
3049
|
const password2 = await ask(password({ message: `Password (at least ${ADMIN_PASSWORD_MIN} characters)`, validate: (v) => (v?.length ?? 0) >= ADMIN_PASSWORD_MIN ? undefined : `at least ${ADMIN_PASSWORD_MIN} characters` }));
|
|
2960
3050
|
const again = await ask(password({ message: "Same password again" }));
|
|
@@ -3007,7 +3097,7 @@ async function advancedStep(env) {
|
|
|
3007
3097
|
else if (env[key] !== undefined)
|
|
3008
3098
|
out[key] = null;
|
|
3009
3099
|
};
|
|
3010
|
-
const port = (key, label, fallback) => ask(
|
|
3100
|
+
const port = (key, label, fallback) => ask(text2({ message: `${label} port`, placeholder: env[key] ?? fallback, defaultValue: env[key] ?? fallback, validate: (v) => v ? validPort(v) : undefined })).then((v) => set(key, v.trim(), fallback));
|
|
3011
3101
|
await port("MYBASE_PORT", "Dashboard + API", "4000");
|
|
3012
3102
|
await port("POSTGRES_PORT", "Postgres", "5432");
|
|
3013
3103
|
await port("GARAGE_S3_PORT", "S3", "3900");
|
|
@@ -3018,7 +3108,7 @@ async function advancedStep(env) {
|
|
|
3018
3108
|
options: ["64mb", "128mb", "256mb", "512mb", "1gb"].map((v) => ({ value: v, label: v, hint: v === "64mb" ? "default, fine for a Pi" : undefined }))
|
|
3019
3109
|
}));
|
|
3020
3110
|
set("CACHE_MAXMEMORY", memory, "64mb");
|
|
3021
|
-
const capacity = await ask(
|
|
3111
|
+
const capacity = await ask(text2({ message: "Storage capacity Garage advertises", placeholder: env.GARAGE_CAPACITY ?? "100G", defaultValue: env.GARAGE_CAPACITY ?? "100G", validate: (v) => !v || /^\d+[KMGT]?$/i.test(v) ? undefined : "like 100G or 2T" }));
|
|
3022
3112
|
set("GARAGE_CAPACITY", capacity.trim(), "100G");
|
|
3023
3113
|
const level = await ask(select({ message: "Log level", initialValue: env.LOG_LEVEL ?? "info", options: ["debug", "info", "warn", "error"].map((v) => ({ value: v, label: v })) }));
|
|
3024
3114
|
set("LOG_LEVEL", level, "info");
|
|
@@ -3041,28 +3131,28 @@ async function portStep(env, interactive) {
|
|
|
3041
3131
|
let next = Number(current) + 1;
|
|
3042
3132
|
while (!portFree(next) && next < 65535)
|
|
3043
3133
|
next++;
|
|
3044
|
-
const answer = await ask(
|
|
3134
|
+
const answer = await ask(text2({ message: `Port ${current} (${label}) is in use by something else. Use which port?`, placeholder: String(next), defaultValue: String(next), validate: (v) => v ? validPort(v) : undefined }));
|
|
3045
3135
|
out[key] = answer.trim();
|
|
3046
3136
|
}
|
|
3047
3137
|
return out;
|
|
3048
3138
|
}
|
|
3049
3139
|
async function writeFiles(dir, envText, interactive, replaceCompose = interactive) {
|
|
3050
3140
|
await mkdir3(dir, { recursive: true });
|
|
3051
|
-
const envPath =
|
|
3141
|
+
const envPath = join6(dir, ".env");
|
|
3052
3142
|
await Bun.write(envPath, envText);
|
|
3053
3143
|
await chmod2(envPath, 384);
|
|
3054
3144
|
await writeCompose(dir, interactive, replaceCompose);
|
|
3055
3145
|
}
|
|
3056
3146
|
async function writeCompose(dir, interactive, replace) {
|
|
3057
|
-
const file = Bun.file(
|
|
3147
|
+
const file = Bun.file(join6(dir, "compose.yml"));
|
|
3058
3148
|
if (await file.exists()) {
|
|
3059
3149
|
const current = await file.text();
|
|
3060
3150
|
if (current === compose_default)
|
|
3061
3151
|
return false;
|
|
3062
|
-
const ok = interactive ? await ask(confirm2({ message: `${tilde(
|
|
3152
|
+
const ok = interactive ? await ask(confirm2({ message: `${tilde(join6(dir, "compose.yml"))} differs from this CLI's (${CLI_VERSION}). Replace it? (the old one is kept as compose.yml.bak)` })) : replace;
|
|
3063
3153
|
if (!ok)
|
|
3064
3154
|
return false;
|
|
3065
|
-
await Bun.write(
|
|
3155
|
+
await Bun.write(join6(dir, "compose.yml.bak"), current);
|
|
3066
3156
|
}
|
|
3067
3157
|
await Bun.write(file, compose_default);
|
|
3068
3158
|
return true;
|
|
@@ -3117,7 +3207,7 @@ ${tail2(pulled.stderr || pulled.stdout)}${pullHint(pulled.stderr || pulled.stdou
|
|
|
3117
3207
|
} else
|
|
3118
3208
|
s.stop("Images downloaded");
|
|
3119
3209
|
}
|
|
3120
|
-
const current = parseEnv(await Bun.file(
|
|
3210
|
+
const current = parseEnv(await Bun.file(join6(dir, ".env")).text());
|
|
3121
3211
|
if (sandboxProfileOn(current)) {
|
|
3122
3212
|
s.start("Updating sandbox runtime images");
|
|
3123
3213
|
await init_server_sandbox();
|
|
@@ -3190,7 +3280,7 @@ async function setup(args) {
|
|
|
3190
3280
|
${dim("Nothing is changed until you confirm at the end. Ctrl-C leaves at any time.")}`);
|
|
3191
3281
|
const dir = await chooseDir(args, interactive);
|
|
3192
3282
|
await preflight(dir);
|
|
3193
|
-
const envFile = Bun.file(
|
|
3283
|
+
const envFile = Bun.file(join6(dir, ".env"));
|
|
3194
3284
|
const envText = await envFile.exists() ? await envFile.text() : null;
|
|
3195
3285
|
const existing = envText ? parseEnv(envText) : {};
|
|
3196
3286
|
const running = await stackExists(dir);
|
|
@@ -3307,7 +3397,7 @@ ${dim("Nothing is changed until you confirm at the end. Ctrl-C leaves at any tim
|
|
|
3307
3397
|
ui.note([
|
|
3308
3398
|
...Object.entries(values).map(([k, v]) => `${k.padEnd(24)} ${isSecretKey(k) ? dim(maskSecret(v)) : v}`),
|
|
3309
3399
|
"",
|
|
3310
|
-
dim(`Writes ${tilde(
|
|
3400
|
+
dim(`Writes ${tilde(join6(dir, ".env"))} (only you can read it) and ${tilde(join6(dir, "compose.yml"))}`)
|
|
3311
3401
|
].join(`
|
|
3312
3402
|
`), "Settings");
|
|
3313
3403
|
if (interactive && !await ask(confirm2({ message: running ? "Save and restart mybase?" : "Save and start mybase?" }))) {
|
|
@@ -3358,10 +3448,10 @@ ${dim("Nothing is changed until you confirm at the end. Ctrl-C leaves at any tim
|
|
|
3358
3448
|
].join(`
|
|
3359
3449
|
`), "mybase is running");
|
|
3360
3450
|
if (secrets.MYBASE_SECRETS_KEY) {
|
|
3361
|
-
ui.warn(`${bold("Back up MYBASE_SECRETS_KEY")} (in ${tilde(
|
|
3451
|
+
ui.warn(`${bold("Back up MYBASE_SECRETS_KEY")} (in ${tilde(join6(dir, ".env"))}) somewhere other than this machine,
|
|
3362
3452
|
like a password manager. If it's lost, every stored secret is lost with it.`);
|
|
3363
3453
|
if (interactive && await ask(confirm2({ message: "Show it now so you can copy it?", initialValue: false }))) {
|
|
3364
|
-
|
|
3454
|
+
log2.message(secrets.MYBASE_SECRETS_KEY);
|
|
3365
3455
|
}
|
|
3366
3456
|
}
|
|
3367
3457
|
outro(`Done. Config for this CLI: ${dim(tilde(configPath))}`);
|
|
@@ -3380,17 +3470,17 @@ var init_server_setup = __esm(async () => {
|
|
|
3380
3470
|
init_server_env();
|
|
3381
3471
|
init_server_sandbox_env();
|
|
3382
3472
|
await init_api();
|
|
3383
|
-
DEFAULT_DIR =
|
|
3473
|
+
DEFAULT_DIR = join6(homedir2(), "mybase");
|
|
3384
3474
|
USERNAME_PATTERN = /^[a-zA-Z0-9_.@-]{1,64}$/;
|
|
3385
3475
|
HOST_PATTERN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
|
|
3386
3476
|
TUNNEL_KEYS = ["COMPOSE_PROFILES", "CLOUDFLARE_TUNNEL_TOKEN", "MYBASE_PUBLIC_URL", "MYBASE_S3_PUBLIC_URL", "MYBASE_TRUST_PROXY"];
|
|
3387
3477
|
BIND_KEYS = ["POSTGRES_BIND", "GARAGE_S3_BIND", "VALKEY_BIND"];
|
|
3388
3478
|
ui = {
|
|
3389
|
-
info: (m) => !jsonMode &&
|
|
3390
|
-
success: (m) => !jsonMode &&
|
|
3391
|
-
warn: (m) => !jsonMode &&
|
|
3392
|
-
error: (m) => !jsonMode &&
|
|
3393
|
-
step: (m) => !jsonMode &&
|
|
3479
|
+
info: (m) => !jsonMode && log2.info(m),
|
|
3480
|
+
success: (m) => !jsonMode && log2.success(m),
|
|
3481
|
+
warn: (m) => !jsonMode && log2.warn(m),
|
|
3482
|
+
error: (m) => !jsonMode && log2.error(m),
|
|
3483
|
+
step: (m) => !jsonMode && log2.step(m),
|
|
3394
3484
|
note: (m, title) => !jsonMode && note(m, title),
|
|
3395
3485
|
spinner() {
|
|
3396
3486
|
return jsonMode ? { start() {}, stop() {}, error() {}, message() {} } : spinner();
|
|
@@ -3407,7 +3497,7 @@ __export(exports_server_sandbox, {
|
|
|
3407
3497
|
sandboxWorkerHealth: () => sandboxWorkerHealth,
|
|
3408
3498
|
setupSandboxServer: () => setupSandboxServer
|
|
3409
3499
|
});
|
|
3410
|
-
import { join as
|
|
3500
|
+
import { join as join7 } from "path";
|
|
3411
3501
|
import { isIP as isIP3 } from "net";
|
|
3412
3502
|
async function publicIp() {
|
|
3413
3503
|
try {
|
|
@@ -3446,8 +3536,8 @@ async function refreshSandboxImages(dir, values, pull = true, run = hostRun) {
|
|
|
3446
3536
|
await pullImage(images.workerImage, run, pull);
|
|
3447
3537
|
const updates = { MYBASE_SANDBOX_IMAGE: images.image, MYBASE_SANDBOX_WORKER_IMAGE: images.workerImage };
|
|
3448
3538
|
if (Object.entries(updates).some(([key, value]) => values[key] !== value)) {
|
|
3449
|
-
const text = await Bun.file(
|
|
3450
|
-
await Bun.write(
|
|
3539
|
+
const text = await Bun.file(join7(dir, ".env")).text();
|
|
3540
|
+
await Bun.write(join7(dir, ".env"), mergeEnv(text, updates));
|
|
3451
3541
|
}
|
|
3452
3542
|
return { ...values, ...updates };
|
|
3453
3543
|
}
|
|
@@ -3505,8 +3595,8 @@ Check the host's gVisor and cgroup support. Ordinary containers will not be used
|
|
|
3505
3595
|
async function prepareSandboxHost(args, options = {}, overrides = {}) {
|
|
3506
3596
|
const io = { ...defaults, ...overrides };
|
|
3507
3597
|
const dir = await serverDir(args);
|
|
3508
|
-
const file = Bun.file(
|
|
3509
|
-
if (!await file.exists() || !await Bun.file(
|
|
3598
|
+
const file = Bun.file(join7(dir, ".env"));
|
|
3599
|
+
if (!await file.exists() || !await Bun.file(join7(dir, "compose.yml")).exists())
|
|
3510
3600
|
throw new CliError(`no mybase server in ${dir}; run mybase server setup --sandboxes first`, 4);
|
|
3511
3601
|
const text = await file.text();
|
|
3512
3602
|
const values = parseEnv(text);
|
|
@@ -4922,6 +5012,611 @@ async function doctor(args) {
|
|
|
4922
5012
|
}
|
|
4923
5013
|
}
|
|
4924
5014
|
|
|
5015
|
+
// src/hosting.ts
|
|
5016
|
+
await init_api();
|
|
5017
|
+
import { existsSync } from "fs";
|
|
5018
|
+
import { rm as rm2 } from "fs/promises";
|
|
5019
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
5020
|
+
import { dirname as dirname3, join as join4, resolve as resolve4 } from "path";
|
|
5021
|
+
|
|
5022
|
+
// src/oci-push.ts
|
|
5023
|
+
import { createHash } from "crypto";
|
|
5024
|
+
import { tmpdir } from "os";
|
|
5025
|
+
import { join as join3 } from "path";
|
|
5026
|
+
import { rm } from "fs/promises";
|
|
5027
|
+
var CHUNK_BYTES = 32 * 1024 * 1024;
|
|
5028
|
+
var text = (b) => new TextDecoder().decode(b).replace(/\0.*$/s, "");
|
|
5029
|
+
function tarSize(field) {
|
|
5030
|
+
if (field[0] & 128) {
|
|
5031
|
+
let n = 0;
|
|
5032
|
+
for (let i = 1;i < field.length; i++)
|
|
5033
|
+
n = n * 256 + field[i];
|
|
5034
|
+
return n;
|
|
5035
|
+
}
|
|
5036
|
+
return parseInt(text(field).trim() || "0", 8);
|
|
5037
|
+
}
|
|
5038
|
+
async function readTar(path) {
|
|
5039
|
+
const file = Bun.file(path);
|
|
5040
|
+
const entries = new Map;
|
|
5041
|
+
let at = 0;
|
|
5042
|
+
let longName = null;
|
|
5043
|
+
while (at + 512 <= file.size) {
|
|
5044
|
+
const h = new Uint8Array(await file.slice(at, at + 512).arrayBuffer());
|
|
5045
|
+
if (h.every((b) => b === 0))
|
|
5046
|
+
break;
|
|
5047
|
+
const size = tarSize(h.subarray(124, 136));
|
|
5048
|
+
const type = String.fromCharCode(h[156]);
|
|
5049
|
+
const prefix = text(h.subarray(345, 500));
|
|
5050
|
+
let name = longName ?? (prefix ? `${prefix}/${text(h.subarray(0, 100))}` : text(h.subarray(0, 100)));
|
|
5051
|
+
const body = at + 512;
|
|
5052
|
+
if (type === "L") {
|
|
5053
|
+
longName = text(new Uint8Array(await file.slice(body, body + size).arrayBuffer()));
|
|
5054
|
+
} else if (type === "x") {
|
|
5055
|
+
const pax = new TextDecoder().decode(await file.slice(body, body + size).arrayBuffer());
|
|
5056
|
+
longName = /\d+ path=([^\n]*)\n/.exec(pax)?.[1] ?? null;
|
|
5057
|
+
} else {
|
|
5058
|
+
longName = null;
|
|
5059
|
+
name = name.replace(/^\.\//, "");
|
|
5060
|
+
if (type === "0" || type === "\x00")
|
|
5061
|
+
entries.set(name, { name, offset: body, size });
|
|
5062
|
+
}
|
|
5063
|
+
at = body + Math.ceil(size / 512) * 512;
|
|
5064
|
+
}
|
|
5065
|
+
return entries;
|
|
5066
|
+
}
|
|
5067
|
+
async function savedImage(tar, entries) {
|
|
5068
|
+
const read = async (name) => {
|
|
5069
|
+
const e = entries.get(name);
|
|
5070
|
+
if (!e)
|
|
5071
|
+
throw new Error(`the saved image is missing ${name}`);
|
|
5072
|
+
return new Uint8Array(await Bun.file(tar).slice(e.offset, e.offset + e.size).arrayBuffer());
|
|
5073
|
+
};
|
|
5074
|
+
const manifest = JSON.parse(new TextDecoder().decode(await read("manifest.json")));
|
|
5075
|
+
if (manifest.length !== 1)
|
|
5076
|
+
throw new Error("expected exactly one image in the saved tar");
|
|
5077
|
+
const bytes = await read(manifest[0].Config);
|
|
5078
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
5079
|
+
const layers = manifest[0].Layers.map((name) => {
|
|
5080
|
+
const e = entries.get(name);
|
|
5081
|
+
if (!e)
|
|
5082
|
+
throw new Error(`the saved image is missing layer ${name}`);
|
|
5083
|
+
return e;
|
|
5084
|
+
});
|
|
5085
|
+
return { config: { bytes, architecture: parsed.architecture, os: parsed.os }, layers };
|
|
5086
|
+
}
|
|
5087
|
+
async function isGzip(tar, e) {
|
|
5088
|
+
const head = new Uint8Array(await Bun.file(tar).slice(e.offset, e.offset + 2).arrayBuffer());
|
|
5089
|
+
return head[0] === 31 && head[1] === 139;
|
|
5090
|
+
}
|
|
5091
|
+
async function sha256Stream(stream) {
|
|
5092
|
+
const hash = createHash("sha256");
|
|
5093
|
+
for await (const chunk of stream)
|
|
5094
|
+
hash.update(chunk);
|
|
5095
|
+
return `sha256:${hash.digest("hex")}`;
|
|
5096
|
+
}
|
|
5097
|
+
async function layerBlob(tar, e) {
|
|
5098
|
+
const slice = () => Bun.file(tar).slice(e.offset, e.offset + e.size);
|
|
5099
|
+
if (await isGzip(tar, e)) {
|
|
5100
|
+
const fromName = /blobs\/sha256\/([0-9a-f]{64})$/.exec(e.name)?.[1];
|
|
5101
|
+
const digest = fromName ? `sha256:${fromName}` : await sha256Stream(slice().stream());
|
|
5102
|
+
return { digest, size: e.size, mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", source: slice };
|
|
5103
|
+
}
|
|
5104
|
+
const out = join3(tmpdir(), `mybase-layer-${crypto.randomUUID()}.tar.gz`);
|
|
5105
|
+
const hash = createHash("sha256");
|
|
5106
|
+
const writer = Bun.file(out).writer();
|
|
5107
|
+
let size = 0;
|
|
5108
|
+
for await (const chunk of slice().stream().pipeThrough(new CompressionStream("gzip"))) {
|
|
5109
|
+
hash.update(chunk);
|
|
5110
|
+
writer.write(chunk);
|
|
5111
|
+
size += chunk.length;
|
|
5112
|
+
}
|
|
5113
|
+
await writer.end();
|
|
5114
|
+
return {
|
|
5115
|
+
digest: `sha256:${hash.digest("hex")}`,
|
|
5116
|
+
size,
|
|
5117
|
+
mediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
5118
|
+
source: () => Bun.file(out),
|
|
5119
|
+
cleanup: () => rm(out, { force: true })
|
|
5120
|
+
};
|
|
5121
|
+
}
|
|
5122
|
+
async function registry(t, path, init = {}, attempts = 3) {
|
|
5123
|
+
const url = path.startsWith("http") ? path : `${t.url}${path}`;
|
|
5124
|
+
for (let i = 1;; i++) {
|
|
5125
|
+
let res = null;
|
|
5126
|
+
try {
|
|
5127
|
+
res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${t.token}`, ...init.headers } });
|
|
5128
|
+
} catch (err) {
|
|
5129
|
+
if (i >= attempts)
|
|
5130
|
+
throw new Error(`can't reach the registry at ${t.url}: ${err.message}`);
|
|
5131
|
+
}
|
|
5132
|
+
if (res && (res.status < 500 || i >= attempts))
|
|
5133
|
+
return res;
|
|
5134
|
+
await Bun.sleep(1000 * i);
|
|
5135
|
+
}
|
|
5136
|
+
}
|
|
5137
|
+
async function failure(res, what) {
|
|
5138
|
+
const body = await res.text().catch(() => "");
|
|
5139
|
+
let reason = body.slice(0, 300);
|
|
5140
|
+
try {
|
|
5141
|
+
const parsed = JSON.parse(body);
|
|
5142
|
+
reason = parsed.error ?? parsed.errors?.map((e) => e.message).join("; ") ?? reason;
|
|
5143
|
+
} catch {}
|
|
5144
|
+
return new Error(`${what} failed (${res.status}): ${reason}`);
|
|
5145
|
+
}
|
|
5146
|
+
async function uploadBlob(t, blob, progress) {
|
|
5147
|
+
const head = await registry(t, `/v2/${t.repo}/blobs/${blob.digest}`, { method: "HEAD" });
|
|
5148
|
+
if (head.ok)
|
|
5149
|
+
return progress({ kind: "skip", digest: blob.digest, size: blob.size });
|
|
5150
|
+
const start = await registry(t, `/v2/${t.repo}/blobs/uploads/`, { method: "POST", headers: { "Content-Length": "0" } });
|
|
5151
|
+
if (start.status !== 202)
|
|
5152
|
+
throw await failure(start, "starting an upload");
|
|
5153
|
+
let location = start.headers.get("Location");
|
|
5154
|
+
const absolute = (loc) => loc.startsWith("http") ? loc.replace(/^https?:\/\/[^/]+/, t.url) : `${t.url}${loc}`;
|
|
5155
|
+
location = absolute(location);
|
|
5156
|
+
const file = blob.source();
|
|
5157
|
+
const step = t.chunkBytes ?? CHUNK_BYTES;
|
|
5158
|
+
for (let sent = 0;sent < blob.size; sent += step) {
|
|
5159
|
+
const end = Math.min(sent + step, blob.size);
|
|
5160
|
+
const chunk = await file.slice(sent, end).arrayBuffer();
|
|
5161
|
+
const res = await registry(t, location, {
|
|
5162
|
+
method: "PATCH",
|
|
5163
|
+
headers: { "Content-Type": "application/octet-stream", "Content-Range": `${sent}-${end - 1}`, "Content-Length": String(end - sent) },
|
|
5164
|
+
body: chunk
|
|
5165
|
+
}, 1);
|
|
5166
|
+
if (res.status !== 202)
|
|
5167
|
+
throw await failure(res, "uploading a layer chunk");
|
|
5168
|
+
location = absolute(res.headers.get("Location"));
|
|
5169
|
+
progress({ kind: "upload", digest: blob.digest, sent: end, size: blob.size });
|
|
5170
|
+
}
|
|
5171
|
+
const done = await registry(t, `${location}${location.includes("?") ? "&" : "?"}digest=${encodeURIComponent(blob.digest)}`, {
|
|
5172
|
+
method: "PUT",
|
|
5173
|
+
headers: { "Content-Length": "0" }
|
|
5174
|
+
});
|
|
5175
|
+
if (done.status !== 201)
|
|
5176
|
+
throw await failure(done, "finishing an upload");
|
|
5177
|
+
progress({ kind: "done", digest: blob.digest, size: blob.size });
|
|
5178
|
+
}
|
|
5179
|
+
async function pushImage(tar, t, progress = () => {}) {
|
|
5180
|
+
const image = await savedImage(tar, await readTar(tar));
|
|
5181
|
+
const configDigest = `sha256:${createHash("sha256").update(image.config.bytes).digest("hex")}`;
|
|
5182
|
+
const configPath = join3(tmpdir(), `mybase-config-${crypto.randomUUID()}.json`);
|
|
5183
|
+
await Bun.write(configPath, image.config.bytes);
|
|
5184
|
+
const blobs = [];
|
|
5185
|
+
try {
|
|
5186
|
+
for (const layer of image.layers)
|
|
5187
|
+
blobs.push(await layerBlob(tar, layer));
|
|
5188
|
+
const config = { digest: configDigest, size: image.config.bytes.length, mediaType: "application/vnd.oci.image.config.v1+json", source: () => Bun.file(configPath) };
|
|
5189
|
+
for (const blob of [...blobs, config])
|
|
5190
|
+
await uploadBlob(t, blob, progress);
|
|
5191
|
+
const manifest = new TextEncoder().encode(JSON.stringify({
|
|
5192
|
+
schemaVersion: 2,
|
|
5193
|
+
mediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
5194
|
+
config: { mediaType: config.mediaType, digest: config.digest, size: config.size },
|
|
5195
|
+
layers: blobs.map((b) => ({ mediaType: b.mediaType, digest: b.digest, size: b.size }))
|
|
5196
|
+
}));
|
|
5197
|
+
const digest = `sha256:${createHash("sha256").update(manifest).digest("hex")}`;
|
|
5198
|
+
const res = await registry(t, `/v2/${t.repo}/manifests/${digest}`, {
|
|
5199
|
+
method: "PUT",
|
|
5200
|
+
headers: { "Content-Type": "application/vnd.oci.image.manifest.v1+json" },
|
|
5201
|
+
body: manifest
|
|
5202
|
+
});
|
|
5203
|
+
if (res.status !== 201)
|
|
5204
|
+
throw await failure(res, "pushing the manifest");
|
|
5205
|
+
return { digest, architecture: image.config.architecture };
|
|
5206
|
+
} finally {
|
|
5207
|
+
await rm(configPath, { force: true });
|
|
5208
|
+
for (const b of blobs)
|
|
5209
|
+
await b.cleanup?.();
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
5212
|
+
|
|
5213
|
+
// src/hosting.ts
|
|
5214
|
+
init_output();
|
|
5215
|
+
|
|
5216
|
+
// src/hosting-templates.ts
|
|
5217
|
+
var TEMPLATES = ["nextjs", "static", "node"];
|
|
5218
|
+
var install = `RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \\
|
|
5219
|
+
elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile; \\
|
|
5220
|
+
elif [ -f package-lock.json ]; then npm ci; \\
|
|
5221
|
+
elif [ -f bun.lock ]; then npm install -g bun && bun install --frozen-lockfile; \\
|
|
5222
|
+
else npm install; fi`;
|
|
5223
|
+
var lockfiles = "COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* bun.lock* ./";
|
|
5224
|
+
var DOCKERFILES = {
|
|
5225
|
+
nextjs: `# Next.js, built with output: "standalone" (set it in next.config).
|
|
5226
|
+
FROM node:22-alpine AS deps
|
|
5227
|
+
WORKDIR /app
|
|
5228
|
+
${lockfiles}
|
|
5229
|
+
${install}
|
|
5230
|
+
|
|
5231
|
+
FROM node:22-alpine AS build
|
|
5232
|
+
WORKDIR /app
|
|
5233
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
5234
|
+
COPY . .
|
|
5235
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
5236
|
+
RUN mkdir -p public && npm run build
|
|
5237
|
+
|
|
5238
|
+
FROM node:22-alpine
|
|
5239
|
+
WORKDIR /app
|
|
5240
|
+
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
|
|
5241
|
+
RUN addgroup -S app && adduser -S app -G app
|
|
5242
|
+
COPY --from=build --chown=app:app /app/public ./public
|
|
5243
|
+
COPY --from=build --chown=app:app /app/.next/standalone ./
|
|
5244
|
+
COPY --from=build --chown=app:app /app/.next/static ./.next/static
|
|
5245
|
+
USER app
|
|
5246
|
+
EXPOSE 3000
|
|
5247
|
+
CMD ["node", "server.js"]
|
|
5248
|
+
`,
|
|
5249
|
+
static: `# Static site: \`npm run build\` writes OUT_DIR, Caddy serves it on :3000.
|
|
5250
|
+
FROM node:22-alpine AS build
|
|
5251
|
+
WORKDIR /app
|
|
5252
|
+
${lockfiles}
|
|
5253
|
+
${install}
|
|
5254
|
+
COPY . .
|
|
5255
|
+
RUN npm run build
|
|
5256
|
+
|
|
5257
|
+
FROM caddy:2.10.2-alpine
|
|
5258
|
+
ARG OUT_DIR=dist
|
|
5259
|
+
COPY --from=build /app/\${OUT_DIR} /srv
|
|
5260
|
+
RUN printf '{\\n admin off\\n persist_config off\\n}\\n:3000 {\\n root * /srv\\n encode gzip\\n try_files {path} {path}/ /index.html\\n file_server\\n}\\n' > /etc/caddy/Caddyfile
|
|
5261
|
+
ENV XDG_CONFIG_HOME=/tmp XDG_DATA_HOME=/tmp
|
|
5262
|
+
USER nobody
|
|
5263
|
+
EXPOSE 3000
|
|
5264
|
+
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
|
5265
|
+
`,
|
|
5266
|
+
node: `# Bun or Node server: \`bun run start\` must listen on $PORT.
|
|
5267
|
+
FROM oven/bun:1.3-alpine
|
|
5268
|
+
WORKDIR /app
|
|
5269
|
+
COPY package.json bun.lock* package-lock.json* ./
|
|
5270
|
+
RUN bun install --production
|
|
5271
|
+
COPY . .
|
|
5272
|
+
ENV NODE_ENV=production PORT=3000
|
|
5273
|
+
USER bun
|
|
5274
|
+
EXPOSE 3000
|
|
5275
|
+
CMD ["bun", "run", "start"]
|
|
5276
|
+
`
|
|
5277
|
+
};
|
|
5278
|
+
var DOCKERIGNORE = `node_modules
|
|
5279
|
+
.git
|
|
5280
|
+
.next
|
|
5281
|
+
dist
|
|
5282
|
+
.env
|
|
5283
|
+
.env.*
|
|
5284
|
+
!.env.example
|
|
5285
|
+
npm-debug.log*
|
|
5286
|
+
Dockerfile
|
|
5287
|
+
.dockerignore
|
|
5288
|
+
`;
|
|
5289
|
+
var configFile = (project, site) => `import { defineConfig } from "@mybase-dev/sdk/config";
|
|
5290
|
+
|
|
5291
|
+
export default defineConfig({
|
|
5292
|
+
project: ${JSON.stringify(project)},
|
|
5293
|
+
hosting: {
|
|
5294
|
+
// \`mybase deploy\` builds ./Dockerfile and runs it at https://${site}.<your domain>.
|
|
5295
|
+
sites: { ${JSON.stringify(site)}: { port: 3000 } },
|
|
5296
|
+
},
|
|
5297
|
+
});
|
|
5298
|
+
`;
|
|
5299
|
+
|
|
5300
|
+
// src/hosting.ts
|
|
5301
|
+
await init_service_enable();
|
|
5302
|
+
var hostingUsage = ` hosting enable <project> Turn on hosting (sites at <site>.<domain>; needs \`mybase server hosting\` once)
|
|
5303
|
+
hosting sites <project> List sites, their URLs, and the latest deploy
|
|
5304
|
+
hosting create <project> <site> [--port 3000] [--health /] Add a site (names are unique across projects)
|
|
5305
|
+
hosting set <project> <site> [--port n] [--health /path] [--secrets-env <env> | --no-secrets-env]
|
|
5306
|
+
hosting env list|set|unset <project> <site> [KEY=VALUE\u2026 | KEY\u2026] The site's own env vars (applied on the next deploy)
|
|
5307
|
+
hosting deploys <project> <site> Deploy history
|
|
5308
|
+
hosting redeploy <project> <site> Restart the live image (picks up env changes)
|
|
5309
|
+
hosting rollback <project> <site> [<deploy id>] Go back to the previous (or a given) deploy
|
|
5310
|
+
hosting logs <project> <site> [--deploy <id>] [--tail 200] [--no-follow] Container output
|
|
5311
|
+
hosting delete <project> <site> [--yes] Remove a site, its containers, and its images
|
|
5312
|
+
hosting init [--template nextjs|static|node] [--project p] [--site s] Write a Dockerfile, .dockerignore, and mybase.config.ts
|
|
5313
|
+
hosting disable <project> [--yes] Turn hosting off: every site goes down
|
|
5314
|
+
deploy [--project p] [--site s] [--config mybase.config.ts] [--no-wait] Build here, push, and go live`;
|
|
5315
|
+
var base4 = (p) => `/projects/${encodeURIComponent(p)}/hosting`;
|
|
5316
|
+
var sitePath = (p, s) => `${base4(p)}/sites/${encodeURIComponent(s)}`;
|
|
5317
|
+
var short = (id) => id.slice(0, 8);
|
|
5318
|
+
var ago = (iso) => {
|
|
5319
|
+
const s = Math.round((Date.now() - +new Date(iso)) / 1000);
|
|
5320
|
+
return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`;
|
|
5321
|
+
};
|
|
5322
|
+
function printDeploy(d) {
|
|
5323
|
+
console.log([short(d.id), d.status.padEnd(8), d.digest.slice(7, 19), ago(d.createdAt).padEnd(8), d.createdBy, d.rollbackOf ? `rollback of ${short(d.rollbackOf)}` : "", d.error ?? ""].join(" ").trimEnd());
|
|
5324
|
+
}
|
|
5325
|
+
function printSite(s) {
|
|
5326
|
+
const status = s.latest ? `${s.latest.status}${s.latest.id !== s.liveDeploy && s.liveDeploy ? ` (live: ${short(s.liveDeploy)})` : ""}` : "never deployed";
|
|
5327
|
+
console.log(`${s.name.padEnd(16)} ${(s.url ?? `http://${s.name}.localhost`).padEnd(36)} :${String(s.port).padEnd(5)} ${status}`);
|
|
5328
|
+
}
|
|
5329
|
+
async function hosting(sub, args) {
|
|
5330
|
+
const { positional, flags } = parseArgs(args);
|
|
5331
|
+
const [project, site] = positional;
|
|
5332
|
+
switch (sub) {
|
|
5333
|
+
case "enable": {
|
|
5334
|
+
const p = need(project, "project name");
|
|
5335
|
+
await enableService(p, "hosting", flags);
|
|
5336
|
+
const o = await api(base4(p));
|
|
5337
|
+
print({ enabled: "hosting", project: p, ...o }, () => {
|
|
5338
|
+
console.log(`Hosting enabled for "${p}".`);
|
|
5339
|
+
if (!o.domain)
|
|
5340
|
+
console.log("No MYBASE_HOSTING_DOMAIN on the server: sites answer at http://<site>.localhost only (run `mybase server hosting`).");
|
|
5341
|
+
console.log(`
|
|
5342
|
+
Next, in your app's directory:
|
|
5343
|
+
mybase hosting init --project ${p} --site <name>
|
|
5344
|
+
mybase deploy`);
|
|
5345
|
+
});
|
|
5346
|
+
return true;
|
|
5347
|
+
}
|
|
5348
|
+
case "sites": {
|
|
5349
|
+
const p = need(project, "project name");
|
|
5350
|
+
const o = await api(base4(p));
|
|
5351
|
+
print(o, () => {
|
|
5352
|
+
if (!o.sites.length)
|
|
5353
|
+
return console.log(`No sites yet. Create one: mybase hosting create ${p} <site>`);
|
|
5354
|
+
for (const s of o.sites)
|
|
5355
|
+
printSite(s);
|
|
5356
|
+
console.log(`
|
|
5357
|
+
Images: ${o.registry}/<site> (${o.platform})`);
|
|
5358
|
+
});
|
|
5359
|
+
return true;
|
|
5360
|
+
}
|
|
5361
|
+
case "create": {
|
|
5362
|
+
const p = need(project, "project name");
|
|
5363
|
+
const s = await api(`${base4(p)}/sites`, {
|
|
5364
|
+
method: "POST",
|
|
5365
|
+
body: JSON.stringify({ name: need(site, "site name"), port: flagInt(flags, "port"), healthPath: flagString(flags, "health") })
|
|
5366
|
+
});
|
|
5367
|
+
print(s, () => console.log(`Site "${s.name}" created. It goes live at ${s.url ?? `http://${s.name}.localhost`} after the first deploy (mybase deploy).`));
|
|
5368
|
+
return true;
|
|
5369
|
+
}
|
|
5370
|
+
case "set": {
|
|
5371
|
+
const p = need(project, "project name");
|
|
5372
|
+
const body = { port: flagInt(flags, "port"), healthPath: flagString(flags, "health") };
|
|
5373
|
+
if (flags.has("no-secrets-env"))
|
|
5374
|
+
body.secretsEnv = null;
|
|
5375
|
+
else if (flags.has("secrets-env"))
|
|
5376
|
+
body.secretsEnv = flagString(flags, "secrets-env");
|
|
5377
|
+
const s = await api(sitePath(p, need(site, "site name")), { method: "PATCH", body: JSON.stringify(body) });
|
|
5378
|
+
print(s, () => {
|
|
5379
|
+
printSite(s);
|
|
5380
|
+
console.log(`secrets env: ${s.secretsEnv ?? "none"}. Env changes apply on the next deploy (mybase hosting redeploy ${p} ${s.name}).`);
|
|
5381
|
+
});
|
|
5382
|
+
return true;
|
|
5383
|
+
}
|
|
5384
|
+
case "env":
|
|
5385
|
+
return env(positional, flags);
|
|
5386
|
+
case "deploys": {
|
|
5387
|
+
const p = need(project, "project name");
|
|
5388
|
+
const list = await api(`${sitePath(p, need(site, "site name"))}/deploys`);
|
|
5389
|
+
print(list, () => list.length ? list.forEach(printDeploy) : console.log("No deploys yet."));
|
|
5390
|
+
return true;
|
|
5391
|
+
}
|
|
5392
|
+
case "redeploy":
|
|
5393
|
+
case "rollback": {
|
|
5394
|
+
const p = need(project, "project name");
|
|
5395
|
+
const s = need(site, "site name");
|
|
5396
|
+
const deploy = sub === "redeploy" ? await api(`${sitePath(p, s)}/deploys`, { method: "POST", body: "{}" }) : await api(`${sitePath(p, s)}/rollback`, { method: "POST", body: JSON.stringify(positional[2] ? { deployId: await fullDeployId(p, s, positional[2]) } : {}) });
|
|
5397
|
+
await finish(p, s, deploy, flags.has("no-wait"));
|
|
5398
|
+
return true;
|
|
5399
|
+
}
|
|
5400
|
+
case "logs": {
|
|
5401
|
+
const p = need(project, "project name");
|
|
5402
|
+
const s = need(site, "site name");
|
|
5403
|
+
const q = new URLSearchParams({ tail: String(flagInt(flags, "tail") ?? 200), follow: flags.has("no-follow") || jsonMode ? "0" : "1" });
|
|
5404
|
+
const deploy = flagString(flags, "deploy");
|
|
5405
|
+
if (deploy)
|
|
5406
|
+
q.set("deploy", await fullDeployId(p, s, deploy));
|
|
5407
|
+
const res = await api(`${sitePath(p, s)}/logs?${q}`, { raw: true });
|
|
5408
|
+
const text = jsonMode ? await res.text() : null;
|
|
5409
|
+
if (text !== null)
|
|
5410
|
+
print({ site: s, logs: text.split(`
|
|
5411
|
+
`).filter(Boolean) }, () => {});
|
|
5412
|
+
else
|
|
5413
|
+
for await (const chunk of res.body)
|
|
5414
|
+
process.stdout.write(chunk);
|
|
5415
|
+
return true;
|
|
5416
|
+
}
|
|
5417
|
+
case "delete": {
|
|
5418
|
+
const p = need(project, "project name");
|
|
5419
|
+
const s = need(site, "site name");
|
|
5420
|
+
confirm(flags.has("yes"), `Site "${s}" goes offline, and its deploys and images are deleted.`, s, "the site name");
|
|
5421
|
+
await api(sitePath(p, s), { method: "DELETE" });
|
|
5422
|
+
print({ deleted: s, project: p }, () => console.log(`Site "${s}" deleted.`));
|
|
5423
|
+
return true;
|
|
5424
|
+
}
|
|
5425
|
+
case "disable": {
|
|
5426
|
+
const p = need(project, "project name");
|
|
5427
|
+
confirm(flags.has("yes"), `Every site in "${p}" goes offline, and their deploys and images are deleted.`, p, "the project name");
|
|
5428
|
+
await api(`/projects/${encodeURIComponent(p)}/services/hosting`, { method: "DELETE" });
|
|
5429
|
+
print({ disabled: "hosting", project: p }, () => console.log(`Hosting disabled for "${p}".`));
|
|
5430
|
+
return true;
|
|
5431
|
+
}
|
|
5432
|
+
case "init":
|
|
5433
|
+
await init(flags);
|
|
5434
|
+
return true;
|
|
5435
|
+
case "deploy":
|
|
5436
|
+
await deploy(args);
|
|
5437
|
+
return true;
|
|
5438
|
+
}
|
|
5439
|
+
return false;
|
|
5440
|
+
}
|
|
5441
|
+
async function fullDeployId(p, s, id) {
|
|
5442
|
+
if (/^[0-9a-f-]{36}$/.test(id))
|
|
5443
|
+
return id;
|
|
5444
|
+
const match = (await api(`${sitePath(p, s)}/deploys`)).filter((d) => d.id.startsWith(id));
|
|
5445
|
+
if (match.length !== 1)
|
|
5446
|
+
throw new CliError(match.length ? `"${id}" matches more than one deploy` : `no deploy starting with "${id}"`, 4);
|
|
5447
|
+
return match[0].id;
|
|
5448
|
+
}
|
|
5449
|
+
async function env(positional, flags) {
|
|
5450
|
+
const [action, project, site, ...pairs] = positional;
|
|
5451
|
+
if (!action || !["list", "set", "unset"].includes(action))
|
|
5452
|
+
throw usageError("usage: mybase hosting env list|set|unset <project> <site> [KEY=VALUE\u2026 | KEY\u2026]");
|
|
5453
|
+
const p = need(project, "project name");
|
|
5454
|
+
const s = need(site, "site name");
|
|
5455
|
+
const path = `${sitePath(p, s)}/env`;
|
|
5456
|
+
const current = (await api(path)).env;
|
|
5457
|
+
if (action === "list") {
|
|
5458
|
+
const values = flags.has("values");
|
|
5459
|
+
print(values ? current : Object.keys(current), () => {
|
|
5460
|
+
if (!Object.keys(current).length)
|
|
5461
|
+
return console.log("No env vars. Set some: mybase hosting env set <project> <site> KEY=VALUE");
|
|
5462
|
+
for (const [k, v] of Object.entries(current))
|
|
5463
|
+
console.log(values ? `${k}=${v}` : k);
|
|
5464
|
+
});
|
|
5465
|
+
return true;
|
|
5466
|
+
}
|
|
5467
|
+
const next = { ...current };
|
|
5468
|
+
if (action === "set") {
|
|
5469
|
+
if (!pairs.length)
|
|
5470
|
+
throw usageError("give at least one KEY=VALUE");
|
|
5471
|
+
for (const pair of pairs) {
|
|
5472
|
+
const at = pair.indexOf("=");
|
|
5473
|
+
if (at < 1)
|
|
5474
|
+
throw usageError(`"${pair}" isn't KEY=VALUE`);
|
|
5475
|
+
next[pair.slice(0, at)] = pair.slice(at + 1);
|
|
5476
|
+
}
|
|
5477
|
+
} else {
|
|
5478
|
+
if (!pairs.length)
|
|
5479
|
+
throw usageError("give at least one KEY");
|
|
5480
|
+
for (const k of pairs)
|
|
5481
|
+
delete next[k];
|
|
5482
|
+
}
|
|
5483
|
+
await api(path, { method: "PUT", body: JSON.stringify({ env: next }) });
|
|
5484
|
+
print({ site: s, keys: Object.keys(next) }, () => console.log(`Saved ${Object.keys(next).length} env vars. They apply on the next deploy: mybase hosting redeploy ${p} ${s}`));
|
|
5485
|
+
return true;
|
|
5486
|
+
}
|
|
5487
|
+
async function init(flags) {
|
|
5488
|
+
const template = flagString(flags, "template") ?? "nextjs";
|
|
5489
|
+
if (!TEMPLATES.includes(template))
|
|
5490
|
+
throw usageError(`--template must be one of ${TEMPLATES.join(", ")}`);
|
|
5491
|
+
const dir = resolve4(flagString(flags, "dir") ?? ".");
|
|
5492
|
+
const written = [];
|
|
5493
|
+
const write = async (name, content) => {
|
|
5494
|
+
const path = join4(dir, name);
|
|
5495
|
+
if (existsSync(path) && !flags.has("force"))
|
|
5496
|
+
return console.error(`${name} exists; left as is (--force replaces it)`);
|
|
5497
|
+
await Bun.write(path, content);
|
|
5498
|
+
written.push(name);
|
|
5499
|
+
};
|
|
5500
|
+
await write("Dockerfile", DOCKERFILES[template]);
|
|
5501
|
+
await write(".dockerignore", DOCKERIGNORE);
|
|
5502
|
+
const project = flagString(flags, "project");
|
|
5503
|
+
const site = flagString(flags, "site");
|
|
5504
|
+
if (project && site)
|
|
5505
|
+
await write("mybase.config.ts", configFile(project, site));
|
|
5506
|
+
print({ template, written }, () => {
|
|
5507
|
+
if (written.length)
|
|
5508
|
+
console.log(`Wrote ${written.join(", ")} (${template}).`);
|
|
5509
|
+
if (template === "nextjs")
|
|
5510
|
+
console.log('Set output: "standalone" in next.config so the image stays small.');
|
|
5511
|
+
if (!(project && site))
|
|
5512
|
+
console.log("Add a hosting block to mybase.config.ts (or pass --project and --site here), then: mybase deploy");
|
|
5513
|
+
else
|
|
5514
|
+
console.log("Next: mybase deploy");
|
|
5515
|
+
});
|
|
5516
|
+
}
|
|
5517
|
+
async function docker(args, quiet = false) {
|
|
5518
|
+
const child = Bun.spawn(["docker", ...args], { stdin: "ignore", stdout: quiet || jsonMode ? "ignore" : 2, stderr: "inherit" });
|
|
5519
|
+
if (await child.exited !== 0)
|
|
5520
|
+
throw new CliError(`docker ${args[0]} failed`);
|
|
5521
|
+
}
|
|
5522
|
+
var goArch = (arch) => arch === "x64" ? "amd64" : arch;
|
|
5523
|
+
async function deploy(args) {
|
|
5524
|
+
const { flags } = parseArgs(args);
|
|
5525
|
+
const configPath = flagString(flags, "config") ?? "mybase.config.ts";
|
|
5526
|
+
const config = existsSync(configPath) ? await readConfigFile(configPath) : null;
|
|
5527
|
+
const project = flagString(flags, "project") ?? config?.project;
|
|
5528
|
+
if (!project)
|
|
5529
|
+
throw usageError("which project? Pass --project, or run in a directory with a mybase.config.ts");
|
|
5530
|
+
const sites = config?.project === project ? config.hosting?.sites ?? {} : {};
|
|
5531
|
+
const names = Object.keys(sites);
|
|
5532
|
+
const site = flagString(flags, "site") ?? (names.length === 1 ? names[0] : undefined);
|
|
5533
|
+
if (!site)
|
|
5534
|
+
throw usageError(names.length ? `which site? --site ${names.join(" | --site ")}` : "which site? Pass --site, or add hosting.sites to mybase.config.ts");
|
|
5535
|
+
const settings = sites[site] ?? {};
|
|
5536
|
+
const root = config ? dirname3(resolve4(configPath)) : process.cwd();
|
|
5537
|
+
const context = resolve4(root, flagString(flags, "dir") ?? settings.context ?? ".");
|
|
5538
|
+
const dockerfile = resolve4(context, flagString(flags, "dockerfile") ?? settings.dockerfile ?? "Dockerfile");
|
|
5539
|
+
if (!existsSync(dockerfile))
|
|
5540
|
+
throw new CliError(`no Dockerfile at ${dockerfile}. Write one: mybase hosting init --template nextjs|static|node`);
|
|
5541
|
+
const token = apiToken();
|
|
5542
|
+
if (!token)
|
|
5543
|
+
throw new CliError("not signed in. Run `mybase login` (or set MYBASE_TOKEN).", 3);
|
|
5544
|
+
let existing = null;
|
|
5545
|
+
try {
|
|
5546
|
+
existing = await api(sitePath(project, site));
|
|
5547
|
+
} catch (err) {
|
|
5548
|
+
if (!(err instanceof CliError && err.status === 404 && /no site named/.test(err.message)))
|
|
5549
|
+
throw err;
|
|
5550
|
+
}
|
|
5551
|
+
if (!existing) {
|
|
5552
|
+
existing = await api(`${base4(project)}/sites`, { method: "POST", body: JSON.stringify({ name: site, port: settings.port, healthPath: settings.healthPath }) });
|
|
5553
|
+
log(`Created site "${site}".`);
|
|
5554
|
+
} else if (settings.port !== undefined && settings.port !== existing.port || settings.healthPath !== undefined && settings.healthPath !== existing.healthPath) {
|
|
5555
|
+
existing = await api(sitePath(project, site), { method: "PATCH", body: JSON.stringify({ port: settings.port, healthPath: settings.healthPath }) });
|
|
5556
|
+
}
|
|
5557
|
+
const health = await api("/health");
|
|
5558
|
+
const platform = `linux/${goArch(health.arch ?? process.arch)}`;
|
|
5559
|
+
const tag = `mybase-deploy/${project}-${site}:latest`;
|
|
5560
|
+
log(`Building ${site} for ${platform}\u2026`);
|
|
5561
|
+
await docker(["build", "--platform", platform, "-t", tag, "-f", dockerfile, context]);
|
|
5562
|
+
const tar = join4(tmpdir2(), `mybase-${project}-${site}-${crypto.randomUUID()}.tar`);
|
|
5563
|
+
try {
|
|
5564
|
+
await docker(["save", "-o", tar, tag], true);
|
|
5565
|
+
log(`Pushing to ${new URL(API_URL).host}/${project}/${site} (${CHUNK_BYTES / 1024 / 1024} MB chunks)\u2026`);
|
|
5566
|
+
let lastPct = -1;
|
|
5567
|
+
const { digest, architecture } = await pushImage(tar, { url: API_URL, token, repo: `${project}/${site}` }, (e) => {
|
|
5568
|
+
if (jsonMode)
|
|
5569
|
+
return;
|
|
5570
|
+
if (e.kind === "skip")
|
|
5571
|
+
process.stderr.write(` ${e.digest.slice(7, 19)} already there
|
|
5572
|
+
`);
|
|
5573
|
+
if (e.kind === "upload") {
|
|
5574
|
+
const pct = Math.floor((e.sent ?? 0) / e.size * 100);
|
|
5575
|
+
if (pct !== lastPct)
|
|
5576
|
+
process.stderr.write(`\r ${e.digest.slice(7, 19)} ${pct}% of ${(e.size / 1024 / 1024).toFixed(1)} MB`);
|
|
5577
|
+
lastPct = pct;
|
|
5578
|
+
}
|
|
5579
|
+
if (e.kind === "done")
|
|
5580
|
+
process.stderr.write(`\r ${e.digest.slice(7, 19)} pushed ${(e.size / 1024 / 1024).toFixed(1)} MB
|
|
5581
|
+
`);
|
|
5582
|
+
});
|
|
5583
|
+
if (architecture && `linux/${architecture}` !== platform)
|
|
5584
|
+
throw new CliError(`the image is ${architecture}, but the server runs ${platform}`);
|
|
5585
|
+
const body = { digest };
|
|
5586
|
+
const created = await api(`${sitePath(project, site)}/deploys`, { method: "POST", body: JSON.stringify(body) });
|
|
5587
|
+
await finish(project, site, created, flags.has("no-wait"));
|
|
5588
|
+
} finally {
|
|
5589
|
+
await rm2(tar, { force: true });
|
|
5590
|
+
}
|
|
5591
|
+
}
|
|
5592
|
+
var log = (line) => {
|
|
5593
|
+
if (!jsonMode)
|
|
5594
|
+
console.error(line);
|
|
5595
|
+
};
|
|
5596
|
+
async function finish(project, site, deploy, noWait) {
|
|
5597
|
+
let d = deploy;
|
|
5598
|
+
if (!noWait) {
|
|
5599
|
+
log(`Deploy ${short(d.id)} queued; waiting for the container to answer\u2026`);
|
|
5600
|
+
const deadline = Date.now() + 20 * 60000;
|
|
5601
|
+
while ((d.status === "queued" || d.status === "starting") && Date.now() < deadline) {
|
|
5602
|
+
await Bun.sleep(2000);
|
|
5603
|
+
d = (await api(`${sitePath(project, site)}/deploys`)).find((x) => x.id === d.id) ?? d;
|
|
5604
|
+
}
|
|
5605
|
+
}
|
|
5606
|
+
const s = await api(sitePath(project, site));
|
|
5607
|
+
print({ deploy: d, url: s.url }, () => {
|
|
5608
|
+
if (d.status === "live")
|
|
5609
|
+
console.log(`Live: ${s.url ?? `http://${site}.localhost`} (deploy ${short(d.id)})`);
|
|
5610
|
+
else if (d.status === "failed")
|
|
5611
|
+
console.log(`Deploy ${short(d.id)} failed: ${d.error}
|
|
5612
|
+
The previous deploy (if any) is still live.`);
|
|
5613
|
+
else
|
|
5614
|
+
console.log(`Deploy ${short(d.id)} is ${d.status}. Check: mybase hosting deploys ${project} ${site}`);
|
|
5615
|
+
});
|
|
5616
|
+
if (d.status === "failed")
|
|
5617
|
+
process.exitCode = 1;
|
|
5618
|
+
}
|
|
5619
|
+
|
|
4925
5620
|
// src/queue.ts
|
|
4926
5621
|
init_src();
|
|
4927
5622
|
init_output();
|
|
@@ -4942,8 +5637,8 @@ var queueUsage = ` queue enable <project> Turn on queues
|
|
|
4942
5637
|
queue redrive <project> <dlq> [--to <queue>] Move dead letters back to their queues
|
|
4943
5638
|
queue stats <project> [--range 24h|7d] Sent, received, deleted, dead-lettered per queue
|
|
4944
5639
|
queue sync [--config <file>] [--dry-run] [--yes] Make code-defined queues match mybase.config.ts`;
|
|
4945
|
-
var
|
|
4946
|
-
var queuePath = (project, name, rest = "") => `${
|
|
5640
|
+
var base5 = (project) => `/projects/${encodeURIComponent(project)}/queue`;
|
|
5641
|
+
var queuePath = (project, name, rest = "") => `${base5(project)}/queues/${encodeURIComponent(name)}${rest}`;
|
|
4947
5642
|
function formatMs(ms) {
|
|
4948
5643
|
for (const [unit, size] of [["d", 86400000], ["h", 3600000], ["m", 60000], ["s", 1000]]) {
|
|
4949
5644
|
if (ms >= size && ms % size === 0)
|
|
@@ -4967,7 +5662,7 @@ async function queue(sub, args) {
|
|
|
4967
5662
|
case "enable": {
|
|
4968
5663
|
const p = need(project, "project name");
|
|
4969
5664
|
const { linked } = await enableService(p, "queue", flags);
|
|
4970
|
-
const settings = await api(
|
|
5665
|
+
const settings = await api(base5(p));
|
|
4971
5666
|
print({ enabled: "queue", project: p, linked, ...settings }, () => {
|
|
4972
5667
|
console.log(`Queues enabled for "${p}"
|
|
4973
5668
|
`);
|
|
@@ -4985,7 +5680,7 @@ async function queue(sub, args) {
|
|
|
4985
5680
|
}
|
|
4986
5681
|
case "list": {
|
|
4987
5682
|
const p = need(project, "project name");
|
|
4988
|
-
const queues = await api(`${
|
|
5683
|
+
const queues = await api(`${base5(p)}/queues`);
|
|
4989
5684
|
print(queues, () => {
|
|
4990
5685
|
if (queues.length === 0)
|
|
4991
5686
|
console.log("No queues yet.");
|
|
@@ -5011,7 +5706,7 @@ async function queue(sub, args) {
|
|
|
5011
5706
|
contentDedup: flags.has("content-dedup") || undefined,
|
|
5012
5707
|
deadLetter: dlq ? { queue: dlq, maxReceives: flagInt(flags, "max-receives") ?? 5 } : undefined
|
|
5013
5708
|
};
|
|
5014
|
-
const q = await api(`${
|
|
5709
|
+
const q = await api(`${base5(p)}/queues`, { method: "POST", body: JSON.stringify(body) });
|
|
5015
5710
|
print(q, () => console.log(`Created ${q.fifo ? "FIFO" : "standard"} queue "${q.name}" (visibility ${formatMs(q.visibilityTimeoutMs)}, retention ${formatMs(q.retentionMs)})`));
|
|
5016
5711
|
return true;
|
|
5017
5712
|
}
|
|
@@ -5096,7 +5791,7 @@ async function queue(sub, args) {
|
|
|
5096
5791
|
const range = flagString(flags, "range") ?? "24h";
|
|
5097
5792
|
if (!QUEUE_STATS_RANGES.includes(range))
|
|
5098
5793
|
throw new Error(`--range must be one of ${QUEUE_STATS_RANGES.join(", ")}`);
|
|
5099
|
-
const stats = await api(`${
|
|
5794
|
+
const stats = await api(`${base5(p)}/stats?range=${range}`);
|
|
5100
5795
|
print(stats, () => {
|
|
5101
5796
|
const { queues } = stats;
|
|
5102
5797
|
console.log(`Last ${range}:`);
|
|
@@ -5125,7 +5820,7 @@ function parseDuration(value) {
|
|
|
5125
5820
|
async function sync2(file, dryRun, yes) {
|
|
5126
5821
|
const config = await readConfigFile(file);
|
|
5127
5822
|
const queues = Object.entries(config.queue?.queues ?? {}).map(([name, q]) => ({ ...q, name }));
|
|
5128
|
-
const path = `${
|
|
5823
|
+
const path = `${base5(config.project)}/queues`;
|
|
5129
5824
|
const plan = await api(path, { method: "PUT", body: JSON.stringify({ queues, dryRun: true }) });
|
|
5130
5825
|
if (dryRun)
|
|
5131
5826
|
return printSync(config.project, true, plan);
|
|
@@ -5151,8 +5846,8 @@ var vectorUsage = ` vector enable <project> Turn on vector + full-text s
|
|
|
5151
5846
|
vector documents <project> <index> [--limit 20] [--after <id>] List documents in id order
|
|
5152
5847
|
vector query <project> <index> [<text>] [--mode text|vector|hybrid] [--top 10] [--filter <json>]
|
|
5153
5848
|
vector sync [--config <file>] [--dry-run] [--yes] Make code-defined indexes match mybase.config.ts`;
|
|
5154
|
-
var
|
|
5155
|
-
var indexPath = (project, name, rest = "") => `${
|
|
5849
|
+
var base6 = (project) => `/projects/${encodeURIComponent(project)}/vector`;
|
|
5850
|
+
var indexPath = (project, name, rest = "") => `${base6(project)}/indexes/${encodeURIComponent(name)}${rest}`;
|
|
5156
5851
|
function describe(i) {
|
|
5157
5852
|
const vectors = i.dimensions ? `${i.dimensions}d ${i.metric}` : "text only";
|
|
5158
5853
|
return [i.name, vectors, i.language, i.embed ? "embeds" : "", `${i.documents} docs`, i.source].filter(Boolean).join("\t");
|
|
@@ -5171,7 +5866,7 @@ async function vector(sub, args) {
|
|
|
5171
5866
|
case "enable": {
|
|
5172
5867
|
const p = need(project, "project name");
|
|
5173
5868
|
const { linked } = await enableService(p, "vector", flags);
|
|
5174
|
-
const settings = await api(
|
|
5869
|
+
const settings = await api(base6(p));
|
|
5175
5870
|
print({ enabled: "vector", project: p, linked, ...settings }, () => {
|
|
5176
5871
|
console.log(`Vector search enabled for "${p}"
|
|
5177
5872
|
`);
|
|
@@ -5189,7 +5884,7 @@ async function vector(sub, args) {
|
|
|
5189
5884
|
}
|
|
5190
5885
|
case "settings": {
|
|
5191
5886
|
const p = need(project, "project name");
|
|
5192
|
-
const settings = await api(
|
|
5887
|
+
const settings = await api(base6(p));
|
|
5193
5888
|
print(settings, () => {
|
|
5194
5889
|
const e = settings.embedding;
|
|
5195
5890
|
console.log(e ? `embedding ${e.model} (${e.dimensions}d) at ${e.url}${e.hasKey ? ", API key stored" : ""}` : "embedding none (apps send their own vectors)");
|
|
@@ -5208,13 +5903,13 @@ async function vector(sub, args) {
|
|
|
5208
5903
|
const apiKey = flags.has("key") ? await readSecret("API key: ") : undefined;
|
|
5209
5904
|
body = { embedding: { url: need(flagString(flags, "url"), "--url"), model: need(flagString(flags, "model"), "--model"), dimensions: dims, apiKey } };
|
|
5210
5905
|
}
|
|
5211
|
-
const settings = await api(
|
|
5906
|
+
const settings = await api(base6(p), { method: "PUT", body: JSON.stringify(body) });
|
|
5212
5907
|
print(settings, () => console.log(settings.embedding ? `Embedding with ${settings.embedding.model} (${settings.embedding.dimensions}d)` : "Embedding provider removed"));
|
|
5213
5908
|
return true;
|
|
5214
5909
|
}
|
|
5215
5910
|
case "list": {
|
|
5216
5911
|
const p = need(project, "project name");
|
|
5217
|
-
const indexes = await api(`${
|
|
5912
|
+
const indexes = await api(`${base6(p)}/indexes`);
|
|
5218
5913
|
print(indexes, () => {
|
|
5219
5914
|
if (indexes.length === 0)
|
|
5220
5915
|
console.log("No indexes yet.");
|
|
@@ -5232,7 +5927,7 @@ async function vector(sub, args) {
|
|
|
5232
5927
|
language: flagString(flags, "lang"),
|
|
5233
5928
|
embed: flags.has("embed") || undefined
|
|
5234
5929
|
};
|
|
5235
|
-
const index = await api(`${
|
|
5930
|
+
const index = await api(`${base6(p)}/indexes`, { method: "POST", body: JSON.stringify(body) });
|
|
5236
5931
|
print(index, () => console.log(`Created index "${index.name}" (${index.dimensions ? `${index.dimensions}d ${index.metric}` : "text only"}, ${index.language})`));
|
|
5237
5932
|
return true;
|
|
5238
5933
|
}
|
|
@@ -5315,7 +6010,7 @@ async function vector(sub, args) {
|
|
|
5315
6010
|
async function sync3(file, dryRun, yes) {
|
|
5316
6011
|
const config = await readConfigFile(file);
|
|
5317
6012
|
const indexes = Object.entries(config.vector?.indexes ?? {}).map(([name, i]) => ({ ...i, name }));
|
|
5318
|
-
const path = `${
|
|
6013
|
+
const path = `${base6(config.project)}/indexes`;
|
|
5319
6014
|
const plan = await api(path, { method: "PUT", body: JSON.stringify({ indexes, dryRun: true }) });
|
|
5320
6015
|
if (dryRun)
|
|
5321
6016
|
return printSync(config.project, true, plan);
|
|
@@ -5340,7 +6035,7 @@ var ratelimitUsage = ` ratelimit enable <project> Turn on rate limits
|
|
|
5340
6035
|
ratelimit delete <project> <name> Delete a rule
|
|
5341
6036
|
ratelimit stats <project> [--range 24h|7d] Allowed and blocked requests per rule
|
|
5342
6037
|
ratelimit sync [--config <file>] [--dry-run] Make code-defined rules match mybase.config.ts`;
|
|
5343
|
-
var
|
|
6038
|
+
var base7 = (project) => `/projects/${encodeURIComponent(project)}/ratelimit`;
|
|
5344
6039
|
function formatWindow(ms) {
|
|
5345
6040
|
for (const [unit, size] of [["d", 86400000], ["h", 3600000], ["m", 60000], ["s", 1000]]) {
|
|
5346
6041
|
if (ms % size === 0)
|
|
@@ -5364,7 +6059,7 @@ async function ratelimit(sub, args) {
|
|
|
5364
6059
|
case "enable": {
|
|
5365
6060
|
const p = need(project, "project name");
|
|
5366
6061
|
const { linked } = await enableService(p, "ratelimit", flags);
|
|
5367
|
-
const settings = await api(
|
|
6062
|
+
const settings = await api(base7(p));
|
|
5368
6063
|
print({ enabled: "ratelimit", project: p, linked, ...settings }, () => {
|
|
5369
6064
|
console.log(`Rate limits enabled for "${p}"
|
|
5370
6065
|
`);
|
|
@@ -5382,7 +6077,7 @@ async function ratelimit(sub, args) {
|
|
|
5382
6077
|
}
|
|
5383
6078
|
case "rules": {
|
|
5384
6079
|
const p = need(project, "project name");
|
|
5385
|
-
const rules = await api(`${
|
|
6080
|
+
const rules = await api(`${base7(p)}/rules`);
|
|
5386
6081
|
print(rules, () => {
|
|
5387
6082
|
if (rules.length === 0)
|
|
5388
6083
|
console.log("No rules yet. Inline limits still work.");
|
|
@@ -5402,14 +6097,14 @@ async function ratelimit(sub, args) {
|
|
|
5402
6097
|
algorithm: flagString(flags, "algorithm"),
|
|
5403
6098
|
burst: flagInt(flags, "burst")
|
|
5404
6099
|
};
|
|
5405
|
-
const r = await api(`${
|
|
6100
|
+
const r = await api(`${base7(p)}/rules`, { method: "POST", body: JSON.stringify(body) });
|
|
5406
6101
|
print(r, () => console.log(`Created rule "${r.name}": ${r.limit} per ${formatWindow(r.windowMs)} (${r.algorithm})`));
|
|
5407
6102
|
return true;
|
|
5408
6103
|
}
|
|
5409
6104
|
case "delete": {
|
|
5410
6105
|
const p = need(project, "project name");
|
|
5411
6106
|
const n = need(name, "rule name");
|
|
5412
|
-
await api(`${
|
|
6107
|
+
await api(`${base7(p)}/rules/${encodeURIComponent(n)}`, { method: "DELETE" });
|
|
5413
6108
|
print({ deleted: n }, () => console.log(`Deleted rule "${n}"`));
|
|
5414
6109
|
return true;
|
|
5415
6110
|
}
|
|
@@ -5418,7 +6113,7 @@ async function ratelimit(sub, args) {
|
|
|
5418
6113
|
const range = flagString(flags, "range") ?? "24h";
|
|
5419
6114
|
if (!RATELIMIT_STATS_RANGES.includes(range))
|
|
5420
6115
|
throw new Error(`--range must be one of ${RATELIMIT_STATS_RANGES.join(", ")}`);
|
|
5421
|
-
const stats = await api(`${
|
|
6116
|
+
const stats = await api(`${base7(p)}/stats?range=${range}`);
|
|
5422
6117
|
print(stats, () => {
|
|
5423
6118
|
const { rules } = stats;
|
|
5424
6119
|
console.log(`Last ${range}:`);
|
|
@@ -5449,7 +6144,7 @@ async function sync4(file, dryRun) {
|
|
|
5449
6144
|
burst: rule.burst
|
|
5450
6145
|
}))
|
|
5451
6146
|
};
|
|
5452
|
-
printSync(config.project, dryRun, await api(`${
|
|
6147
|
+
printSync(config.project, dryRun, await api(`${base7(config.project)}/rules`, { method: "PUT", body: JSON.stringify(body) }));
|
|
5453
6148
|
}
|
|
5454
6149
|
|
|
5455
6150
|
// src/realtime.ts
|
|
@@ -5466,7 +6161,7 @@ var realtimeUsage = ` realtime enable <project> Turn on realtime and print
|
|
|
5466
6161
|
realtime channels <project> [--prefix p] Channels with subscribers right now
|
|
5467
6162
|
realtime publish <project> <channel> <event> [json] Send a message to everyone subscribed
|
|
5468
6163
|
realtime disable <project> [--yes] Turn it off (closes every connection)`;
|
|
5469
|
-
var
|
|
6164
|
+
var base8 = (project) => `/projects/${encodeURIComponent(project)}/realtime`;
|
|
5470
6165
|
function printSettings3(s) {
|
|
5471
6166
|
console.log(` socket ${s.socketUrl} (connectRealtime in @mybase-dev/sdk/realtime)`);
|
|
5472
6167
|
console.log(` publish POST ${s.publishUrl} (realtime:write key)`);
|
|
@@ -5491,7 +6186,7 @@ async function realtime(sub, args) {
|
|
|
5491
6186
|
case "enable": {
|
|
5492
6187
|
const p = need(project, "project name");
|
|
5493
6188
|
const { linked } = await enableService(p, "realtime", flags);
|
|
5494
|
-
const s = await api(
|
|
6189
|
+
const s = await api(base8(p));
|
|
5495
6190
|
print({ enabled: "realtime", project: p, linked, ...s }, () => {
|
|
5496
6191
|
console.log(`Realtime enabled for "${p}"
|
|
5497
6192
|
`);
|
|
@@ -5502,7 +6197,7 @@ async function realtime(sub, args) {
|
|
|
5502
6197
|
}
|
|
5503
6198
|
case "info": {
|
|
5504
6199
|
const p = need(project, "project name");
|
|
5505
|
-
const [s, stats] = await Promise.all([api(
|
|
6200
|
+
const [s, stats] = await Promise.all([api(base8(p)), api(`${base8(p)}/stats`)]);
|
|
5506
6201
|
const { secret: _secret, ...settings } = s;
|
|
5507
6202
|
print({ ...settings, stats }, () => {
|
|
5508
6203
|
printSettings3({ ...s, secret: "(mybase realtime key)" });
|
|
@@ -5519,7 +6214,7 @@ async function realtime(sub, args) {
|
|
|
5519
6214
|
const p = need(project, "project name");
|
|
5520
6215
|
if (flags.has("rotate"))
|
|
5521
6216
|
confirm(args.includes("--yes"), `Every open realtime connection on "${p}" closes, and tokens signed with the old secret stop working.`, p, "the project name");
|
|
5522
|
-
const s = flags.has("rotate") ? await api(`${
|
|
6217
|
+
const s = flags.has("rotate") ? await api(`${base8(p)}/secret/rotate`, { method: "POST" }) : await api(base8(p));
|
|
5523
6218
|
print({ secret: s.secret }, () => {
|
|
5524
6219
|
if (flags.has("rotate"))
|
|
5525
6220
|
console.log(`Rotated. Update MYBASE_REALTIME_SECRET where your server signs tokens.
|
|
@@ -5549,7 +6244,7 @@ async function realtime(sub, args) {
|
|
|
5549
6244
|
if (n !== undefined)
|
|
5550
6245
|
body[key] = n;
|
|
5551
6246
|
}
|
|
5552
|
-
const s = Object.keys(body).length ? await api(
|
|
6247
|
+
const s = Object.keys(body).length ? await api(base8(p), { method: "PATCH", body: JSON.stringify(body) }) : await api(base8(p));
|
|
5553
6248
|
const { secret: _secret, ...shown } = s;
|
|
5554
6249
|
print(shown, () => printSettings3({ ...s, secret: "(mybase realtime key)" }));
|
|
5555
6250
|
return true;
|
|
@@ -5557,7 +6252,7 @@ async function realtime(sub, args) {
|
|
|
5557
6252
|
case "channels": {
|
|
5558
6253
|
const p = need(project, "project name");
|
|
5559
6254
|
const prefix = flagString(flags, "prefix");
|
|
5560
|
-
const { channels } = await api(`${
|
|
6255
|
+
const { channels } = await api(`${base8(p)}/channels${prefix ? `?prefix=${encodeURIComponent(prefix)}` : ""}`);
|
|
5561
6256
|
print(channels, () => {
|
|
5562
6257
|
if (!channels.length)
|
|
5563
6258
|
return console.log("No channels have subscribers right now.");
|
|
@@ -5576,7 +6271,7 @@ async function realtime(sub, args) {
|
|
|
5576
6271
|
payload = data;
|
|
5577
6272
|
}
|
|
5578
6273
|
}
|
|
5579
|
-
const { ids } = await api(`${
|
|
6274
|
+
const { ids } = await api(`${base8(p)}/publish`, {
|
|
5580
6275
|
method: "POST",
|
|
5581
6276
|
body: JSON.stringify({ channel: need(channel, "channel"), event: need(event, "event"), data: payload })
|
|
5582
6277
|
});
|
|
@@ -5635,8 +6330,8 @@ async function target(flags, needEnv = true) {
|
|
|
5635
6330
|
}
|
|
5636
6331
|
return { project, env: env ?? "" };
|
|
5637
6332
|
}
|
|
5638
|
-
var
|
|
5639
|
-
var envPath = (project, env, rest = "") => `${
|
|
6333
|
+
var base9 = (project) => `/projects/${encodeURIComponent(project)}/secrets`;
|
|
6334
|
+
var envPath = (project, env, rest = "") => `${base9(project)}/envs/${encodeURIComponent(env)}${rest}`;
|
|
5640
6335
|
var fetchSecrets = (project, env) => api(`/secrets/${encodeURIComponent(project)}/${encodeURIComponent(env)}`);
|
|
5641
6336
|
var describeEnv = (e) => [e.name, e.parent ? `branch of ${e.parent}` : "root", `${e.keys} keys`, `rev ${e.revision}`, e.linked ? "linked" : ""].filter(Boolean).join("\t");
|
|
5642
6337
|
async function readStdin() {
|
|
@@ -5655,7 +6350,7 @@ async function secrets(sub, args) {
|
|
|
5655
6350
|
case "enable": {
|
|
5656
6351
|
const p = need(positional[0] ?? flagString(flags, "project"), "project name");
|
|
5657
6352
|
await api(`/projects/${encodeURIComponent(p)}/services/secrets`, { method: "PUT" });
|
|
5658
|
-
const overview = await api(
|
|
6353
|
+
const overview = await api(base9(p));
|
|
5659
6354
|
print({ enabled: "secrets", project: p, ...overview }, () => {
|
|
5660
6355
|
console.log(`Secrets enabled for "${p}": ${overview.envs.map((e) => e.name).join(", ")}
|
|
5661
6356
|
`);
|
|
@@ -5675,7 +6370,7 @@ async function secrets(sub, args) {
|
|
|
5675
6370
|
}
|
|
5676
6371
|
case "setup": {
|
|
5677
6372
|
const { project, env } = await target(flags);
|
|
5678
|
-
const overview = await api(
|
|
6373
|
+
const overview = await api(base9(project));
|
|
5679
6374
|
if (!overview.envs.some((e) => e.name === env)) {
|
|
5680
6375
|
throw usageError(`"${project}" has no env "${env}" (envs: ${overview.envs.map((e) => e.name).join(", ")})`);
|
|
5681
6376
|
}
|
|
@@ -5685,7 +6380,7 @@ async function secrets(sub, args) {
|
|
|
5685
6380
|
}
|
|
5686
6381
|
case "envs": {
|
|
5687
6382
|
const { project } = await target(flags, false);
|
|
5688
|
-
const overview = await api(
|
|
6383
|
+
const overview = await api(base9(project));
|
|
5689
6384
|
print(overview.envs, () => {
|
|
5690
6385
|
for (const e of overview.envs)
|
|
5691
6386
|
console.log(describeEnv(e));
|
|
@@ -5696,7 +6391,7 @@ async function secrets(sub, args) {
|
|
|
5696
6391
|
const [action, name, next] = positional;
|
|
5697
6392
|
const { project } = await target(flags, false);
|
|
5698
6393
|
if (action === "create") {
|
|
5699
|
-
const env = await api(`${
|
|
6394
|
+
const env = await api(`${base9(project)}/envs`, {
|
|
5700
6395
|
method: "POST",
|
|
5701
6396
|
body: JSON.stringify({ name: need(name, "env name"), parent: flagString(flags, "from") ?? null })
|
|
5702
6397
|
});
|
|
@@ -5799,7 +6494,7 @@ async function secrets(sub, args) {
|
|
|
5799
6494
|
const { project, env } = await target(flags);
|
|
5800
6495
|
const envs = env.split(",").map((e) => e.trim()).filter(Boolean);
|
|
5801
6496
|
confirm(yes, `This rotates the credentials of "${project}" (database password, storage key, cache password if the cache is on, and the API key link made before) and stores the new ones, with the other services' keys, in ${envs.join(", ")} plus any env linked before. Apps still using the old credentials will stop working.`, project, "the project name");
|
|
5802
|
-
const result = await api(`${
|
|
6497
|
+
const result = await api(`${base9(project)}/link`, { method: "POST", body: JSON.stringify({ envs }) });
|
|
5803
6498
|
print(result, () => {
|
|
5804
6499
|
console.log(`Rotated and stored in ${result.envs.join(", ")}: ${result.keys.join(", ")}`);
|
|
5805
6500
|
console.log("Restart your apps with `mybase run` to pick them up.");
|
|
@@ -5809,7 +6504,7 @@ async function secrets(sub, args) {
|
|
|
5809
6504
|
case "unlink": {
|
|
5810
6505
|
const { project, env } = await target(flags);
|
|
5811
6506
|
confirm(yes, `This removes the project credentials from "${env}". They stay valid; nothing is rotated.`, env, "the env name");
|
|
5812
|
-
await api(`${
|
|
6507
|
+
await api(`${base9(project)}/link/${encodeURIComponent(env)}`, { method: "DELETE" });
|
|
5813
6508
|
print({ unlinked: env, project }, () => console.log(`Removed the credentials from "${env}"`));
|
|
5814
6509
|
return true;
|
|
5815
6510
|
}
|
|
@@ -5985,8 +6680,8 @@ async function sandbox(sub, args) {
|
|
|
5985
6680
|
|
|
5986
6681
|
// src/server.ts
|
|
5987
6682
|
init_dist4();
|
|
5988
|
-
import { join as
|
|
5989
|
-
import { styleText as
|
|
6683
|
+
import { join as join9 } from "path";
|
|
6684
|
+
import { styleText as styleText5 } from "util";
|
|
5990
6685
|
|
|
5991
6686
|
// ../../packages/shared/src/logger.ts
|
|
5992
6687
|
var LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
@@ -6029,6 +6724,8 @@ var SERVICE_COLORS = {
|
|
|
6029
6724
|
realtime: color.magenta,
|
|
6030
6725
|
sandbox: color.yellow,
|
|
6031
6726
|
"sandbox-worker": color.yellow,
|
|
6727
|
+
hosting: color.green,
|
|
6728
|
+
"hosting-worker": color.green,
|
|
6032
6729
|
"db-public": color.cyan,
|
|
6033
6730
|
dev: color.gray
|
|
6034
6731
|
};
|
|
@@ -6101,9 +6798,125 @@ init_server_env();
|
|
|
6101
6798
|
init_server_sandbox_env();
|
|
6102
6799
|
await __promiseAll([
|
|
6103
6800
|
init_api(),
|
|
6104
|
-
init_server_sandbox()
|
|
6105
|
-
init_server_setup()
|
|
6801
|
+
init_server_sandbox()
|
|
6106
6802
|
]);
|
|
6803
|
+
|
|
6804
|
+
// src/server-hosting.ts
|
|
6805
|
+
init_dist4();
|
|
6806
|
+
init_cloudflare2();
|
|
6807
|
+
init_output();
|
|
6808
|
+
init_server_docker();
|
|
6809
|
+
init_server_env();
|
|
6810
|
+
await init_server_setup();
|
|
6811
|
+
import { join as join8 } from "path";
|
|
6812
|
+
import { styleText as styleText4 } from "util";
|
|
6813
|
+
var LOCAL_HOSTING_WORKER = "http://hosting-worker:4020";
|
|
6814
|
+
var DOMAIN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
6815
|
+
function guessDomain(values) {
|
|
6816
|
+
try {
|
|
6817
|
+
const host = new URL(values.MYBASE_PUBLIC_URL ?? "").hostname;
|
|
6818
|
+
const parts = host.split(".");
|
|
6819
|
+
return parts.length >= 3 && DOMAIN.test(host) ? parts.slice(1).join(".") : null;
|
|
6820
|
+
} catch {
|
|
6821
|
+
return null;
|
|
6822
|
+
}
|
|
6823
|
+
}
|
|
6824
|
+
function hostingSettings(values, domain) {
|
|
6825
|
+
const key = values.MYBASE_HOSTING_WORKER_KEY || Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
|
|
6826
|
+
return {
|
|
6827
|
+
COMPOSE_PROFILES: composeProfile(values.COMPOSE_PROFILES, "hosting", true),
|
|
6828
|
+
MYBASE_HOSTING_WORKER_URL: LOCAL_HOSTING_WORKER,
|
|
6829
|
+
MYBASE_HOSTING_WORKER_KEY: key,
|
|
6830
|
+
MYBASE_HOSTING_DOMAIN: domain
|
|
6831
|
+
};
|
|
6832
|
+
}
|
|
6833
|
+
async function ask2(answer) {
|
|
6834
|
+
const value = await answer;
|
|
6835
|
+
if (isCancel(value))
|
|
6836
|
+
throw new CliError("cancelled; nothing changed");
|
|
6837
|
+
return value;
|
|
6838
|
+
}
|
|
6839
|
+
async function routeWildcard(tunnelToken, domain, apiToken, interactive) {
|
|
6840
|
+
const tunnelId = tunnelIdFromToken(tunnelToken);
|
|
6841
|
+
if (!tunnelId)
|
|
6842
|
+
throw new CliError("CLOUDFLARE_TUNNEL_TOKEN in .env isn't a tunnel token");
|
|
6843
|
+
let token = apiToken;
|
|
6844
|
+
if (!token) {
|
|
6845
|
+
if (!interactive)
|
|
6846
|
+
return false;
|
|
6847
|
+
ui.note([`Same token as the tunnel setup: ${TOKEN_URL}`, ...TOKEN_PERMISSIONS.map((perm) => ` \u2022 ${perm}`), "", "Used now to add *." + domain + ", not saved."].join(`
|
|
6848
|
+
`), "Cloudflare API token");
|
|
6849
|
+
token = (await ask2(password({ message: "Cloudflare API token (empty to skip)" }))).trim();
|
|
6850
|
+
if (!token)
|
|
6851
|
+
return false;
|
|
6852
|
+
}
|
|
6853
|
+
const cf = cloudflare(token);
|
|
6854
|
+
const zone = zoneOf(await cf.listZones(), domain);
|
|
6855
|
+
if (!zone)
|
|
6856
|
+
throw new CliError(`${domain} isn't an active zone on this Cloudflare token`);
|
|
6857
|
+
if (zone.name !== domain)
|
|
6858
|
+
ui.warn(`${domain} is under ${zone.name}: Cloudflare's free certificate only covers one level (*.${zone.name}), so https://<site>.${domain} needs Advanced Certificate Manager.`);
|
|
6859
|
+
const wildcard = `*.${domain}`;
|
|
6860
|
+
const record = await cf.findRecord(zone.id, wildcard).catch(() => null);
|
|
6861
|
+
if (record && !pointsAtTunnel(record, tunnelId)) {
|
|
6862
|
+
if (!interactive)
|
|
6863
|
+
throw new CliError(`${wildcard} already points at ${record.content}; replace it by hand or rerun in a terminal`);
|
|
6864
|
+
const replace = await ask2(confirm2({ message: `${wildcard} already points at ${record.content}. Replace it?`, initialValue: false }));
|
|
6865
|
+
if (!replace)
|
|
6866
|
+
return false;
|
|
6867
|
+
}
|
|
6868
|
+
const current = await cf.getIngress(zone.account.id, tunnelId);
|
|
6869
|
+
await cf.putIngress(zone.account.id, tunnelId, mergeIngress(current, [{ hostname: wildcard, service: "http://caddy:80" }]));
|
|
6870
|
+
await cf.upsertCname(zone.id, wildcard, tunnelId);
|
|
6871
|
+
return true;
|
|
6872
|
+
}
|
|
6873
|
+
async function setupHostingServer(args) {
|
|
6874
|
+
const dir = await serverDir(args);
|
|
6875
|
+
const envFile = Bun.file(join8(dir, ".env"));
|
|
6876
|
+
if (!await envFile.exists())
|
|
6877
|
+
throw new CliError(`no mybase server in ${dir}. Run \`mybase server setup\` (or pass --dir).`, 4);
|
|
6878
|
+
const text = await envFile.text();
|
|
6879
|
+
const values = parseEnv(text);
|
|
6880
|
+
const interactive = !jsonMode && process.stdin.isTTY === true;
|
|
6881
|
+
if (!jsonMode)
|
|
6882
|
+
intro(`${styleText4("bold", " mybase ")} ${styleText4("dim", "server hosting")}`);
|
|
6883
|
+
if (args.includes("--off")) {
|
|
6884
|
+
await Bun.write(join8(dir, ".env"), mergeEnv(text, { COMPOSE_PROFILES: composeProfile(values.COMPOSE_PROFILES, "hosting", false), MYBASE_HOSTING_WORKER_URL: null }));
|
|
6885
|
+
await compose(dir, ["--profile", "hosting", "rm", "-sf", "caddy", "registry", "hosting-worker"]);
|
|
6886
|
+
await startStack(dir, values, false);
|
|
6887
|
+
return print({ dir, hosting: false }, () => outro("Hosting off: sites aren't reachable. Turn it off per project to remove their containers."));
|
|
6888
|
+
}
|
|
6889
|
+
let domain = flag(args, "--domain")?.toLowerCase() ?? null;
|
|
6890
|
+
if (!domain && interactive) {
|
|
6891
|
+
const guess = values.MYBASE_HOSTING_DOMAIN || guessDomain(values) || "";
|
|
6892
|
+
domain = (await ask2(text2({ message: "Base domain for sites (<site>.<domain>); empty for LAN only", placeholder: "example.com", defaultValue: guess, initialValue: guess }))).trim().toLowerCase() || null;
|
|
6893
|
+
} else if (!domain)
|
|
6894
|
+
domain = values.MYBASE_HOSTING_DOMAIN || null;
|
|
6895
|
+
if (domain && !DOMAIN.test(domain))
|
|
6896
|
+
throw usageError(`"${domain}" isn't a domain name`);
|
|
6897
|
+
const updates = hostingSettings(values, domain);
|
|
6898
|
+
let routed = false;
|
|
6899
|
+
if (domain && values.CLOUDFLARE_TUNNEL_TOKEN) {
|
|
6900
|
+
try {
|
|
6901
|
+
routed = await routeWildcard(values.CLOUDFLARE_TUNNEL_TOKEN, domain, flag(args, "--cf-token") ?? process.env.CLOUDFLARE_API_TOKEN, interactive);
|
|
6902
|
+
} catch (err) {
|
|
6903
|
+
throw new CliError(err instanceof CloudflareError ? err.message : err.message);
|
|
6904
|
+
}
|
|
6905
|
+
}
|
|
6906
|
+
await Bun.write(join8(dir, ".env"), mergeEnv(text, updates));
|
|
6907
|
+
const version = await startStack(dir, values);
|
|
6908
|
+
const url = domain ? `https://<site>.${domain}` : `http://<site>.localhost:${values.MYBASE_HOSTING_PORT || "80"}`;
|
|
6909
|
+
print({ dir, version, hosting: true, domain, wildcardRouted: routed }, () => {
|
|
6910
|
+
if (domain && values.CLOUDFLARE_TUNNEL_TOKEN && !routed)
|
|
6911
|
+
ui.warn(`*.${domain} isn't routed yet. In the tunnel's public hostnames, add *.${domain} \u2192 http://caddy:80, and a proxied CNAME *.${domain} \u2192 <tunnel id>.cfargotunnel.com. Or rerun with --cf-token.`);
|
|
6912
|
+
if (domain && !values.CLOUDFLARE_TUNNEL_TOKEN)
|
|
6913
|
+
ui.info(`No tunnel: point *.${domain} at this machine yourself (port ${values.MYBASE_HOSTING_PORT || "80"}, plain HTTP), or run mybase server tunnel first.`);
|
|
6914
|
+
outro(`Hosting is on. Sites answer at ${url}. Next: mybase hosting enable <project>, then mybase deploy.`);
|
|
6915
|
+
});
|
|
6916
|
+
}
|
|
6917
|
+
|
|
6918
|
+
// src/server.ts
|
|
6919
|
+
await init_server_setup();
|
|
6107
6920
|
var serverUsage = ` server setup [--dir <dir>] Install mybase on this machine: checks Docker, generates keys, sets up
|
|
6108
6921
|
LAN/Tailscale access, a Cloudflare Tunnel, start on boot, and the admin
|
|
6109
6922
|
[--yes] [--public-host <h>] [--tunnel-token <t> --domain <d>] [--port <n>] [--no-autostart]
|
|
@@ -6113,6 +6926,9 @@ var serverUsage = ` server setup [--dir <dir>] Install mybase on this machi
|
|
|
6113
6926
|
Set up sandbox host support; optionally enable it for a project
|
|
6114
6927
|
[--deny-hosts <host,ip,\u2026>] [--image <guest>] [--worker-image <worker>]
|
|
6115
6928
|
Public IP/hostnames, keys and release images are detected automatically
|
|
6929
|
+
server hosting [--dir <dir>] [--domain <example.com>] [--cf-token <t>] | --off
|
|
6930
|
+
Run sites here: registry, Caddy, and the hosting worker; with a tunnel,
|
|
6931
|
+
routes *.<domain> to them (the Cloudflare token isn't saved)
|
|
6116
6932
|
server status [--dir <dir>] Containers, health, and whether mybase starts on boot
|
|
6117
6933
|
server update [--dir <dir>] Download new images and restart (also refreshes compose.yml)
|
|
6118
6934
|
server restart [--dir <dir>] Restart every container (API, Postgres, storage, cache, search, tunnel)
|
|
@@ -6125,19 +6941,19 @@ var serverUsage = ` server setup [--dir <dir>] Install mybase on this machi
|
|
|
6125
6941
|
[--host <db.domain> --dns-token <t> [--email <e>] [--staging]] | --off
|
|
6126
6942
|
server autostart on|off Start mybase when this machine boots (and restart it after crashes)
|
|
6127
6943
|
Default dir: the last one setup used, else ~/mybase`;
|
|
6128
|
-
var dim2 = (s) =>
|
|
6129
|
-
var green = (s) =>
|
|
6130
|
-
var red = (s) =>
|
|
6944
|
+
var dim2 = (s) => styleText5("dim", s);
|
|
6945
|
+
var green = (s) => styleText5("green", s);
|
|
6946
|
+
var red = (s) => styleText5("red", s);
|
|
6131
6947
|
async function loadEnv(dir) {
|
|
6132
|
-
const file = Bun.file(
|
|
6133
|
-
if (!await file.exists() || !await Bun.file(
|
|
6948
|
+
const file = Bun.file(join9(dir, ".env"));
|
|
6949
|
+
if (!await file.exists() || !await Bun.file(join9(dir, "compose.yml")).exists()) {
|
|
6134
6950
|
throw new CliError(`no mybase server in ${dir}. Run \`mybase server setup\` (or pass --dir).`, 4);
|
|
6135
6951
|
}
|
|
6136
6952
|
const text = await file.text();
|
|
6137
6953
|
return { text, values: parseEnv(text) };
|
|
6138
6954
|
}
|
|
6139
6955
|
async function saveEnv(dir, text) {
|
|
6140
|
-
await Bun.write(
|
|
6956
|
+
await Bun.write(join9(dir, ".env"), text);
|
|
6141
6957
|
}
|
|
6142
6958
|
async function status(args) {
|
|
6143
6959
|
const dir = await serverDir(args);
|
|
@@ -6195,7 +7011,7 @@ async function update(args) {
|
|
|
6195
7011
|
const { values } = await loadEnv(dir);
|
|
6196
7012
|
const interactive = !jsonMode && process.stdin.isTTY === true && !args.includes("--yes");
|
|
6197
7013
|
if (!jsonMode)
|
|
6198
|
-
intro(`${
|
|
7014
|
+
intro(`${styleText5("bold", " mybase ")} ${dim2("server update")}`);
|
|
6199
7015
|
const replaced = await writeCompose(dir, interactive, true);
|
|
6200
7016
|
if (replaced)
|
|
6201
7017
|
ui.success(`compose.yml updated to this CLI's (${CLI_VERSION})`);
|
|
@@ -6306,7 +7122,7 @@ async function tunnel(args) {
|
|
|
6306
7122
|
const { text, values } = await loadEnv(dir);
|
|
6307
7123
|
const interactive = !jsonMode && process.stdin.isTTY === true;
|
|
6308
7124
|
if (!jsonMode)
|
|
6309
|
-
intro(`${
|
|
7125
|
+
intro(`${styleText5("bold", " mybase ")} ${dim2("server tunnel")}`);
|
|
6310
7126
|
let updates;
|
|
6311
7127
|
let host = null;
|
|
6312
7128
|
if (args.includes("--off")) {
|
|
@@ -6341,7 +7157,7 @@ async function databasePublic(args) {
|
|
|
6341
7157
|
const interactive = !jsonMode && process.stdin.isTTY === true;
|
|
6342
7158
|
const off = args.includes("--off");
|
|
6343
7159
|
if (!jsonMode)
|
|
6344
|
-
intro(`${
|
|
7160
|
+
intro(`${styleText5("bold", " mybase ")} ${dim2("server database-public")}`);
|
|
6345
7161
|
let updates;
|
|
6346
7162
|
if (off) {
|
|
6347
7163
|
updates = Object.fromEntries(DB_PUBLIC_KEYS.map((k) => [k, null]));
|
|
@@ -6456,6 +7272,8 @@ async function server(command, args) {
|
|
|
6456
7272
|
await setup(args);
|
|
6457
7273
|
else if (command === "sandbox")
|
|
6458
7274
|
await setupSandboxServer(args);
|
|
7275
|
+
else if (command === "hosting")
|
|
7276
|
+
await setupHostingServer(args);
|
|
6459
7277
|
else if (command === "status")
|
|
6460
7278
|
await status(args);
|
|
6461
7279
|
else if (command === "update")
|
|
@@ -6479,8 +7297,8 @@ async function server(command, args) {
|
|
|
6479
7297
|
init_config();
|
|
6480
7298
|
init_output();
|
|
6481
7299
|
await init_api();
|
|
6482
|
-
import { chmod as chmod3, rename, rm as
|
|
6483
|
-
import { join as
|
|
7300
|
+
import { chmod as chmod3, rename, rm as rm4 } from "fs/promises";
|
|
7301
|
+
import { join as join10 } from "path";
|
|
6484
7302
|
var updateUsage = ` update [--version <v>] [--cli] [--yes]
|
|
6485
7303
|
Update this CLI to the latest release (or <v>), then the server on this
|
|
6486
7304
|
machine, if there is one. --cli: only the CLI`;
|
|
@@ -6535,11 +7353,11 @@ async function replaceBinary(version) {
|
|
|
6535
7353
|
}
|
|
6536
7354
|
const check = Bun.spawnSync([temp, "help"], { stdout: "ignore", stderr: "ignore" });
|
|
6537
7355
|
if (check.exitCode !== 0) {
|
|
6538
|
-
await
|
|
7356
|
+
await rm4(temp, { force: true });
|
|
6539
7357
|
throw new CliError(`the downloaded binary doesn't run on this machine; kept ${CLI_VERSION}`);
|
|
6540
7358
|
}
|
|
6541
7359
|
if (process.platform === "win32") {
|
|
6542
|
-
await
|
|
7360
|
+
await rm4(`${target}.old`, { force: true });
|
|
6543
7361
|
await rename(target, `${target}.old`);
|
|
6544
7362
|
}
|
|
6545
7363
|
await rename(temp, target);
|
|
@@ -6578,7 +7396,7 @@ async function update2(args) {
|
|
|
6578
7396
|
}
|
|
6579
7397
|
}
|
|
6580
7398
|
const dir = (await loadConfig()).server;
|
|
6581
|
-
const installed = dir && await Bun.file(
|
|
7399
|
+
const installed = dir && await Bun.file(join10(dir, "compose.yml")).exists();
|
|
6582
7400
|
if (installed && !args.includes("--cli")) {
|
|
6583
7401
|
let go = yes || jsonMode;
|
|
6584
7402
|
if (!go && process.stdin.isTTY) {
|
|
@@ -6608,7 +7426,7 @@ var usersUsage = ` users enable <project> Turn on users: an auth schema
|
|
|
6608
7426
|
users revoke <project> <user-id> [<session-id>] Sign a user out (one session, or everywhere)
|
|
6609
7427
|
users delete <project> <user-id> [--yes] Delete a user and their sessions
|
|
6610
7428
|
users disable <project> [--yes] Turn it off, deleting every user account`;
|
|
6611
|
-
var
|
|
7429
|
+
var base10 = (project) => `/projects/${encodeURIComponent(project)}/users`;
|
|
6612
7430
|
function authSnippet(project, apiUrl) {
|
|
6613
7431
|
return `// auth.ts: npm i better-auth pg @mybase-dev/sdk
|
|
6614
7432
|
import { betterAuth } from "better-auth";
|
|
@@ -6639,7 +7457,7 @@ async function users(sub, args) {
|
|
|
6639
7457
|
case "enable": {
|
|
6640
7458
|
const p = need(project, "project name");
|
|
6641
7459
|
const { linked } = await enableService(p, "users", flags);
|
|
6642
|
-
const o = await api(
|
|
7460
|
+
const o = await api(base10(p));
|
|
6643
7461
|
print({ enabled: "users", project: p, linked, ...o }, () => {
|
|
6644
7462
|
console.log(`Users enabled for "${p}". Better Auth's tables go in the "${o.schema}" schema.
|
|
6645
7463
|
`);
|
|
@@ -6653,7 +7471,7 @@ async function users(sub, args) {
|
|
|
6653
7471
|
}
|
|
6654
7472
|
case "init": {
|
|
6655
7473
|
const p = need(project, "project name");
|
|
6656
|
-
const o = await api(
|
|
7474
|
+
const o = await api(base10(p));
|
|
6657
7475
|
const env = flagString(flags, "env");
|
|
6658
7476
|
let stored = null;
|
|
6659
7477
|
if (env) {
|
|
@@ -6676,7 +7494,7 @@ Set BETTER_AUTH_SECRET to 32+ random bytes (openssl rand -base64 32), or rerun w
|
|
|
6676
7494
|
case "list": {
|
|
6677
7495
|
const p = need(project, "project name");
|
|
6678
7496
|
const search = flagString(flags, "search");
|
|
6679
|
-
const list = await api(`${
|
|
7497
|
+
const list = await api(`${base10(p)}/accounts?limit=100${search ? `&search=${encodeURIComponent(search)}` : ""}`);
|
|
6680
7498
|
print(list, () => {
|
|
6681
7499
|
if (list.users.length === 0)
|
|
6682
7500
|
console.log(search ? "No users match." : "No users yet.");
|
|
@@ -6689,7 +7507,7 @@ Set BETTER_AUTH_SECRET to 32+ random bytes (openssl rand -base64 32), or rerun w
|
|
|
6689
7507
|
}
|
|
6690
7508
|
case "show": {
|
|
6691
7509
|
const p = need(project, "project name");
|
|
6692
|
-
const u = await api(`${
|
|
7510
|
+
const u = await api(`${base10(p)}/accounts/${encodeURIComponent(need(userId, "user id"))}`);
|
|
6693
7511
|
print(u, () => {
|
|
6694
7512
|
console.log(`${u.email ?? u.id}${u.name ? ` (${u.name})` : ""}${u.banned ? " BANNED" : ""}`);
|
|
6695
7513
|
console.log(` id ${u.id}`);
|
|
@@ -6707,10 +7525,10 @@ Active sessions (${u.sessions.length}):`);
|
|
|
6707
7525
|
const p = need(project, "project name");
|
|
6708
7526
|
const id = encodeURIComponent(need(userId, "user id"));
|
|
6709
7527
|
if (sessionId) {
|
|
6710
|
-
await api(`${
|
|
7528
|
+
await api(`${base10(p)}/accounts/${id}/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
|
|
6711
7529
|
print({ revoked: 1 }, () => console.log("Signed out that session."));
|
|
6712
7530
|
} else {
|
|
6713
|
-
const r = await api(`${
|
|
7531
|
+
const r = await api(`${base10(p)}/accounts/${id}/sessions`, { method: "DELETE" });
|
|
6714
7532
|
print(r, () => console.log(`Signed out ${r.revoked} session(s).`));
|
|
6715
7533
|
}
|
|
6716
7534
|
return true;
|
|
@@ -6719,7 +7537,7 @@ Active sessions (${u.sessions.length}):`);
|
|
|
6719
7537
|
const p = need(project, "project name");
|
|
6720
7538
|
const id = need(userId, "user id");
|
|
6721
7539
|
confirm(args.includes("--yes"), `This deletes user ${id} of "${p}" and signs them out.`, id, "the user id");
|
|
6722
|
-
await api(`${
|
|
7540
|
+
await api(`${base10(p)}/accounts/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
6723
7541
|
print({ deleted: id }, () => console.log(`Deleted user ${id}`));
|
|
6724
7542
|
return true;
|
|
6725
7543
|
}
|
|
@@ -6763,6 +7581,7 @@ var USAGE = {
|
|
|
6763
7581
|
users: usersUsage,
|
|
6764
7582
|
captcha: captchaUsage,
|
|
6765
7583
|
realtime: realtimeUsage,
|
|
7584
|
+
hosting: hostingUsage,
|
|
6766
7585
|
run: runUsage
|
|
6767
7586
|
};
|
|
6768
7587
|
var GLOBAL_FLAGS = ` --json Print results as JSON (errors too, on stderr)
|
|
@@ -6901,6 +7720,12 @@ async function main(args) {
|
|
|
6901
7720
|
if (await realtime(sub, rest))
|
|
6902
7721
|
return;
|
|
6903
7722
|
}
|
|
7723
|
+
if (command === "hosting" && sub) {
|
|
7724
|
+
if (await hosting(sub, rest))
|
|
7725
|
+
return;
|
|
7726
|
+
}
|
|
7727
|
+
if (command === "deploy")
|
|
7728
|
+
return deploy(args.slice(1));
|
|
6904
7729
|
if (command && command !== "help")
|
|
6905
7730
|
throw usageError(`unknown command: mybase ${args.filter((a) => !a.startsWith("-")).slice(0, 2).join(" ")}
|
|
6906
7731
|
|